[테스트 주도 개발 시작하기] CHAPTER 3 - 테스트 코드 작성 순서

myeonji·2023년 1월 5일
1
  • 테스트 작성 순서
  • 작성 순서 예제
  • 테스트 목록

테스트 코드 작성 순서

앞서 구현했던 암호 강도 측정기를 예시를 이어서 진행한다.

구현하기 쉬운 테스트부터 시작하기

  • 모든 조건을 충족하는 경우
  • 모든 조건을 충족하지 않는 경우

chap2에서는 모든 조건을 충족하는 경우를 첫 번째 테스트로 시작했다.

// PasswordStrengthMeterTest 클래스
@Test
void meetsAllCriteria_Then_Strong() {
	PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab12!@AB");
    assertEquals(PasswordStrength.STRONG, result);
}

// PasswordStrengthMeter 클래스
public class PasswordStrengthMeter {
	public PasswordStrength meter(String s) {
    	return PasswordStrength.STRONG;
    }
}

위의 첫 번째 테스트를 통과시킨 뒤에 다음 테스트는 무엇을 해야할까?
STRONG(강함)에 해당하는 예를 추가해도 된다.

// PasswordStrengthMeterTest 클래스
@Test
void meetsAllCriteria_Then_Strong() {
	PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab12!@AB");
    assertEquals(PasswordStrength.STRONG, result);
    PasswordStrength result2 = meter.meter("abc1!Add");
    assertEquals(PasswordStrength.STRONG, result2);
}

당연히 STRONG으로 통과가 된다.

다음으로는 어떤 테스트를 추가할지 생각해본다.
구현하기 쉬운 것이 선택 기준이 된다.

  • 모든 규칙을 충족하지 않는 경우
  • 한 규칙만 충족하는 경우
  • 두 규칙을 충족하는 경우

모든 규칙을 충족하지 않는 경우는 정반대 조건이므로, 결국 모든 규칙을 검사하는 코드를 구현해야 할 것이다.
따라서 한 번에 구현해야 할 코드가 너무 많아진다.

한 규칙만 충족하는 경우는 한 규칙을 충족하는지 여부를 검사해서 WEAK를 리턴하면 되고, 두 규칙을 충족하는 경우는 한 규칙을 충족하는지 검사해서 충족하지 않으면 NORMAL를 리턴하면 될 것이다.

따라서 모든 규칙을 충족하지 않는 경우보다 한 규칙 또는 두 규칙을 충족하는 경우를 테스트 하는 것이 더 쉽게 구현할 수 있으리라 예상했다.

그렇다면 여러 규칙 중에서 어떤 규칙을 검사하는 것이 더 쉬울지 확인해보았다.
대문자 포함 여부나 숫자 포함 여부를 검사하는 것보다 8글자 이상인지 검사하는 것이 더 쉬울 것 같다.

그래서 다음 테스트는 길이만 8글자 미만이고 나머지 규칙은 충족하는 암호의 강도는 보통 으로 정했다.

하나의 테스트를 통과했으면 그 다음으로 구현하기 쉬운 테스트를 선택해야 한다. 보통 수 분 내에 구현을 완료하고 통과시킬 수 있는 테스트를 선택하여 점진적으로 완성시킨다.

예외 상황을 먼저 테스트해야 하는 이유

다양한 예외 상황은 복잡한 if-else 블록을 동반할 때가 많다. 예외 상황을 전혀 고려하지 않은 코드에 예외 상황을 반영하려면 코드의 구조를 뒤집거나 코드 중간에 예외 상황을 처리하기 위해 조건문을 중복해서 추가하는 일이 벌어진다.
이는 코드를 복잡하게 만들어 버그 발생 가능성을 높인다.

✔ TDD를 하는 동안 예외 상황을 찾고 테스트에 반영하자!

앞서 구현한 암호 등급 측정 예시의 경우 이러한 예외 상황이 있었다.

  • 암호 값이 없는 상황
  • 빈 문자열이 들어오는 상황

