테스트 코드 작성 순서

테스트 코드 작성 순서는 다음과 같은 규칙 위주로 작성하는 것이 좋다

  • 쉬운 경우 → 어려운 경우
  • 예외적인 경우 → 정상적인 경우
2장 구현 순서

1. 모든 규칙을 충족
2. 길이만 8글자 미만이고 나머지 규칙 충족
3. 숫자 포함하지 않고 나머지 규칙 충족
4. 값이 없으면 유효하지 않음
5. 대문자를 포함하지 않고 나머지 규칙 충족
6. 길이가 8글자 이상인 규칙만 충족
7. 숫자 포함 규칙만 충족
8. 대문자 포함 규칙만 충족
9. 아무 규칙도 충족하지 않음.

모든 규칙을 충족하는 경우는 값을 그대로 return 해주는 방식으로 쉽게 구현이 가능했었다. 이렇게 쉬운 케이스부터 어려운 케이스까지 TDD를 하며 구현하고, 중간에 예외적인 케이스를 찾아 먼저 구현해주는것이 중요하다고 한다.

  • 구현하기 쉬운 테스트부터 시작하기
  • 예외 상황을 먼저 테스트하기
  • 완급 조절
    1. 정해진 값을 리턴
    2. 값 비교를 이용해서 정해진 값을 리턴
    3. 다양한 테스트를 추가하면서 구현을 일반화
  • 지속적인 리팩토링

테스트 작성 순서 연습

매달 비용을 지불해야 사용할 수 있는 유료 서비스의 만료일 계산기 구현하기

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

쉬운 것부터 테스트

테스트 추가할 때는 두 가지 상황 먼저 고려해야 한다.

  • 구현하기 쉬운 것부터 테스트
  • 예외 상황을 먼저 테스트
@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);
    
}

날짜에서 1개월 기준이 월 별로 일수가 달라진다는 예외 상황이 가장 먼저 떠올랐지만, 가장 일반적이고 단순하게 짜기 위해서 한 달 뒤 같은 날을 만료일로 계산 하는 로직을 먼저 구현하는 것이 좋을 것 같다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return LocalDate.of(2025, 11, 1);

    }
}

상수를 리턴시켜줘서 가장 간단하게 구현했다.

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

위의 테스트 작성 순서의 완급조절 부분에서 단계적으로 코드 작성 순서를 언급했었다. 이제 다양한 테스트 케이스를 추가하면서 구현을 일반화할 차례다.

@Test
void 만원_납부하면_한달_뒤가_만료일() {
    LocalDate billingDate = LocalDate.of(2025, 10, 1);
    int payAmount = 10_000;
    ExpiryDateCalculator cal = new ExpiryDateCalculator();
    LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);

    assertEquals(LocalDate.of(2025, 11, 1), expiryDate);

    LocalDate billingDate2 = LocalDate.of(2025, 11, 5);
    int payAmount2 = 10_000;
    ExpiryDateCalculator cal2 = new ExpiryDateCalculator();
    LocalDate expiryDate2 = cal2.calculateExpiryDate(billingDate2, payAmount2);

    assertEquals(LocalDate.of(2025, 12, 5), expiryDate2);
}
public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
    return billingDate.plusMonths(1);
}

billingDate에 무조건 1달을 더해주고 리턴시켜줘서 일반화하여 테스트가 통과되는것을 확인했다.

코드 정리: 중복 제거

@Test
void 만원_납부하면_한달_뒤가_만료일() {
    assertExpiryDate(
        LocalDate.of(2025, 10, 1),
        10_000,
        LocalDate.of(2025, 11, 1)
    );

    assertExpiryDate(
        LocalDate.of(2025, 11, 5),
        10_000,
        LocalDate.of(2025, 12, 5)
    );
}

private void assertExpiryDate(
        LocalDate billingDate, int payAmount, LocalDate expectedExpiryDate) {
    ExpiryDateCalculator cal = new ExpiryDateCalculator();
    LocalDate realExpiryDate = cal.calculateExpiryDate(billingDate, payAmount);
    assertEquals(expectedExpiryDate, realExpiryDate);
}
  • 메서드를 이용하여 테스트 코드 중복 제거

예외 상황 처리 - 1

이제 구현 전에 생각해두었던 예외 상황을 구현하면 될 것 같다.

  • 2월일 경우 만료일이 이전 달과 다름 → 2월 28일, 2월 29일
  • 만료일이 30일인 달과 31일인 달이 존재
@Test
void 납부일과_한달_뒤_일자가_같지_않음() {
    assertExpiryDate(
        LocalDate.of(2025, 1, 31),
        10_000,
        LocalDate.of(2025, 2, 28)
    );
    assertExpiryDate(
        LocalDate.of(2025, 5, 31),
        10_000,
        LocalDate.of(2025, 6, 30)
    );

    assertExpiryDate(
        LocalDate.of(2024, 1, 31),
        10_000,
        LocalDate.of(2024, 2, 29)
    );
}

위 예외 케이스는 이미 사용했던 LocalDate의 plusMonths가 처리해주기 때문에 테스트는 바로 통과 가능하다.

예외 상황 처리 - 2

다음 테스트를 선택하기 위해 쉬운 예시와 예외 상황을 생각해보고 설정해야 한다.

쉬운 예시

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

예외 상황

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

기존 코드가 1개월 납부 기준으로 작성하고 있었으니, 더 심화되는 1개월 납부 기준 예외 상황을 먼저 처리하고 그 다음에 금액에 따른 만료일 처리를 하는 것이 좋아 보인다.

