Search
Duplicate

단위 테스트 1부 - 더 큰 그림

단위 테스트 - 생산성과 품질을 위한 단위 테스트 원칙과 패턴 책을 바탕으로 정리를 진행했습니다. 하단 북마크에 책 구매 링크를 연결해두었습니다.

 단위 테스트의 목표

단위 테스트 책에서 저자는 “단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 한다”고 말합니다.
[단위 테스트를 적용하는 모든 프로젝트가 이득을 얻고 있을까?]
책에서는 현재 대부분의 회사들이 단위 테스트를 프로젝트 내에 적용하고 있다고 말합니다. 하지만, 제대로 된 테스트를 작성하지 않고 테스트를 추가하는 행위에만 의미를 두어 테스트가 없느니만 못한 상황이 발생합니다.
[테스트 대상 코드에 문제가 있는건 아닐까?]
코드를 단위 테스트 할 수 있다는 건, 코드가 서로 충분히 분리되어서 따로 테스트할 수 있는 상황이 되었음을 의미합니다. 하지만, 단위 테스트를 할 수 있다는 게 코드 품질이 좋다는 걸 의미하지 않습니다.
또한, 나은 설계(프로젝트를 분리된 구조로 만들기)는 단위 테스트의 주요한 목표가 아닙니다.
[그럼, 단위 테스트의 목표는 뭘까?]
단위 테스트의 목표는 ”소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것” 입니다.
책에 따르면 테스트 코드가 포함되지 않은 프로젝트는 작업에 드는 시간이 가파르게 증가합니다. 처음에는 테스트 코드를 작성하는 시간이 포함되지 않아 작업 소요 시간이 테스트를 포함한 프로젝트보다 적게 들지만, 시스템이 복잡해지고 무질서해지면서 작업 소요 시간이 급격하게 늘어나게 됩니다.
테스트는 안전망 역할을 하며, 새로운 기능을 도입하거나, 새로운 요구사항을 적용한 방식으로 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인할 수 있게 해줍니다.
즉, 테스트를 통해 장기적으로 개발 속도를 유지할 수 있게 됩니다.
[그렇다면, 모든 테스트가 도움을 준다고 할 수 있는가?]
모든 테스트가 좋은 테스트라고 할 순 없습니다.
지속 가능한 프로젝트 성장을 위해서는 고품질 테스트에만 집중해야 합니다.
[고품질 테스트를 커버리지 지표를 통해서 가늠할 수 있는가?]
커버리지 지표는 “테스트 스위트가 소스 코드를 얼마나 실행하는지를 백분율”로 나타냅니다.
테스트가 소스 코드를 완전히 실행한다면 이를 100% 커버리지라고 말합니다.
하지만, 100% 커버리지가 양질의 테스트 스위트를 보장하지 않습니다.
즉, 테스트의 품질을 가늠하기엔 효과적인 방식이 아닙니다.
커버리지 지표가 모든 가능한 결과를 검증한다고 보장할 수 없으며, 외부 라이브러리 코드 경로를 고려할 수 없기 때문입니다.
[커버리지 지표는 불필요한 지표인가?]
커버리지 지표를 통해서 좋은 테스트를 가름하는건 어렵지만, 좋지 않은 테스트를 판단하는데 사용될 수 있습니다.
책에서는 커버리지 지표는 좋은 부정 지표이지만 나쁜 긍정 지표라고 말합니다.
즉, 커버리지 지표가 낮으면 테스트되지 않은 코드가 많으니 문제 징후라 볼 수 있지만, 높은 숫자가 좋은 테스트를 뜻한다고 볼 순 없다는 겁니다.
[그럼, 좋은 테스트를 확인하는 방법은 없는가?]
좋은 테스트가 가지고 있는 몇가지 특성이 있습니다.
좋은 테스트는 개발 주기에 통합되어 있어, 코드가 변경될 때마다 아무리 작은 것이라도 실행됨
가장 중요한 부분인 비즈니스 로직(도메인)에 더 많은 관심을 기울이고 있음
*위의 지침에 따르려면 도메인 모델이 중요하지 않은 부분과 분리되어 있어야 함
최소 유지비로 최대 가치를 달성해야 함

 단위 테스트란 무엇인가?