따라서 예외 상황에 대한 테스트를 추가하였다. 이 테스트를 추가하지 않으면 NPE가 발생할 수 있으므로 이런 문제가 발생할 가능성을 사전에 낮춰야 한다.

완급 조절

처음 TDD로 구현할 때 어려운 것 중 하나는 한 번에 얼마만큼의 코드를 작성할 것인가이다.

TDD를 처음 접할 때는 다음 단계를 따라 익힌다.

  1. 정해진 값을 리턴
  2. 값 비교를 이용해서 정해진 값을 리턴
  3. 다양한 테스트를 추가하면서 구현을 일반화

예시로 암호 강도 측정 기능에서 길이가 8글자 미만이지만 나머지 규칙은 충족하는 상황을 위 단계를 밟아 진행해보았다.

@Test
 void meetsOtherCriteria_except_for_Length_Then_Normal() {
     PasswordStrengthMeter meter = new PasswordStrengthMeter();
     PasswordStrength result = meter.meter("ab12!@A");
     assertEquals(PasswordStrength.NORMAL, result);
 }

딱 위 테스트를 통과할 만큼만 코드를 작성한다.

public class PasswordStrengthMeter {
	public PasswordStrength meter(String s) {
    	if ("ab12!@A".equals(s))
        	return PasswordStrength.NORMAL;
         return PasswordStrength.STRONG;
    }
}

상수를 비교해서 테스트를 통과 시켰다.
다음으로 동일한 조건을 검증하기 위해 테스트를 추가한다.

@Test
 void meetsOtherCriteria_except_for_Length_Then_Normal() {
     PasswordStrengthMeter meter = new PasswordStrengthMeter();
     PasswordStrength result = meter.meter("ab12!@A");
     assertEquals(PasswordStrength.NORMAL, result);
     PasswordStrength result2 = meter.meter("Ab12!c");
     assertEquals(PasswordStrength.NORMAL, result2);
 }

새로 추가한 Ab12!c 를 통과시키려면 값 비교를 추가해야 한다.

public class PasswordStrengthMeter {
	public PasswordStrength meter(String s) {
    	if ("ab12!@A".equals(s) || "Ab12!c".equals(s))
        	return PasswordStrength.NORMAL;
         return PasswordStrength.STRONG;
    }
}

||를 사용하여 상수를 이용해 테스트를 통과시켰다.
다음 차례는 상수를 제거하고 일반화 하는 것이다.

public class PasswordStrengthMeter {
	public PasswordStrength meter(String s) {
    	if (s.length() < 8)
        	return PasswordStrength.NORMAL;
         return PasswordStrength.STRONG;
    }
}

이렇게 길이를 비교하는 일반화로 바꿀 수 있다.
몇 차례 상수를 사용해서 테스트를 통과시키고 뒤에 구현을 일반화 하는 과정이다.
이러한 과정이 점진적으로 구현을 진행할 수 있는 밑거름이다!

지속적인 리팩토링

테스트를 통과한 뒤에는 리팩토링을 진행한다.
매번 리팩토링을 진행해야 하는 것은 아니고, 적당한 후보가 보이면 진행한다.

코드 중복은 대표적인 리팩토링 대상이다.

소프트웨어의 생존 시간이 길어질수록 소프트웨어를 지속적으로 개선해야 한다. 코드 변경이 어려우면 변화하는 요구를 제때 반영할 수 없게 되며 이는 소프트웨어의 생존과 직결된다.
코드를 잘 변경하려면 변경하기 쉬운 구조를 가져야 하는데 이를 위한 것이 바로 리팩토링이다.

테스트 대상 코드의 리팩토링 시점

테스트 대상 코드에서 상수를 변수로 바꾸거나 변수 이름을 변경하는 것과 같은 작은 리팩토링은 발견하면 바로 실행한다.
반면에 메서드 추출과 같이 메서드의 구조에 영향을 주는 리팩토링은 큰 틀에서 구현 흐름이 눈에 들어오기 시작한 뒤에 진행한다.


