TDD 입문 예제

최재혁·2022년 11월 17일
0
post-thumbnail

🤨들어가며..

테스트 주도 개발 시작하기(최범균 저)를 읽고 정리한 글입니다.

YES24

TDD, 테스트 주도 개발이란 단어를 처음 알게된 것은 학부 시절, 소프트웨어공학개론이라는 전공이었습니다. 당시에는 애플리케이션 동작에 필요한 테스트를 먼저 작성하고, 그 이후에 기능 구현을 진행한다는 TDD의 개념조차 받아들이기 어려웠던 기억이 납니다. 지금은 머리로 정의 정도는 이해하고 있지만, 실제로 TDD 방식으로 개발을 해본 경험은 없었습니다.

테스트 코드의 중요성을 실감하고 있는 요즘, TDD를 입문하기 쉬운 책을 찾아보다 최범균님의 저서 "테스트 주도 개발 시작하기"를 구매하였습니다. 교보문고에서 쇼핑만 1시간 정도 한 것 같습니다 하하

사실 구매하고 읽기 시작한지 얼마 안되었는데, 생각보다 술술 읽히고 예제 또한 잘 구성되어 있어 따라가기 수월하다는 느낌을 받았습니다. 예제 설명과 코드 또한 잘 구성되어 있어 저 같은 주니어 개발자들이 TDD에 입문하시기에 괜찮은 책이라고 생각합니다.

이번 포스트에서는 TDD의 작성 순서와 고려해야할 사항을, 책에 나온 예제와 함께 공유해보려 합니다.

예제 상황

어렵게만 느껴지던 TDD에도 표준은 아니지만, 나름의 테스트 작성 기법이 있습니다.

  1. 초반에는 복잡한 테스트보다는 구현하기 쉬운 테스트부터 시작한다.

  2. 예외 상황을 먼저 테스트한다.

  3. 테스트 코드를 작성한 뒤에는 리팩토링을 진행한다.

이를 따라가면서, TDD로 다음 요구 사항에 따른 예제 코드를 작성해보겠습니다.

매달 비용을 지불해야 사용할 수 있는 유료 서비스가 있다. 이 서비스는 다음 규칙에 따라 서비스 만료일을 결정한다.

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

이 때, 납부한 금액 기준으로 서비스 만료일을 계산하는 기능을 어떻게 구현할 것인가?

TDD 작성

1. 테스트 패키지에 테스트 생성

먼저, 배포할 파일이 담긴 main 패키지와 TDD로 개발을 진행할 test 패키지의 경로를 구분해 줍니다. test 경로에서 기능을 구현하고, 테스트를 통과하면 main 패키지로 이동하는 방식입니다.

저희가 개발해야 하는 것은, 납부한 금액 기준으로 서비스 만료일을 계산하는 기능입니다. 모든 기능 개발은 @Test 어노테이션 아래에서 진행됩니다. test 패키지 아래에, 테스트를 진행할 ExpiryDateCalculatorTest 클래스를 만들어 줍니다.

public class ExpiryDateCalculatorTest{
}

2. 구현하기 쉬운 것부터 테스트

처음부터 복잡한 기능을 테스트하는 것보다는, 구현하기 쉬운 것부터 테스트합니다.

만약 초반부터 다양한 상황을 고려해야 하고 조합을 검사해야 하는 복잡한 케이스부터 테스트를 구현하면, 테스트를 통과하기 위해 한 번에 구현해야 하는 코드가 많아집니다. 따라서 테스트 통과 시간이 길어지고, 버그가 발생하면 버그를 잡기 위해 더 많은 시간을 허비해야 하겠죠. 또한, 구현하기 쉬운 경우부터 시작하면, 빠르게 테스트를 통과시킬 수 있어 기초 단계부터의 점진적인 개발에도 도움이 됩니다.

주어진 요구 사항 중, 구현하기 쉬운 것은 뭐가 있을까요? 첫번째 요구 사항인 "1만원을 납부했을 때, 한달 뒤 같은 날을 만료일로 계산한다." 가 괜찮아 보입니다. 그냥 납부일로부터 1달을 더해주면 되니까요. 코드를 작성해 보겠습니다.

public class ExpiryDateCalculatorTest {