단위 테스트의 목표를 알았다면, 단위 테스트가 무엇인지도 알아봅시다.
단위 테스트에는 3가지 중요한 속성이 존재합니다.
작은 코드 조각(단위)을 검증
빠르게 수행 가능
격리된 방식으로 처리하는 자동화된 테스트
이 중, 3번째 속성 [격리된 방식]에서 두 가지 견해로 나뉘어 집니다.

런던파

런던파에게 격리는 “테스트 대상 시스템을 Collaborator에게서 격리하는 것”을 말합니다.
즉, 테스트 대상 시스템이 의존하고 있는 여러 클래스를 테스트 대역(Test Double)으로 대체해야 합니다.
테스트 대역으로 의존성을 대체하면서 테스트 대상 시스템(클래스)을 검증하는 테스트는 의존성과 별개로 수행되어 질 수 있게 됩니다.
또한, 의존성이 가지고 있는 또 다른 의존성을 다룰 필요도 없어집니다.
런던파 방식으로 테스트 작성해보겠습니다. *Swift
Student 클래스를 만들어두고 해당 클래스가 Calculator 프로토콜을 준수하는 타입을 주입받아서 사용하도록 코드를 작성했습니다.
final class Student { private let calculator: any Calculator init(calculator: any Calculator) { self.calculator = calculator } func calculate(_ expression: String) -> Int { // calculator 사용해서 계산 ... } }
Swift
복사
런던파는 테스트를 진행할 때, 테스트 대역으로 의존성을 대체합니다.
Student 클래스를 테스트하기 위해서 실제 사용하는 Calculator 타입을 사용하는 것이 아니라 Mock 타입을 생성합니다.
final class MockCalculator: Calculator { public func sum(_ a: Int, _ b: Int) -> Int { return a + b } public func minus(_ a: Int, _ b: Int) -> Int { return a - b } }
Swift
복사
Mock 타입을 Student에 주입하여 테스트를 진행합니다.
func test_student() { let calculator = MockCalculator() let sut = Student(calculator: calculator) let result = sut.calculate("1 + 2 + 3 - 1") XCTAssertEqual(result, 5) }
Swift
복사
Mock은 실제 상태와 상관없이 테스트가 요구하는 방식으로 요청에 응답할 수 있도록 코드를 작성할 수 있습니다.
사용중인 클래스와 비슷한 틀을 가진 Mock를 생성하기 위해서는 인터페이스(Protocol)가 필요합니다.
구체 클래스로 Mock를 만들 수도 있지만, 이는 안티 패턴입니다.

고전파

고전파는 “코드를 꼭 격리하는 방식으로 테스트해야 하는 것은 아니고, 대신 단위 테스트는 서로 격리해서 실행해야 한다”고 생각합니다.
단위 테스트의 격리는 테스트 순서에 영향 받지 않기 때문에 서로의 결과에 영향을 미치지 않게 됩니다.
따라서, 공유 상태에 도달하지 않는 한, 여러 클래스를 한 번에 테스트할 수 있습니다.
[공유 상태에 도달할 수 있는 부분은 테스트 시에 어떻게 하는가?]
이전에 런던파에서 요긴하게 사용했던 테스트 대역을 사용해서 공유 상태를 일으키는 의존성을 대체합니다.
즉, MockDatabase, MockFileSystem를 만들어서 문제를 해결합니다.
공유 의존성으로 해당 객체들을 대신하게 되면 테스트 실행 속도를 높일 수 있습니다.
공유 의존성에 대한 호출은 오래 걸리기 때문에, 실제 공유 의존성 객체를 가지고서 테스트를 진행하는 건 통합 테스트의 영역이라고 볼 수 있습니다.
결론적으로, 두 분파의 [협력자를 격리하는가], [단위 테스트끼리 격리하는가]에 대한 관점에서 의견이 나뉜 것으로 볼 수 있습니다.
이 관점에 따라서, 각 분파가 테스트 대역을 사용해야 한다고 생각하는 부분도 달라지게 되었습니다.
런던파가 말하는 [협력자]는 공유하거나 변경 가능한 의존성입니다. 따라서, 이러한 협력자들을 테스트 대역(Test Double)로 만들어서 사용하게 됩니다.
고전파는 [단위 테스트]가 서로 영향을 미치지 않게끔 격리하는 걸 중요하게 생각합니다. 따라서, 공유 의존성을 테스트 대역으로 만들어서 사용합니다.