테스트 작성 순서 연습

매달 비용을 지불해야 사용할 수 있는 유료 서비스가 있다고 가정한다.

  • 서비스를 사용하려면 매달 1만 원을 선불로 납부한다. 납부일 기준으로 한 달 뒤가 서비스 만료일이 된다.
  • 2개월 이상 요금을 납부할 수 있다.
  • 10만 원을 납부하면 서비스를 1년 제공한다.

쉬운 것부터 테스트

  • 구현하기 쉬운 것부터 먼저 테스트
  • 예외 상황을 먼저 테스트

만료일 계산기에서는 1만 원을 납부하면 한 달 뒤 같은 날을 만료일로 계산하는 것이 가장 쉬울 것 같다.

public class ExpiryDateCalculatorTest {

    @Test
    void 만원_납부하면_한달_뒤가_만료일이_됨() {
        LocalDate billingDate = LocalDate.of(2019, 3, 1);
        int payAmount = 10_000;

        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);

        assertEquals(LocalDate.of(2019, 4, 1), expiryDate);
    }
}
public class ExpiryDateCalculator {

    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return LocalDate.of(2019, 4, 1);
    }
}

예를 추가하면서 구현을 일반화

1만 원을 납부하는 예를 하나 더 추가한다.
위에서는 2019-03-01 이였지만 이번에는 2019-05-05로 한다.
따라서 만료일은 2019-06-05이어야 한다.

이제 return LocalDate.of(2019, 4, 1);은 테스트에 실패하므로, 구현을 일반화 한다.

public class ExpiryDateCalculatorTest {

    @Test
    void 만원_납부하면_한달_뒤가_만료일이_됨() {
        // 2019-03-01 ~ 2019-04-01
        LocalDate billingDate = LocalDate.of(2019, 3, 1);
        int payAmount = 10_000;

        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);

        assertEquals(LocalDate.of(2019, 4, 1), expiryDate);

        // 2019-05-05 ~ 2019-06-05
        LocalDate billingDate2 = LocalDate.of(2019, 5, 5);
        int payAmount2 = 10_000;

        ExpiryDateCalculator cal2 = new ExpiryDateCalculator();
        LocalDate expiryDate2 = cal.calculateExpiryDate(billingDate2, payAmount2);

        assertEquals(LocalDate.of(2019, 6, 5), expiryDate2);
    }
}
public class ExpiryDateCalculator {

    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return billingDate.plusMonths(1);
    }
}

코드 정리 : 중복 제거

다음 형태의 중복이 존재한다.

LocalDate billingDate = 납부일;
int paymentAmount = 납부액;

ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate expiryDate = cal.calculateExpiryDate(billingDate, paymentAmount);

assertEquals(기대값, expiryDate);

따라서 중복 부분을 메서드로 빼어 중복을 제거하였다.

public class ExpiryDateCalculatorTest {
    
    private void assertExpiryDate(LocalDate billingDate, int payAmount, LocalDate expectedExpiryDate) {
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate realExpiryDate = cal.calculateExpiryDate(billingDate, payAmount);
        assertEquals(expectedExpiryDate, realExpiryDate);
    }

    @Test
    void 만원_납부하면_한달_뒤가_만료일이_됨() {
        // 2019-03-01 ~ 2019-04-01
        assertExpiryDate(
                LocalDate.of(2019, 3, 1), 10_000, 
                LocalDate.of(2019, 4, 1));

        // 2019-05-05 ~ 2019-06-05
        assertExpiryDate(
                LocalDate.of(2019, 5, 5), 10_000, 
                LocalDate.of(2019, 6, 5));
    }
}

예외 상황 처리