@Test
void 첫_납부일과_만료일_일자가_다를때_만원_납부() {
    PayData payData = PayData.builder()
        .firstBillingDate(LocalDate.of(2025, 1, 31))
        .billingDate(LocalDate.of(2025, 2, 28))
        .payAmount(10_000)
        .build();

    assertExpiryDate(payData, LocalDate.of(2025, 3, 31));
}
package chap03;

import java.time.LocalDate;

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

    private PayData() {}

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

    public LocalDate getFirstBillingDate() {
        return firstBillingDate;
    }

    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 firstBillingDate(LocalDate firstBillingDate) {
            data.firstBillingDate = firstBillingDate;
            return this;
        }
        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;
        }
    }
}

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

      return payData.getBillingDate().plusMonths(1);
  }
  • 첫 납부일(firstBillingDate)이 추가되어 파라미터 가독성을 위해 PayData 객체 추가하여 리팩토링 과정 진행함
  • 테스트 코드 통과시키기 위해 분기처리 후 상수값 리턴
PayData payData2 = PayData.builder()
    .firstBillingDate(LocalDate.of(2025, 1, 30))
    .billingDate(LocalDate.of(2025, 2, 28))
    .payAmount(10_000)
    .build();

assertExpiryDate(payData2, LocalDate.of(2025, 3, 30));

PayData payData3 = PayData.builder()
    .firstBillingDate(LocalDate.of(2025, 5, 31))
    .billingDate(LocalDate.of(2025, 6, 30))
    .payAmount(10_000)
    .build();

assertExpiryDate(payData3, LocalDate.of(2025, 7, 31));

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());
        }
        
        /*
        기존 코드
        if (payData.getFirstBillingDate().equals(LocalDate.of(2025, 1, 31))) {
            return LocalDate.of(2025, 3, 31);
        }
        */
    }

    return payData.getBillingDate().plusMonths(1);
}
  • 추가 테스트 코드 작성한 후 코드를 일반화했다.

일반화 후 테스트가 정상적으로 통과하는 것을 확인했다.

코드 정리: 상수를 변수로

public LocalDate calculateExpiryDate(PayData payData) {
    int addedMonths = 1;

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

    return payData.getBillingDate().plusMonths(addedMonths);
}
  • 테스트를 통과시키기 위해 간단하게 작성했던 상수값을 변수값으로 변경했다. (addedMonths)

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

위에 설정해 놓았던 쉬운 예시를 구현할 차례다.

  • 2만 원을 지불하면 만료일이 두 달 뒤가 된다.
  • 3만 원을 지불하면 만료일이 세 달 뒤가 된다.
@Test
void 이만원_이상_납부하면_비례해서_만료일_계산() {
    assertExpiryDate(
        PayData.builder()
            .billingDate(LocalDate.of(2025, 3, 1))
            .payAmount(20_000)
            .build(),
        LocalDate.of(2025, 5, 1));
    assertExpiryDate(
        PayData.builder()
            .billingDate(LocalDate.of(2025, 3, 1))
            .payAmount(30_000)
            .build(),
        LocalDate.of(2025, 6, 1));
}
int addedMonths = payData.getPayAmount() / 10_000;
  • 리팩토링했던 변수값(addedMonths)을 변경해서 바로 구현해주었다.

예외 상황 테스트 추가

기존 1만원일 때 추가했던 예외상황에서 2만원으로 변경만 해주면 된다.

  • 첫 납부일 2025-01-31, 만료되는 2025-02-28에 2만 원을 납부하면 다음 만료일은 2025-04-30
@Test
void 첫_납부일과_만료일_일자가_다를때_이만원_이상_납부() {
    assertExpiryDate(
        PayData.builder()
            .firstBillingDate(LocalDate.of(2025, 1, 31))
            .billingDate(LocalDate.of(2025, 2, 28))
            .payAmount(20_000)
            .build(),
        LocalDate.of(2025, 4, 30));
}
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 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 dayOfFirstBilling = payData.getFirstBillingDate().getDayOfMonth();
    if (dayOfFirstBilling != candidateExp.getDayOfMonth()) {
        final int dayLenOfCandiMon = YearMonth.from(candidateExp).lengthOfMonth();
        if (dayLenOfCandiMon < dayOfFirstBilling) {
            return candidateExp.withDayOfMonth(dayLenOfCandiMon);
        }
        return candidateExp.withDayOfMonth(dayOfFirstBilling);
    } else {
        return candidateExp;
    }
}
  • 코드 가독성을 위해 메서드를 분리했다.

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

  • 10만원을 납부시 서비스를 1년 제공하는 규칙을 구현
@Test
void 십만원을_납부하면_1년_제공() {
    assertExpiryDate(
        PayData.builder()
            .billingDate(LocalDate.of(2025, 1, 28))
            .payAmount(100_000)
            .build(),
        LocalDate.of(2026, 1, 28));
    assertExpiryDate(
        PayData.builder()
            .billingDate(LocalDate.of(2024, 2, 29))
            .payAmount(100_000)
            .build(),
        LocalDate.of(2025, 2, 28));
}
int addedMonths = payData.getPayAmount() == 100_000 ?
		12 : payData.getPayAmount() / 10_000;

테스트할 목록 정리하기

  • TDD 시작할 때 테스트할 목록을 미리 정리하면 좋음.
  • 처음 테스트 코드 작성이 어려우면 검증하는 코드부터 작성해보기
  • 구현이 막히면 과감하게 코드를 지우고 테스트 순서를 바꿔 진행해보기

3장에 나와있는 만료일 계산기의 요구사항대로 예제 코드들을 전부 학습해보았다.

추가로 더 구현해야 될 예외 케이스들이 있지만 다음 글 작성할 때 체크해봐야겠다.

Categories:

Updated:

Leave a comment