 단위 테스트 구조

 단위 테스트 구성

AAA 패턴 사용

A(Arrange), A(Act), A(Assert) 패턴을 사용해서 테스트를 준비, 실행, 검증 부분으로 나눌 수 있습니다.
아래 코드에서 calculator, sut를 생성하는 부분이 준비, 실제로 메서드를 호출하는 부분이 실행, 마지막으로 실행된 결과를 검증하는 부분을 검증이라고 볼 수 있습니다.
func test_student() { // Arrange let calculator = MockCalculator() let sut = Student(calculator: calculator) // Act let result = sut.calculate("1 + 2 + 3 - 1") // Assert XCTAssertEqual(result, 5) }
Swift
복사
AAA 패턴을 사용하게 되면 모든 테스트가 단순하고 균일한 구조를 가질 수 있게 됩니다.
비슷한 패턴으로는 Given-When-Then 패턴이 있습니다.

테스트 내 if 문 피하기

테스트는 분기가 없는 간단한 일련의 단계여야 합니다.
if 문은 한 번에 너무 많은 것을 검증한다는 뜻이기 때문에 이러한 테스트는 여러 테스트로 나눠야 합니다.

각 구절의 크기

[준비 구절이 가장 큰 경우]
준비 구절은 일반적으로 실행, 검증 구절보다 큽니다.
하지만, 준비 구절을 줄이고 싶다면 비공개 메서드나 팩토리 클래스를 사용할 수 있습니다.
또한, 테스트에 사용되는 여러 example object 생성을 도와주는 Object Mother 패턴이나, 원하는 테스트 객체 생성을 할 수 있게 Test Data Builder 패턴을 사용해서 준비 구절을 줄일 수도 있습니다.
[실행 구절이 한 줄 이상인 경우를 경계하라]
실행 구절은 보통 코드 한 줄인데, 두 줄 이상인 경우 sut의 공개 API에 문제가 있을 수도 있습니다.
테스트에 큰 문제는 없지만 단일 작업을 수행하는데 두 줄 이상의 메서드 호출이 필요하다는 것이 이상하기 때문입니다.
무엇이 이상하다는 건지 설명하기 위해서, 이전에 예시로 들었던 Student 클래스 가져와 보겠습니다.
func test_student() { // Arrange let calculator = MockCalculator() let sut = Student(calculator: calculator) // Act let result = sut.calculate("1 + 2 + 3 - 1") // Assert XCTAssertEqual(result, 5) }
Swift
복사
현재 Student 클래스 테스트는 하나의 실행 구절을 가지고 있습니다.
calculate라는 메서드가 호출되면 메서드 내에서 알아서 계산한 다음에 result를 넘겨주기 때문입니다.
하지만, 코드가 아래와 같다면 어떨까요?
func test_student() { // Arrange let calculator = MockCalculator() let sut = Student(calculator: calculator) // Act let chars = sut.split("1 + 2 + 3 - 1") let result = sut.calaulate(chars) // Assert XCTAssertEqual(result, 5) }
Swift
복사
위의 calculate 메서드 내에서 진행하고 있는 [String를 Character로 분리], [분리된 Character를 Operand인지, Operator인지 확인 후 계산] 기능이 각각의 메서드로 존재하게 된다면 테스트 코드를 이렇게 작성해야 할 겁니다.
일반적으로 문장에 대한 split과 계산은 한 번에 될 것이라고 생각합니다.
즉, 두 줄 이상의 메서드 호출이 이상하다는 건 단일한 메서드로 존재할 수 있는 부분이 분리되었다는 측면에서 이상하다는 겁니다.
그렇다고, 반드시 두 줄 이상으로 작성하지 말라고 할 수 없습니다.

검증문의 크기

단위 테스트의 단위는 동작의 단위이지 코드의 단위가 아닙니다.
따라서, 동작의 단위는 여러 결과를 낼 수 있고 하나의 테스트로 모든 결과를 평가하는 것이 좋습니다.
하지만, 그 상황에서 검증 구절이 너무 커지는 것은 경계해야 합니다.

 테스트 간 테스트 픽스처 재사용

일반적으로 테스트 실행 대상 객체를 준비 코드에서 생성할 때에 많은 코드가 작성됩니다.
따라서, 이 부분을 별도의 메서드나 클래스로 도출하여 테스트 간에 재사용하는 방식으로 사용하게 됩니다.
가장 쉽게 사용하는 방법이 바로 테스트 생성자(setUp)에서 객체를 초기화하는 방식입니다.
var sut: Student! override func setUp() { super.setUp() let calculator = MockCalculator() sut = Student(calculator: calculator) }
Swift
복사
저자는 테스트 생성자에서 객체를 초기화하는 방식을 올바르지 않은 방식이라고 말합니다.
해당 방식은 테스트 코드의 양을 크게 줄일 수 있습니다만, 두 가지 단점이 있습니다.
테스트 간 결합도가 높아집니다.
테스트 생성자에서 테스트 픽스처를 초기화하기 때문에 모든 테스트가 서로 결합되어 있게 됩니다.
즉, 준비 로직을 수정하게 되면 모든 테스트에 영향을 미칠 수 밖에 없습니다.
이는 테스트를 수정해도 다른 테스트에 영향을 주어서는 안된다는 지침을 어깁니다.
이를 지키기 위해서는 테스트 클래스에 공유 상태를 두면 안됩니다.
테스트 가독성이 떨어집니다.
생성자로 준비 로직을 보내면서 테스트만 보고는 전체 그림을 볼 수 없게 됩니다.
따라서, 저자는 준비 로직이 별로 없더라도 테스트 메서드로 준비 로직을 옮기는 것이 좋다고 말합니다.
즉, 독립적인 테스트에 대한 불확실성을 두면 안된다고 말합니다.
[그렇다면, 테스트 픽스처를 어떻게 하면 잘 재사용할 수 있을까?]
테스트 클래스에 비공개 팩토리 메서드를 두는 방식을 사용할 수 있습니다.
공통된 초기화 코드를 메서드로 추출해 테스트 코드를 짧게 하면서, 동시에 테스트 진행 상황에 대한 전체 맥락을 유지할 수 있게 됩니다.
func test_student() { let calculator = MockCalculator() let sut = createStudent(with: calculator) let result = sut.calculate("1 + 2 + 3 - 1") XCTAssertEqual(result, 5) } private func createStudent(with calculator: any Calculator) -> Student { return Student(calculator: calculator) }
Swift
복사
하지만, 위의 예제는 준비 로직이 매우 간단하기 때문에 굳이 팩토리 메서드를 둘 필요가 없습니다.
테스트 픽스처를 생성자에서 초기화하는 방식은 지양하는 방식이지만, 저자는 테스트 전부 또는 대부분에 사용되는 픽스처에 경우에는 생성자에 픽스처를 인스턴스화할 수 있다고 말합니다.

 단위 테스트 명명법

테스트에 올바른 명칭을 붙이는 건 중요합니다.
올바른 명칭은 테스트가 검증하는 내용과 기본 시스템의 동작을 이해하는 데 도움이 됩니다.
저자는 “문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자” 라고 합니다.
이에 대한 예시로 엄격한 명명 정책으로 작성된 테스트 이름을 수정하는 과정을 보여줍니다.
잘못된 배송 날짜를 올바르게 식별하는지 검증하는 테스트를 예시로 듭니다.
func test_isDeliveryValid_InvalidDate_ReturnsFalse()
Swift
복사
위의 이름을 보고 우리는 테스트를 한 눈에 이해하기 어렵습니다. 수정해봅시다.
func test_delivery_with_invalid_date_should_be_considered_invalid()
Swift
복사
이제 이름이 프로그래머가 아닌 사람들에게 납득되고, 마찬가지로 프로그래머도 더 쉽게 이해할 수 있게 수정되었습니다. 하지만, 이상적이진 않습니다.
테스트는 동작 단위에 대한 사실입니다. 따라서, 소망이나 욕구를 이름에 포함해선 안됩니다.
func test_delivery_with_a_past_date_is_invalid()
Swift
복사
처음과는 다르게 테스트 대상의 동작 관점에서 단도직입적으로 나타내는 이름이 되었습니다.
[왜 SUT의 메서드 이름을 테스트 이름에 추가해선 안될까?]
코드를 테스트하는 것이 아니라 동작을 테스트하는 것이라는 점에서 SUT의 메서드 이름을 테스트에 추가하는 건 적절하지 않습니다.
하지만, 유틸리티 코드는 예외로 적용됩니다.
유틸리티 코드는 비즈니스 로직이 없고, 코드의 동작이 단순한 보조 기능에서 크게 벗어나지 않으므로 비즈니스 담당자에게는 아무런 의미가 없습니다. 따라서, SUT 메서드 이름을 사용해도 괜찮습니다.

 매개변수화된 테스트 리팩터링

테스트 하나로는 동작 단위를 완전하게 설명하기 어렵습니다.
따라서, 우리는 여러 가지 테스트를 작성하게 됩니다. 이 과정에서 Parameterized Test는 유사한 테스트를 묶는데 도움을 줍니다.
WWDC24에서 발표된 Testing 기능도 매개변수화된 테스트 기능을 지원합니다.
따라서, Testing을 사용해서 예시 코드를 작성해보겠습니다.
@Test( arguments: [ "1 + 2 + 3 - 1", "2 + 4 - 1", "7 - 3 + 1" ] ) func calculated_Result_Is_Valid(expression: String) async throws { let calculator = MockCalculator() let sut = createStudent(with: calculator) let result = sut.calculate(expression) #expect(result == 5) }
Swift
복사
위에서 작성했던 expression 테스트 코드를 Testing 방식으로 수정하면서, Parameterized Test로 변경했습니다. Parameterized Test를 사용하면 유사한 테스트를 하나의 테스트 메서드로 묶을 수 있기 때문에 테스트 코드의 양을 크게 줄일 수 있게 됩니다.
하지만, 그만큼 비용이 발생합니다. 테스트 메서드가 나타내는 사실을 전보다 알기 어려워진다는 겁니다.
위에 예시에서도 알 수 있지만, calculated_Result_Is_Valid 메서드를 보고 우리는 결과가 타당한지 확인하는 테스트가 있다는 것만 알 수 있을 뿐 어떤 값을 테스트하고 있는지 쉽지 인지할 수 없습니다.
따라서, 매개변수화된 테스트를 통해서 긍정적인 테스트 케이스와 부정적인 테스트 케이스를 판단하지 못하는 상태라면 이를 각각의 테스트로 분리할 필요가 있습니다.
또한, 테스트를 하는 동작이 너무 복잡한 상태라면 매개변수화된 테스트를 사용해선 안됩니다.