단순히 한 달 추가로 끝나지 않는 상황이 존재한다.

  • 납부일이 2019-01-31이고 납부액이 1만 원이면 만료일은 2019-02-28이다.
  • 납부일이 2019-05-31이고 납부액이 1만 원이면 만료일은 2019-06-30이다.
  • 납부일이 2020-01-31이고 납부액이 1만 원이면 만료일은 2020-02-29이다.
public class ExpiryDateCalculatorTest {

    private void assertExpiryDate(LocalDate billingDate, int payAmount, LocalDate expectedExpiryDate) {
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate realExpiryDate = cal.calculateExpiryDate(billingDate, payAmount);
        assertEquals(expectedExpiryDate, realExpiryDate);
    }
    ... 생략

    @Test
    void 납부일과_한달_뒤_일자가_같지_않음() {
        assertExpiryDate(
                LocalDate.of(2019, 1, 31), 10_000,
                LocalDate.of(2019, 2, 28));
        assertExpiryDate(
                LocalDate.of(2019, 5, 31), 10_000,
                LocalDate.of(2019, 6, 30));
        assertExpiryDate(
                LocalDate.of(2020, 1, 31), 10_000,
                LocalDate.of(2020, 2, 29));
    }
}

ExpiryDateCalculator 클래스는 return billingDate.plusMonths(1); 코드에서 고치지 않았는데, 테스트에 통과한다.
LocalDate#plusMonths() 메서드가 알아서 한 달 추가 처리를 해준 것이다.

다음 테스트 선택 : 다시 예외 상황

다음 테스트를 선택한다.
1만 원을 납부할 때 한 달 뒤가 만료일이 되는 테스트를 진행했으므로, 그 다음 쉽거나 예외적인 것을 선택한다.

  • 2만 원을 지불하면 만료일이 두 달 뒤가 된다.
  • 3만 원을 지불하면 만료일이 세 달 뒤가 된다.

예외상황으로 생각해보면,

  • 첫 납부일이 2019-01-31이고 만료되는 2019-02-28에 1만 원을 납부하면 다음 만료일은 2019-03-31이다.
  • 첫 납부일이 2019-01-30이고 만료되는 2019-02-28에 1만 원을 납부하면 다음 만료일은 2019-03-30이다.
  • 첫 납부일이 2019-05-31이고 만료되는 2019-06-30에 1만 원을 납부하면 다음 만료일은 2019-07-31이다.

쉬운 것과 예외 상황 중에 무엇을 먼저 테스트해야 할까?
쉬운 예는 2개월 이상 요금을 지불한 예를 테스트하기 위함이고, 예외 상황은 1개월 요금을 지불할 때 발생할 수 있는 예외 상황이다.

이전 테스트가 1개월 요금 지불을 기준으로 하므로 1개월 요금 지불에 대한 예외 상황을 마무리 하는 것으로 한다.

예외 상황을 테스트하려면 첫 납부일이 필요하다. 앞선 테스트는 납부일과 납부액만 있으므로 기존 코드에 첫 납부일을 추가한다.

다음 테스트를 추가하기 전에 리팩토링

  • calculateExpiryDate 메서드의 파라미터로 첫 납부일 추가
  • 첫 납부일, 납부일, 납부액을 담은 객체를 calculateExpiryDate 메서드에 전달

calculateExpiryDate의 파라미터가 세 개로 늘어난다. 파라미터는 적을 수록 코드 가독성과 유지보수에 유리하므로 메서드 파라미터 개수가 세 개 이상이면 객체로 바꿔 한 개로 줄이는 것을 고려해야 한다.

파라미터로 사용할 객체를 생성한다.

public class PayData {
    private LocalDate billingDate;
    private int payAmount;

    private PayData() {}

    public PayData(LocalDate billingDate, int payAmount) {
        this.billingDate = billingDate;
        this.payAmount = payAmount;
    }

    public LocalDate getBillingDate() {
        return billingDate;
    }