    @Test
    @DisplayName("만원 납부하면 한달 뒤가 만료일이 됨")
    void pay_10000_won(){
        LocalDate billingDate = LocalDate.of(2022,11,17);
        int payAmount = 10000;

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

        Assertions.assertEquals(LocalDate.of(2022,12,17), expiryDate);
    }
}

지금 작성한 테스트 코드는, 우리가 만들지도 않은 기능에 대하여 이렇게 동작해야 함을 테스트하는 것입니다. ExpiryDateCalculator 클래스나 calculateExpiryDate()는 만들어지지도 않았죠.

(실제로 위와 같이 빨갛게 표시가 되며 컴파일 에러가 발생합니다.)

ExpireDateCalculatorcalculateExpiryDate가 원하는 동작을 수행하도록 작성을 해줘야 합니다. 그런데 여기서 중요한 것은, 기능 구현은 지금까지 작성한 테스트를 통과할 만큼만 진행해야 한다는 것입니다. 말 그대로, 테스트가 기능 구현을 "주도"하도록 하는 것이죠. 개발자 입장에서는 LocalDateTime을 계산해서, 1달이 지나면 서비스를 만료하는 로직을 바로 짜고 싶은 마음이 굴뚝같겠지만, 저희는 TDD 입문자잖아요? 이렇게 단계를 밟아나가는 과정을 몸에 익혀야 합니다. 이 과정을 반복하면서 점진적으로 기능을 완성해 나가는 것이 TDD입니다. 정확히 테스트를 통과할 만큼만 기능을 작성하겠습니다. 테스트를 통과하려면, 2022-12-17에 해당하는 LocalDateTime을 리턴하면 됩니다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return LocalDate.of(2022,12,17);    // 테스트를 통과할 만큼만 상수로 작성!!
    }
}

[테스트 성공]

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

이제 동일 조건의 예를 추가하면서 구현을 일반화합니다. 2022-11-17 이외에, 1만 원을 다른 날짜에 납부하는 예시를 하나 더 작성합니다.

public class ExpiryDateCalculatorTest {

    @Test
    @DisplayName("만원 납부하면 한달 뒤가 만료일이 됨")
    void pay_10000_won(){
        LocalDate billingDate = LocalDate.of(2022,11,17);
        int payAmount = 10000;

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

        Assertions.assertEquals(LocalDate.of(2022,12,17), expiryDate);

        // 새로 추가한 예
        LocalDate billingDate2 = LocalDate.of(2022, 8, 3);
        int payAmount2 = 10000;

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

        Assertions.assertEquals(LocalDate.of(2022,9,3), expiryDate2);
    }
}

다시 테스트를 돌려보면, 새로 추가한 예시의 테스트에서 실패합니다. 2022-12-17 이라는 상수값을 리턴하도록 작성한 코드를 고쳐야 합니다. 이 때, 다시 상수값을 리턴하도록 고쳐도 되고, 구현을 일반화해서 실제 작동하는 기능을 작성해도 되지만, 동작이 단순하므로 바로 일반화하여 구현하도록 하겠습니다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return billingDate.plusMonths(1);   // 구현을 일반화
    }
}

이제 다시 테스트를 수행하면 통과하는 것을 확인할 수 있습니다.

4. 코드 정리 (리팩토링)

테스트 코드 또한 유지보수의 대상이므로 중복을 제거하는 것이 좋습니다. 하지만 테스트 코드의 중복을 제거하는 것은 고민이 필요합니다. 왜냐하면, 각 테스트 메서드는 스스로 무엇을 테스트하는지 명확하게 설명할 수 있어야 하기 때문입니다. 일단 중복을 제거해보고, 각 테스트가 의미하는 바를 쉽게 파악할 수 있는지 확인해봅니다.

public class ExpiryDateCalculatorTest {

    @Test
    @DisplayName("만원 납부하면 한달 뒤가 만료일이 됨")
    void pay_10000_won(){
        assertExpiryDate(LocalDate.of(2022,11,17), 10000, LocalDate.of(2022,12,17));
        assertExpiryDate(LocalDate.of(2022,8,3),10000,LocalDate.of(2022,9,3));
    }
    
    // 공통 로직을 메서드로 추출
    private void assertExpiryDate(LocalDate billingDate, int payAmount, LocalDate expectedExpiryDate) {
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate realExpiryDate = cal.calculateExpiryDate(billingDate, payAmount);
        Assertions.assertEquals(expectedExpiryDate, realExpiryDate);
    }
}

