1. 기능뿐 아니라 동작을 시험하라함수당 하나의 테스트 케이스만 있으면 적절하지 않을 때가 많다해결책 : 각 동작을 테스트하는 데 집중하라모든 동작이 테스트 되었는지 거듭 확인하라오류 시나리오를 잊지 말라2. 테스트만을 위해 퍼블릭으로 만들지 말라프라이빗 함수를 테스트하는 것은 바람직하지 않을 때가 많다해결책 : 퍼블릭 API 를 통해 테스트하라해결책 : 코드를 더 작은 단위로 분할하라3. 한 번에 하나의 동작만 테스트하라여러 동작을 한꺼번에 테스트하면 테스트가 제대로 안 될 수 있다해결책 : 각 동작은 자체 테스트 케이스에서 테스트하라매개변수를 사용한 테스트4. 공유 설정을 적절하게 사용하라상태 공유는 문제가 될 수 있다해결책 : 상태를 공유하지 않거나 초기화하라설정 공유는 문제가 될 수 있다해결책 : 중요한 설정은 테스트 케이스 내에서 정의하라설정 공유가 적절한 경우5. 적절한 어서션 확인자를 사용하라부적합한 확인자는 테스트 실패를 잘 설명하지 못할 수 있다해결책 : 적절한 확인자를 사용하라6. 테스트 용이성을 위해 의존성 주입을 사용하라하드 코딩된 의존성은 테스트를 불가능하게 할 수 있다해결책 : 의존성 주입을 사용하라
1. 기능뿐 아니라 동작을 시험하라
각 함수를 테스트하는 데만 집중할 때의 문제점은 한 함수가 종종 여러 개의 동작을 수행할 수 있고 한 동작이 여러 함수에 걸쳐 있을 수 있다는 점이다.
단순히 눈에 보이는 대로 함수 이름을 테스트 목록에 넣기보다는 함수가 수행하는 모든 동작으로 목록을 채우는 것이 좋다
함수당 하나의 테스트 케이스만 있으면 적절하지 않을 때가 많다
class MortgageAssessor { private const Double MORTGAGE_MULTIPLIER = 10.0; MortgageDecision assess(Customer customer) { if (!isEligibleForMortgage(customer)) { return MortgageDecision.rejected(); } return MortgageDecision.approve(getMaxLoanAmount(customer)); } }
testAssess() { Customer .. MorgageDecision decision = mortgageAsssessor.assess(customer); assertThat(decision.isApproved()).isTrue(); assertThat(decision.getMaxLoanAmount()).isEqualTo( new MonetaryAmount(300000,Currency.USD)); }
- 위 테스트 코드는 아래 내용만 테스트함
- 신용 등급이 좋고, 기존 대출이 없으며, 대출이 금지되지 않은 고객에게 대출 승인된다
- 최대 대출금액은 고객의 수입에서 지출을 뺀 금액에 10을 곱한 금액
이 하나의 테스트 케이스는
MorgageAssessor.assess()
함수가 올바른 방식으로 동작하는지 확인하기에 충분하지 않다.해결책 : 각 동작을 테스트하는 데 집중하라
하나의 함수에 집중하기보다는 궁극적으로 중요한 모든 행동을 파악하고 각각에 대한 테스트 케이스가 있는지 확인하는 것이 더 효과적이다.
MortgageAssessor 클래스에서 다음과 같은 동작이 관심의 대상이 됨
- 다음 중 적어도 하나에 해당하는 고객에 대해서는 담보대출 신청이 기각됨
- 신용 등급이 좋지 않음
- 이미 대출이 있음
- 대출이 금지된 고객
- 주택 담보대출 신청이 받아들여진다면 최대 대출금액은 고객의 수입에서 지출을 뺀 금액에 10을 곱한 금액이다.
이러한 각 동작은 테스트되어야 하며, 따라서 작성해야 할 테스트 케이스는 훨씬 많다.
실제 코드가 100줄인데 테스트 코드가 300줄인 경우가 드물지 않다. 테스트 코드의 양이 실제 코드의 양보다 많지 않다면, 모든 동작이 제대로 테스트되고 있지 않음 을 나타내는 경고 표시일 수 있다.
모든 동작이 테스트 되었는지 거듭 확인하라
아래와 같은 질문을 코드를 검토하는 과정에서 던져보라. 하나라도 예 라면 모든 행동이 테스트 되고 있지 못하다는 것을 의미함
- 삭제해도 여전히 컴파일 되거나 테스트가 통과하는 코드 라인이 있는가?
- if 문(또는 이와 동등한 기능의 문장)의 참 거짓 논리를 반대로 해도 테스트가 통과하는가? (예:
if(something) {
을if (!something) {
으로 변경)
- 논리 연산자나 산술 연산자를 다른 것으로 대체해도 테스트가 통과하는가? (예 : && 을 ||로 변경. +를 -로 변경)
- 상숫값이나 하드 코딩된 값을 변경해도 테스트가 통과하는가?
요점은 테스트 대상 코드의 각 줄, if 문, 논리 표현식, 값 등 은 그것이 존재하는 이유가 있어야 한다는 것. 불필요한 코드라면 그것은 제거되어야 함. 불필요한 것이 아니라면 그것은 어떻게든 그것에 의존하는 어떤 중요한 행동이 있다는 것을 의미 → 테스트 케이스가 있어야 함
오류 시나리오를 잊지 말라
오류 시나리오가 발생할 때 코드가 어떻게 동작하는 지도 테스트되어야 한다.
2. 테스트만을 위해 퍼블릭으로 만들지 말라
프라이빗 함수를 테스트하는 것은 바람직하지 않을 때가 많다
testIsEligibleMortgage_badCreditRating_ineligible() { Customer customer = new Customer( income: new MonetaryAmouint(..) ... ) assertThat(MortgageAssessor.isEligibleForMortgage(customer)).isFalse(); }
프라이빗 함수를 퍼블릭으로 만든 후에 테스트할 때의 문제는 아래와 같다
- 해당 테스트는 실제로 우리가 신경 쓰는 행동을 테스트하는 것이 아니다. 고객이 신용 등급이 나쁘면 주택 담보 대출 신청이 거절되는 것이 우리가 신경 써야 하는 결과지, 신용등급이 나쁜 고객으로 판단하는 함수를 호출하는 것이 중요한 것이 아님
- 이렇게 되면 테스트가 구현 세부사항에 독립적이지 못하게 된다. isEligibleForMortgage() 라는 프라이빗 함수가 있다는 사실은 구현 세부사항. 코드를 리팩터링 하며 함수 이름을 바꾸거나 별도의 헬퍼 클래스로 옮기게 되면 실패하는 테스트가 있을 수 있다.
- private 함수를 테스트하기 위해 오픈하면, MorgageAssessor 클래스의 퍼블릭 API 를 변경한 효과를 가짐. 다른 개발자가 해당 함수를 사용하게 될 수 있음
해결책 : 퍼블릭 API 를 통해 테스트하라
MorgageAssessor 클래스의 경우 실제로 중요하게 확인해야 할 행동은 신용등급이 나쁜 고객에 대해 모기지 신청을 거절하는 것이다.
이는 클래스의 퍼블릭 API 인 MorgageAssessor.assess() 함수를 호출해 테스트가 가능하다.
testAssess_badCreditRating_mortgageRejected() { Customer customer = MorgageAssessor mortgageAssessor = new MortgageAssessor(); MortgageDecision decision = mortgageAssessor.assess(customer); assertThat(decision.isApproved()).isFalse(); }
해결책 : 코드를 더 작은 단위로 분할하라
위의 사례에서는 고객의 신용등급이 좋은지 판단하는 논리가 비교적 간단함. 그러나 해당 부분이 복잡한 논리를 갖게 되면 이 함수를 퍼블릭으로 만들어 테스트하고자 하는 마음이 들 수 있다.
class MortgageAssessor { // isCreditRatingGood 함수가 테스트를 위해 공개 Result<Boolean, Error> isCreditRatingGood(Int customerId) { CreditScoreResponse response = creditScoreService.query(customerId); } }
위의 상황에서는 더 근본적인 문제점이 MortgageAssessor 클래스가 하는 일이 너무 많다는 점이다.
추상화 계층을 논의할 때, 하나의 클래스가 너무 많은 여러 개념을 다루지 않도록 하는 것이 바람직하다는 점을 살펴보았음.
MortgageAssessor 클래스는 많은 다양한 개념을 포함하고 있기 때문에 이 클래스를 더 작은 계층으로 나누는 것이 해결책임 → 고객의 신용등급이 좋은지 판단하는 논리를 별도의 클래스로 옮기는 것
class MortgageAssesor { private final CreditRatingChecker creditRatingChecker; private Result<Boolean, Error> isEligibleForMortgage(Customer customer) { ... return creditRatingChecker.isCreditRatingGood(customer.getId()); } } class CreditRatingChecker { Result<Boolean, Error> isCreditRatingGood(Int customerId) { ... } }
위와 같이 나눔으로써 둘다 쉽게 테스트가 가능해 진다.
3. 한 번에 하나의 동작만 테스트하라
많은 경우, 각각의 동작을 테스트하려면 약간 다른 시나리오를 설정해야 하므로, 각각의 시나리오는 그에 해당하는 별도의 테스트 케이스로 테스트하는 것이 가장 자연스럽다
여러 동작을 한꺼번에 테스트하면 테스트가 제대로 안 될 수 있다
List<Coupon> getValidCoupons(List<Coupon> coupons, Customer customer) { return coupons.filter(coupon -> !coupon.alreadyRedeemed()) .filter(coupon -> !coupon.hasExpired()) .filter(coupon -> coupon.issuedTo() == customer) .sortBy(coupon -> coupon.getValue(), SortOrder.DESCENDING); } void testGetValidCoupons_allBehaviors() { Customer customer1 = new Customer("test customer 1"); ... List<Coupon> validCoupons = getValidCoupons( [redeemed, expired, issuedToSomeoneElse, valid1, valid2], customer1); assertThat(validCoupons).containsExactly(valid2, valid1).inOrder(); }
- 위 함수는 몇 가지 중요한 동작을 수행
- 유효한 쿠폰만 반환
- 이미 사용된 쿠폰은 유효하지 않은 것으로 간주
- 유효기간이 지난 쿠폰은 유효하지 않은 것으로 간주
- 함수 호출 시 지정된 고객이 아닌 다른 고객에게 발행한 쿠폰은 유효하지 않은 것으로 간주한다.
- 반환할 쿠폰은 내림차순으로 정렬
한 번에 모든 동작을 테스트하는 것은 잘 설명된 실패에도 해당되지 않는다. 동작 중 하나가 실패했을 때 위 테스트 케이스가 실패하고, 그 실패 메시지로 어떤 동작이 실패했는지 알 수가 없다. (한번에 모든 동작이 테스트 되고 있으므로)
테스트 코드가 이해하기 어렵고 통과하지 못한 경우 이유를 제대로 설명하지 않으면, 다른 개발자의 시간을 낭비할 뿐 아니라 버그가 발생할 가능성도 커진다.
해결책 : 각 동작은 자체 테스트 케이스에서 테스트하라
훨씬 더 나은 접근법은 잘 명명된 테스트 케이스를 사용하여 각 동작을 개별적으로 테스트하는 것이다.
이렇게 했을 시, 실패 했을 때 어떤 동작에 문제가 있는지 정확하게 알 수 있다.
그러나, 코드 중복이 많아지는 단점이 있긴 함. 이런 코드 중복을 줄이는 한 가지 방법은 매개변수화된 테스트를 사용하는 것
매개변수를 사용한 테스트
[TestCase(true, false, TestName = "이미 사용함")] [TestCase(false, true, TestName = "유효기간 만료")] void testGetValidCoupons_excludesInvalidCoupons( Boolean alreadyRedeemed, Boolean hasExpired) { Customer customer = new Customer("test customer"); Coupon coupon = new Coupon( alreadyRedeeme: alreadyRedeemed, hasExpired : hasExpired, issuedTo : customer, value: 100); List<Coupon> validCoupons = getValidCoupons([coupon], customer); assertThat(validCoupons).isEmpty(); }
매개변수를 사용한 테스트는 많은 코드를 반복하지 않고도 모든 동작을 테스트할 수 있는 좋은 도구다.
테스트 케이스에서 매개변수를 사용하기 위해 설정하는 구문과 방법은 테스트 프레임워크마다 다를 수 있다.
4. 공유 설정을 적절하게 사용하라
테스트 케이스는 의존성을 설정하거나 테스트 데이터 저장소에 값을 채우거나 다른 종류의 상태를 초기화하는 등 어느 정도의 설정이 필요할 때가 있음
- 상태 공유 : 설정 코드가 BeforeAll 블록에 추가되면 모든 테스트 케이스 전에 한 번 실행됨. 설정된 모든 상태가 모든 테스트 케이스 간에 공유됨
- 설정 공유 : 설정 코드가 BeforeEach 블록에 추가되면 각 테스트 케이스가 실행되기 전에 실행된다. 테스트 케이스는 이 코드에 의한 모든 설정을 공유함
상태 공유는 문제가 될 수 있다
한 테스트 케이스가 수행하는 모든 조치는 다른 테스트 케이스의 결과에 영향을 미치지 않아야 한다. 테스트 케이스 간에 상태를 공유하고 이 상태가 가변적이면 이 규칙을 실수로 위반하기 쉬움
class OrderManagerTest { @BeforeAll void oneTimeSetUp() { database = Database.createInstance(); database.waitForReady(); } void testProcessOrder_outOfStockItem_orderDelayed() { assertThat(database.getOrderStatus(orderId)).isEqualTo(OrderStatus.DELAYED); } void testProcessOrder_paymentNotComplete_orderDelayed() { // 설정이 공유되면, 이 테스트 코드가 테스트하는 코드에 문제가 있더라도, 위에서 이미 통과했기에 // 테스트가 성공할 수 있음 assertThat(database.getOrderStatus(orderId)).isEqualTo(OrderStatus.DELAYED); } }
해결책 : 상태를 공유하지 않거나 초기화하라
데이터베이스 인스턴스를 테스트 케이스 간에 공유하면 문제가 생기기에, 이 경우 각 테스트 케이스 간에 반드시 상태가 초기화되도록 많은 주의를 기울여야 한다.
@AfterEach void tearDown() { database.reset(); }
설정 공유는 문제가 될 수 있다
class OrderPostageManagerTest { private Order testOrder; @BeforeEach void setUp() { testOrder = new Order( customer: new Customer(address: new Address("Test addresss")), items: [ new Item(name: "Test item 1"), new Item(name: "Test item 2"), new Item(name: "Test item 3"), ]); } }
위의 테스트 코드를 다른 테스트 코드 작성자가 Item을 한개 추가했다고 가정하자.
근데, 위 testOrder를 참고하는 테스트 코드가 Item이 꼭 3개만 있어야 하는 테스트 코드라 하면, 해당 테스트 코드가 실패하게 됨(
testGetPostageLabel_threeItems_largePackage
)void testGetPostageLabel_threeItems_largePackage() { PostageManager postageManager = new PostageManager(); PostageLabel label = postageManager.getPostageLabel(testOrder); assertThat(label.isLargePackage()).isTrue(); }
위 테스트 케이스의 핵심은 정확히 세 개의 항목이 있을 때 발생하는 결과를 테스트하는 것
일반적으로 테스트 케이스에 중요한 값이나 상태는 공유하지 않는 것이 최선임.
설정을 공유하면 어떤 테스트 케이스가 어떤 특정 항목에 의존하는지 정확하게 추적하는 것은 매우 어려우며, 향후 변경 사항이 발생하면 테스트 케이스가 원래 목적 했던 동작을 더 이상 테스트하지 않게 될 수 있다.
해결책 : 중요한 설정은 테스트 케이스 내에서 정의하라
테스트 케이스가 특정 값이나 설정 상태에 의존한다면 그렇게 하는 것이 더 안전한 경우가 많다.
테스트 케이스의 결과가 설정값에 직접 영향을 받는 경우 해당 테스트 케이스 내에서 설정하는 것이 가장 좋음
설정 공유가 적절한 경우
공유가 필요한 경우는 테스트 케이스의 결과에 직접적인 영향을 미치지는 않는 설정에 대해서임
Order 클래스의 인스턴스를 생성하려면 오더에 대한 일부 메타데이터가 필요하다고 가정하자.
테스트 케이스 결과는 이 메타데이터와 전혀 관련이 없다. 그러나 메타데이터 없이 Order 클래스의 인스턴스를 생성할 수 없기에 메타데이터는 설정이 되어야 함. 이럴 때 공유해서 한번만 정의하기.
5. 적절한 어서션 확인자를 사용하라
부적합한 확인자는 테스트 실패를 잘 설명하지 못할 수 있다
void testGetClassNames_containsCustomClassNames() { TextWidget textWidget = new TextWidget(["custom_class_1", "custom_class_2"]); assertThat(textWidget.getClassNames()).isEqualTo( ["text-widget", "selectable", "custom_class_1","custom_class_2"]); }
- 위의 테스트 케이스는 class Name의 리스트의 순서도 제한하고
- 굳이 테스트에서 검증이 필요하지 않은 text-widget과 selectable도 포함되어 있음
- 그리고 테스트가 실패할 시 무슨 이유 때문에 실패하는지도 알수 없음
해결책 : 적절한 확인자를 사용하라
assertThat(textWidget.getClassNames()) .containsAtLeast("custom_class_1", "custom_class_2")
코드에 문제가 있을 때 테스트가 반드시 실패해야 한다는 점 외에도 테스트가 어떻게 실패 할지에 대해 생각해보는 것이 중요하다.
적절한 어서션 확인자를 사용하면 테스트 실패 시 실패의 이유에 대해 잘 알 수 있지만, 그렇지 않으면 실패의 이유를 명확히 파악하기 어렵기에 테스트 코드를 실행할 때 다른 개발자가 어려움을 겪을 수 있다.
6. 테스트 용이성을 위해 의존성 주입을 사용하라
하드 코딩된 의존성은 테스트를 불가능하게 할 수 있다
class InvoiceReminder { private final AddressBook addressBook; private final EmailSender emailSender; InvoiceReminder() { this.addressBook = DataStore.getAddressBook(); this.emailSender = new EmailSenderImpl(); } }
- 실제 addressBook과 emailSender를 이용하기 때문에 실제 고객 데이터베이스의 연락처 정보를 조회하게 되고, 이메일을 실제로 보내게 됨
- 손쉬운 해결책은 테스트 더블을 사용하는 것인데, 의존성 주입이 불가능해서 이 방법을 사용할 수가 없음
해결책 : 의존성 주입을 사용하라
class InvoiceReminder { private final AddressBook addressBook; private final EmailSender emailSender; InvoiceReminder(AddressBook addressBook, EmailSender emailSender) { this.addressBook = addressBook; this.emailSender = emailSender; } }
- 서로 다른 코드가 느슨하게 결합하고 재설정이 가능하면, 테스트는 훨씬 더 쉬워지는 경향을 띤다.