    public int getPayAmount() {
        return payAmount;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private PayData data = new PayData();

        public Builder billingDate(LocalDate billingDate) {
            data.billingDate = billingDate;
            return this;
        }
        public Builder payAmount(int payAmount) {
            data.payAmount = payAmount;
            return this;
        }
        public PayData build() {
            return data;
        }
    }
}

이제 파라미터로 PayData를 사용한다.

public class ExpiryDateCalculator {

    public LocalDate calculateExpiryDate(PayData payData) {
        return payData.getBillingDate().plusMonths(1);
    }
}

따라서 ExpiryDateCalculatorTest 클래스도 PayData 클래스를 사용하도록 변경한다.

public class ExpiryDateCalculatorTest {

    private void assertExpiryDate(PayData payData, LocalDate expectedExpiryDate) {
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate realExpiryDate = cal.calculateExpiryDate(payData);
        assertEquals(expectedExpiryDate, realExpiryDate);
    }

    @Test
    void 만원_납부하면_한달_뒤가_만료일이_됨() {
        // 2019-03-01 ~ 2019-04-01
        assertExpiryDate(
                PayData.builder()
                        .billingDate(LocalDate.of(2019, 3, 1))
                        .payAmount(10_000)
                        .build(),
                LocalDate.of(2019, 4, 1));

        // 2019-05-05 ~ 2019-06-05
        assertExpiryDate(
                PayData.builder()
                        .billingDate(LocalDate.of(2019, 5, 5))
                        .payAmount(10_000)
                        .build(),
                LocalDate.of(2019, 6, 5));
    }

    @Test
    void 납부일과_한달_뒤_일자가_같지_않음() {
        assertExpiryDate(
                PayData.builder()
                        .billingDate(LocalDate.of(2019, 1, 31))
                        .payAmount(10_000)
                        .build(),
                LocalDate.of(2019, 2, 28));
        assertExpiryDate(
                PayData.builder()
                        .billingDate(LocalDate.of(2019, 5, 31))
                        .payAmount(10_000)
                        .build(),
                LocalDate.of(2019, 6, 30));
        assertExpiryDate(
                PayData.builder()
                        .billingDate(LocalDate.of(2020, 1, 31))
                        .payAmount(10_000)
                        .build(),
                LocalDate.of(2020, 2, 29));
    }
}

예외 상황 테스트 진행 계속

  • 첫 납부일이 2019-01-31이고 만료되는 2019-02-28에 1만 원을 납부하면 다음 만료일은 2019-03-31이다.
@Test
 void 첫_납부일과_만료일_일자가_다를때_만원_납부() {
     PayData payData = PayData.builder()
             .firstBillingDate(LocalDate.of(2019, 1, 31))
             .billingDate(LocalDate.of(2019, 2, 28))
             .payAmount(10_000)
             .build();
     assertExpiryDate(payData, LocalDate.of(2019, 3, 31));
 }

첫 납부일을 전달해야 하므로 PayData.Builder#firstBillingDate() 메서드를 사용하는 코드를 작성한다.
따라서 PayData 클래스에 추가해주었다.

기존 코드로 테스트하면 실패하므로, ExpiryDateCalculator에 아래 코드를 추가하였다.

public class ExpiryDateCalculator {

    public LocalDate calculateExpiryDate(PayData payData) {
        if (payData.getFirstBillingDate().equals(LocalDate.of(2019, 1, 31))) {
            return LocalDate.of(2019, 3, 31);
        }

        return payData.getBillingDate().plusMonths(1);
    }
}

if 절을 통해 (상수) 일단 테스트를 통과시키는 코드이다.

해당 코드는 통과하였지만 앞서 작성한 두 테스트는 실패하였다. PayDatafirstBillingDate를 추가하게 되었기 때문에 NPE이 발생하였다.

따라서 if 절로 null을 검사하는 코드를 추가한다.

public class ExpiryDateCalculator {