assertExpiryDate 메서드로 공통로직을 추출했습니다. 아직은 코드가 길지 않고, 구현부를 보면 어떤 로직을 검증하고 있는지 쉽게 알 수 있으므로 중복을 제거한 코드로 진행하겠습니다.

5. 예외 상황 처리

쉬운 구현을 하나 처리했으니, 발생할 수 있는 예외 상황을 구현해봅시다. 예외 상황은 비교적 초반에 구현할수록 좋습니다. 예외 상황은 복잡한 if-else 구문을 동반하는 경우가 많은데, 예외 상황을 고려하지 않은 코드에 새로운 예외 상황을 추가하려면, 코드의 구조를 다시 뒤집거나 코드 중간에 if 조건문을 중복해서 추가하는 경우가 발생합니다. 이는 코드를 복잡하게 만들어 버그 가능성을 높입니다.

다음과 같은 예외 상황이 발생할 수 있습니다.

  • 납부일이 2022-01-31이고, 납부액이 1만원이면, 만료일은 2022-02-28이다.
  • 납부일의 2022-05-31이고, 납부액이 1만원이면, 만료일은 2022-06-30이다.
  • 납부일이 2024-01-31이고, 납부액이 1만원이면, 만료일은 2024-02-29이다. (윤년)

이 세 가지 예외 상황은 납부일 기준으로 다음 달의 같은 날이 만료일이 아닙니다. 예외 케이스를 테스트로 추가하겠습니다.

@Test
@DisplayName("만원 납부했을 때, 납부일과 한달 뒤의 만료일의 날짜가 일치하지 않음")
void different_date_10000_won() {
    assertExpiryDate(LocalDate.of(2022,1,31),10000,LocalDate.of(2022,2,28));
    assertExpiryDate(LocalDate.of(2022,5,31),10000,LocalDate.of(2022,6,30));
        assertExpiryDate(LocalDate.of(2024,1,31),10000,LocalDate.of(2024,2,29));

}

실행해보니, 생각과는 다르게 테스트를 통과합니다. 알고 보니, calculateExpiryDate 메서드의 구현부의 LocalDateTime#plusMonths() 메서드가 알아서 한 달 추가 처리를 해 준 것입니다.

6. 다음 테스트 선택 & 리팩토링

지금까지 1만원을 납부했을 때, 한 달 뒤가 만료일이 되는 테스트를 진행했습니다. 이제 그 다음으로 쉽거나, 예외 상황을 선택하면 됩니다.

쉬운 예)

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

예외 상황) 첫 납부일과 만료일의 일자가 같지 않을 때, 1만원을 납부하면 첫 납부일 기준으로 다음 납부일이 결정됨

  • 첫 납부일이 2022-01-31이고, 만료일인 2022-02-28에 다시 1만원을 납부하면, 다음 만료일은 2022-03-31이 된다.
  • 첫 납부일이 2022-05-31이고, 만료일인 2022-06-30에 1만원을 납부하면, 다음 만료일은 2022-07-31이다.

쉬운 예는 n만원을 지불했을 때의 상황이고, 예외 상황은 1만원을 지불 했을 때의 예외 상황입니다. 지금까지 1만원에 대한 테스트를 진행했으므로, 1만원 지불 시의 예외 상황을 마무리하는 것이 더 좋아보입니다.

예외 상황을 테스트하려면, 첫 납부일이라는 파라미터가 하나 더 필요합니다. calculateExpiryDate의 파라미터로 첫 납부일 파라미터를 추가해줄 수도 있지만, 파라미터가 3개 이상이면 유지보수에 불리하므로 객체(PayData)로 묶어 전달하는 방식을 선택하겠습니다.

@Getter
@Builder
@AllArgsConstructor
public class PayData {
    private LocalDate billingDate;
    private int payAmount;
}
public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {
        return payData.getBillingDate().plusMonths(1);
    }
}
public class ExpiryDateCalculatorTest {

    @Test
    @DisplayName("만원 납부하면 한달 뒤가 만료일이 됨")
    void pay_10000_won(){
        assertExpiryDate(PayData.builder()
                .billingDate(LocalDate.of(2022,11,17))
                .payAmount(10000).build(),LocalDate.of(2022,12,17));
        assertExpiryDate(PayData.builder()
                .billingDate(LocalDate.of(2022,8,3))
                .payAmount(10000).build(), LocalDate.of(2022,9,3));
    }