    public LocalDate calculateExpiryDate(PayData payData) {
        if (payData.getFirstBillingDate() != null) {
            if (payData.getFirstBillingDate().equals(LocalDate.of(2019, 1, 31))) {
                return LocalDate.of(2019, 3, 31);
            }
        }

        return payData.getBillingDate().plusMonths(1);
    }
}

그럼 모든 테스트가 통과한다!

이제 새로운 테스트 사례를 추가해서 구현을 일반화 할 차례이다.
새로 추가할 사례는 다음과 같다.

  • 첫 납부일이 2019-01-30이고 만료되는 2019-02-28에 1만 원을 납부하면 다음 만료일은 2019-03-30이다.
@Test
void 첫_납부일과_만료일_일자가_다를때_만원_납부() {
	... 생략
     PayData payData2 = PayData.builder()
             .firstBillingDate(LocalDate.of(2019, 1, 30))
             .billingDate(LocalDate.of(2019, 2, 28))
             .payAmount(10_000)
             .build();
     assertExpiryDate(payData2, LocalDate.of(2019, 3, 30));
 }

ExpiryDateCalculator 클래스는 2019-01-31때의 if절이 구현되어 있으므로 2019-01-30인 새로운 테스트는 실패한다.

따라서 테스트를 통과할만큼만 구현을 일반화 해본다.

  • 첫 납부일과 납부일의 일자가 다르면 첫 납부일의 일자를 만료일의 일자로 사용
public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {
        if (payData.getFirstBillingDate() != null) {
            LocalDate candidateExp = payData.getBillingDate().plusMonths(1);
            if (payData.getFirstBillingDate().getDayOfMonth() != candidateExp.getDayOfMonth()) {
                return candidateExp.withDayOfMonth(payData.getFirstBillingDate().getDayOfMonth());
            }
        }

        return payData.getBillingDate().plusMonths(1);
    }
}

아까 테스트를 예로 들자면,

  • 첫 납부일이 2019-01-30이고 만료되는 2019-02-28에 1만 원을 납부하므로 다음 만료일은 2019-03-30이다.

에서 만료일 2019-03-3030일은 첫 납부일 2019-01-30으로 인해 정해지는 것이다.

코드 정리 : 상수를 변수로

ExpiryDateCalculator 클래스에서 payData.getBillingDate().plusMonths(1) 코드의 1을 변수 addedMonths로 변경하였다.

다음 테스트 선택 : 쉬운 테스트

  • 2만 원을 지불하면 만료일이 두 달 뒤가 된다.
  • 3만 원을 지불하면 만료일이 석 달 뒤가 된다.
@Test
 void 이만원_이상_납부하면_비례해서_만료일_계산() {
     assertExpiryDate(
             PayData.builder()
                     .billingDate(LocalDate.of(2019, 3, 1))
                     .payAmount(20_000)
                     .build(),
             LocalDate.of(2019, 5, 1));
 }

ExpiryDateCalculator 클래스에 변수 addedMonthspayData.getPayAmount() / 10_000로 변경하면 테스트에 성공한다.

납부 금액이 비례하여 만료일이 계산되기 때문이다.

예외 상황 테스트 추가

  • 첫 납부일이 2019-01-31이고 만료되는 2019-02-28에 2만원을 납부하면 다음 만료일은 2019-04-30이다.
@Test
void 첫_납부일과_만료일_일자가_다를때_이만원_이상_납부() {
    assertExpiryDate(
            PayData.builder()
                    .firstBillingDate(LocalDate.of(2019, 1, 31))
                    .billingDate(LocalDate.of(2019, 2, 28))
                    .payAmount(20_000)
                    .build(),
            LocalDate.of(2019, 4, 30));
}

위 테스트에 의해 만료일자는 첫 납부 일자와 같아야 하므로 2019-04-31이 되어야 한다. 하지만 4월에는 31일이 없기 때문에 익셉션이 발생한다.

java.time.DateTimeException: Invalid date 'APRIL 31'

이 테스트를 통과시키려면 다음 조건을 확인해야 한다.

  • 후보 만료일이 포함된 달의 마지막 날 < 첫 납부일의 일자
// ExpiryDateCalculator 클래스
...생략
if (YearMonth.from(candidateExp).lengthOfMonth() < payData.getFirstBillingDate().getDayOfMonth()) {
        return candidateExp.withDayOfMonth(YearMonth.from(candidateExp).lengthOfMonth());
}

코드 정리

ExpiryDateCalculator 클래스의 코드를 정리한다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {
        int addedMonths = payData.getPayAmount() / 10_000;

        if (payData.getFirstBillingDate() != null) {
            LocalDate candidateExp = payData.getBillingDate().plusMonths(addedMonths);
            if (payData.getFirstBillingDate().getDayOfMonth() != candidateExp.getDayOfMonth()) {
                if (YearMonth.from(candidateExp).lengthOfMonth() < payData.getFirstBillingDate().getDayOfMonth()) {
                    return candidateExp.withDayOfMonth(YearMonth.from(candidateExp).lengthOfMonth());
                }
                return candidateExp.withDayOfMonth(payData.getFirstBillingDate().getDayOfMonth());
            }
        }

        return payData.getBillingDate().plusMonths(addedMonths);
    }
}

메서드를 분리하고 가독성을 높여 정리하여 아래와 같이 되었다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {
        int addedMonths = payData.getPayAmount() / 10_000;
        if (payData.getFirstBillingDate() != null) {
            return expiryDateUsingFirstBillingDate(payData, addedMonths);
        } else {
            return payData.getBillingDate().plusMonths(addedMonths);
        }
    }

    private LocalDate expiryDateUsingFirstBillingDate(PayData payData, int addedMonths) {
        LocalDate candidateExp = payData.getBillingDate().plusMonths(addedMonths);
        final int dayOfFristBilling = payData.getFirstBillingDate().getDayOfMonth();
        if (dayOfFristBilling != candidateExp.getDayOfMonth()) {
            final int dayLenOfCandiMon = YearMonth.from(candidateExp).lengthOfMonth();
            if (dayLenOfCandiMon < dayOfFristBilling) {
                return candidateExp.withDayOfMonth(dayLenOfCandiMon);
            }
            return candidateExp.withDayOfMonth(dayOfFristBilling);
        } else {
            return candidateExp;
        }
    }
}

다음 테스트: 10개월 요금을 납부하면 1년 제공

@Test
void 십만원을_납부하면_1년_제공() {
    assertExpiryDate(
            PayData.builder()
                    .billingDate(LocalDate.of(2019, 1, 28))
                    .payAmount(100_000)
                    .build(),
            LocalDate.of(2020, 1, 28));
}

ExpiryDateCalculator 클래스에서 int addedMonths = payData.getPayAmount() == 100_000 ? 12 : payData.getPayAmount() / 10_000; 를 추가하여 변경하면 테스트에 통과한다.

테스트할 목록 정리하기

TDD를 시작할 때 테스트할 목록을 미리 정리하자.

  • 1만 원 납부하면 한 달 뒤가 만료일
  • 달의 마지막 날에 납부하면 다음달 마지막 날이 만료일
  • 2만 원 납부하면 2개월 뒤가 만료일
  • 3만 원 납부하면 3개월 뒤가 만료일
  • 10만 원을 납부하면 1년 뒤가 만료일

이렇게 테스트할 내용을 정리하고, 이 중 어떤 테스트가 구현이 쉬울지 또는 어떤 테스트가 예외적인지 상상한다.

하나의 테스트 코드를 만들고 이를 통과시키고 리팩토링하고 다시 다음 테스트 코드를 만들고 .. 이렇게 짧은 리듬을 반복한다.

시작이 안 될 때는 단언부터 고민

구현이 막히면 코드를 지우고 다시 시작한다.

  • 쉬운 테스트, 예외적인 테스트
  • 완급 조절

위 두 가지를 상기하여 진행하면 된다.

0개의 댓글