    @Test
    @DisplayName("만원 납부했을 때, 납부일과 한달 뒤의 만료일의 날짜가 일치하지 않음")
    void different_date_10000_won() {
        assertExpiryDate(PayData.builder()
                .billingDate(LocalDate.of(2022,1,31))
                .payAmount(10000).build(),LocalDate.of(2022,2,28));

        assertExpiryDate(PayData.builder()
                .billingDate(LocalDate.of(2022,5,31))
                .payAmount(10000).build(),LocalDate.of(2022,6,30));
    }

    // 공통 로직을 메서드로 추출
    private void assertExpiryDate(PayData payData, LocalDate expectedExpiryDate) {
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate realExpiryDate = cal.calculateExpiryDate(payData);
        Assertions.assertEquals(expectedExpiryDate, realExpiryDate);
    }
}

7. 예외 상황 테스트 계속 (상수 값)

@Test
@DisplayName("첫 납부일과 만료일 일자가 다를 때 만원 납부")
void different_date_one_more_10000_won() {
    PayData payData = PayData.builder()
            .firstBillingDate(LocalDate.of(2022, 1, 31))
            .billingDate(LocalDate.of(2022,2,28))
            .payAmount(10000).build();
    
    assertExpiryDate(payData, LocalDate.of(2022,3,31));
}

첫 납부일 일자를 전달하는 firstBillingDate가 빌더에 추가되었습니다. 이 친구는 아직 정의되지 않았으므로 빨간색으로 컴파일 에러가 발생합니다. PayData에 필드로 추가하여 컴파일 에러를 제거하겠습니다.

@Getter
@Builder
@AllArgsConstructor
public class PayData {
    
    private LocalDate firstBillingDate; // 필드에 추가
    private LocalDate billingDate;
    private int payAmount;
}

컴파일 에러만 제거한 상태에서, 테스트를 수행하면 다음과 같이 실패하게 됩니다.

일단 테스트를 통과 시키기 위해, 상수 값을 이용하겠습니다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {
        
        if (payData.getBillingDate().equals(LocalDate.of(2022,1,31))) {
            return LocalDate.of(2022, 3, 31);   // 상수 값 추가
        }
        return payData.getBillingDate().plusMonths(1);
    }
}

테스트를 다시 실행해보면, 위의 "첫 납부일과 만료일 일자가 다를 때 만원 납부" 테스트는 성공하지만, 나머지 테스트들에서 NPE 에러가 발생합니다.

나머지 테스트에서는 payData.getBillingDate()를 검사하는 코드가 null이어서 발생한 것 같습니다. 이를 방지하기 위해, null을 검사하는 코드를 추가하겠습니다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {

        if (payData.getFirstBillingDate() != null) {
            if (payData.getFirstBillingDate().equals(LocalDate.of(2022,1,31))) {
                return LocalDate.of(2022, 3, 31);   // 상수 값 추가
            }
        }
        return payData.getBillingDate().plusMonths(1);
    }
}

8. 예외 상황 테스트 (구현 일반화)

7번 단계에서는 2022-03-31 이라는 상수 값을 리턴하도록 해서 테스트를 통과시켰습니다. 이를 새로운 테스트 사례를 추가하여 구현을 일반화하겠습니다.

  • 첫 납부일이 2022-05-31이고, 만료일인 2022-06-30에 1만원을 납부하면, 다음 만료일은 2022-07-31이다.

"첫 납부일과 만료일 일자가 다를 때 만원 납부" 테스트에 위의 사례를 추가합니다.

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

    assertExpiryDate(payData, LocalDate.of(2022,3,31));

    // 새로 추가한 사례
    PayData payData2 = PayData.builder()
        .firstBillingDate(LocalDate.of(2022, 5, 31))
        .billingDate(LocalDate.of(2022, 6, 30))
        .payAmount(10000).build();

    assertExpiryDate(payData2, LocalDate.of(2022,7,31));
}

실행하면, 당연히 새로 추가한 테스트 사례때문에 테스트가 실패합니다. 이를 통과시키려면 상수값으로 구현된 로직을 일반화 해야합니다. 이때, 테스트를 통과할 만큼만 구현을 일반화해야 합니다. 간단히,

  • 첫 납부일과 두 번째 납부일의 일자가 다르면 첫 납부일의 일자를 만료일의 일자로 사용

하는 로직을 구성하면, 테스트를 통과할 것 같습니다.

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);
    }
}

테스트를 실행해보면, 테스트를 통과한 것을 알 수 있습니다. 다른 테스트도 실행하여, 테스트가 깨지지 않는지 확인합시다.

9. 다음 테스트 선택

예외 처리에 대한 테스트를 진행했으므로, 다음 테스트를 구상합니다. 6번 단계에서 후보군이었던

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

이라는 쉬운 케이스를 테스트하면 될 것 같습니다.

@Test
@DisplayName("2만원 이상 납부하면 금액에 비례해서 만료일 계산")
void n_10000_won_calculate(){
    assertExpiryDate(PayData.builder()
            .billingDate(LocalDate.of(2022,3,1))
            .payAmount(20000).build(), LocalDate.of(2022,5,1));
    assertExpiryDate(PayData.builder()
            .billingDate(LocalDate.of(2022,3,1))
            .payAmount(30000).build(), LocalDate.of(2022,6,1));
}

테스트하면 당연히 실패합니다. n 만원 이상 시의 로직을 구현해주도록 하겠습니다.

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(PayData payData) {
        int addedMonths = payData.getPayAmount() / 10000;  // n만원 납부 시, n개월 만큼 연장
        if (payData.getFirstBillingDate() != null) {
            LocalDate candidateExp = payData.getBillingDate().plusMonths(addedMonths); // addedMonths 만큼 기간 연장
            if (payData.getFirstBillingDate().getDayOfMonth() !=
                    candidateExp.getDayOfMonth()) { 
                return candidateExp.withDayOfMonth(payData.getFirstBillingDate().getDayOfMonth()); 
            }
        }
        return payData.getBillingDate().plusMonths(addedMonths);
    }
}

정리

완전한 기능 구현과 예외 처리는 더 진행해야 하지만, 애플리케이션의 완성이 목표가 아니므로 여기까지 진행하도록 하겠습니다.

이번 포스트에서 TDD는 철저히 초반에 제시했던 작성 기법 그대로 따라갔습니다.

  1. 초반에는 복잡한 테스트보다는 구현하기 쉬운 테스트부터 시작한다.

  2. 예외 상황을 먼저 테스트한다.

  3. 테스트 코드를 작성한 뒤에는 리팩토링을 진행한다.

더 상세하게는,

  1. 처음 테스트는 통과만 할 수 있도록 상수 값을 리턴
  2. 기능을 구현할 때는 정확히 테스트를 통과할 정도로만 작성
  3. 리팩토링은 테스트 코드의 역할 - 테스트 메서드는 스스로 무엇을 테스트하는지 명확하게 설명할 수 있어야 한다 - 을 해치지 않는 선에서 진행

과 같은 Tip들이 있었습니다.

TDD를 진행하다 보면 처음에는, 지금까지 개발해왔던 방식:

  • 만들 기능을 설계하고, 뒤이어 개발을 진행하고 그 이후에 테스트를 하는 방식

보다 개발 속도가 너무 처지는 느낌에 꼭 필요한가? 라는 생각이 들 수도 있습니다. 하지만, TDD가 익숙해지면 상황에 따라 구현 속도를 조절할 수 있습니다. 지금은 익숙하지 않은 도구를 다루기 시작하는 상황이므로, 너무 조급하게 생각하지 않아도 될 것 같습니다.

또한 TDD로 개발을 하면, 다음과 같은 장점이 있습니다.

  1. 코드 수정에 대한 피드백이 빠르다.
  2. TDD는 개발 과정에서 지속적인 리팩토링을 수행하므로, 코드 품질이 급격하게 나빠지지 않게 막아준다.
  3. 항상 예외 상황을 상정하고 개발하므로 이후에 예외 상황으로 인한 버그를 줄여준다.

특히, 유지보수와 버그 수정이 일상인 개발자들에게 필수적으로 알아야 하는 지식이라고 생각합니다.

더 자세한 내용은 테스트 주도 개발 시작하기 - 최범균 저 를 참고하시길 바랍니다.

profile
잘못된 고민은 없습니다

0개의 댓글