Immediate feedback for interface design decisions

TDD 는 설계 방법론이 아니다. TDD 가 설계(design)의 필요성을 대체하지 않는다.

Kent Beck said "Immediate feedback for interface design decisions".

위 내용이 TDD 가 제공하는 이점이자 핵심인 것 같다.

말로만 들어서는 이해하기 어렵다. 직접 코딩을 하면서 느껴야 Kent Beck 님이 말한 내용 중 "Immediate feedback for interface design decisions" 이 부분에 대해서 공감을 할 수 있다.

Examples

아래 예제는 테스트 주도 개발 시작하기 - 최범균 책에있는 '서비스 만료일 계산' 예제를 따왔다.

서비스 규칙:

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

적절한 클래스 이름을 생각한 후에 빈 테스트 클래스를 작성한다.

class ExpiryDateCalculatorTest { }

쉬운 것 부터 테스트를 시작한다. (아주 중요한 규칙이다.) 가독성을 위해서 테스트 메서드명은 한글로 작성한다.

@Test
fun `만원을 납부하면 한달 뒤가 만료일이 됨`() {
    val billingDate = LocalDate.of(2019, 3, 1)
    val payAmount = 10_000
    val calculator = ExpiryDateCalculator()
    val expiryDate = calculator.calculateExpiryDate(billingDate, payAmount)
    assertEquals(LocalDate.of(2019, 4, 1), expiryDate)
}

RedGreenRefactor 규칙에 따라 실패하는 테스트 코드를 작성하고, 실행한다.

다음으로 테스트를 통과 시키기 위해서 ExpiryDateCalculator 클래스를 작성해야 한다. 이 시점부터, TDD 가 인터페이스 디자인 결정에 대한 즉각적인 피드백 을 준다는 느낌을 받을 수 있다.

class ExpiryCalculator {
    fun calculateExpiryDate(billingDate: LocalDate, payAmount: Int): LocalDate {
        return LocalDate.of(2019, 4, 1) // hard coding
    }
}

하드 코딩으로 테스트를 통과하게 만들었다.

이제 새로운 단언문을 추가하면서 구현을 일반화 한다.

@Test
fun `만원을 납부하면 한달 뒤가 만료일이 됨`() {
    val payAmount = 10_000
    val calculator = ExpiryDateCalculator()
    
    val billingDate1 = LocalDate.of(2019, 3, 1)
    val expiryDate = calculator.calculateExpiryDate(billingDate1, payAmount)
    assertEquals(LocalDate.of(2019, 4, 1), expiryDate)

    val billingDate2 = LocalDate.of(2019, 4, 1)
    val expiryDate = calculator.calculateExpiryDate(billingDate, payAmount)
    assertEquals(LocalDate.of(2019, 5, 1), expiryDate)
}

위 코드를 통과 시키기 위해서 구현 클래스의 반환문을 수정해야 한다.

class ExpiryCalculator {
    fun calculateExpiryDate(billingDate: LocalDate, payAmount: Int): LocalDate {
        return billingDate.plusMonths(1)
    }
}

리팩토링할 내용이 보이면 리팩토링을 진행하면된다.

class ExpiryDateCalculatorTest {

    private val calculator = ExpiryCalculator()

    @Test
    fun `만원을 납부하면 한달 뒤가 만료일이 됨`() {
        assertExpiryDate(
            billingDate = LocalDate.of(2024, 1,1),
            payAmount = 10_000,
            expected = LocalDate.of(2024, 2,1)
        )

        assertExpiryDate(
            billingDate = LocalDate.of(2024, 2,1),
            payAmount = 10_000,
            expected = LocalDate.of(2024, 3,1)
        )
    }
    
    private fun assertExpiryDate(
        billingDate: LocalDate,
        payAmount: Int,
        expected: LocalDate
    ) {
        val expiryDate = calculator.calculateExpiryDate(billingDate = billingDate, payAmount = payAmount)
        assertEquals(expected, expiryDate)
    }
}

이제 예외적인 상황을 처리하기 위한 시나리오를 생각한다.

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

예외적인 상황을 처리하기 위한 테스트 코드를 작성하고 실행을 하면 통과할 것이다. LocalDate 의 plusMonths 메서드가 알아서 처리를 해준다.

이제 다음 테스트를 위한 시나리오를 생각한다.

  • 첫 납부일이 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 이다.

위 테스트 케이스를 처리하기 위해서는 calculateExpiryDate 메서드의 파라미터에 첫 납부일이 필요하다. 즉, 인터페이스 디자인 결정 을 내려야 하는 순간이다. 파라미터가 3개 이상인 경우에는 가독성을 위해서 별도의 클래스로 추출하여 리팩토링 할 수 있다.

data class PayData(
    val billingDate: LocalDate,
    val payAmount: Int
)
class ExpiryCalculator {

    fun calculateExpiryDate(payData: PayData): LocalDate {
        return payData.billingDate.plusMonths(1)
    }
}

테스트 코드는 아래와 같이 수정해준다.

class ExpiryDateCalculatorTest {

    private val calculator = ExpiryCalculator()

    @Test
    fun `만원을 납부하면 한달 뒤가 만료일이 됨`() {
        assertExpiryDate(
            billingDate = LocalDate.of(2024, 1,1),
            payAmount = 10_000,
            expected = LocalDate.of(2024, 2,1)
        )

        assertExpiryDate(
            billingDate = LocalDate.of(2024, 2,1),
            payAmount = 10_000,
            expected = LocalDate.of(2024, 3,1)
        )
    }

    private fun assertExpiryDate(
        billingDate: LocalDate,
        payAmount: Int,
        expected: LocalDate
    ) {
        val payData = PayData(billingDate = billingDate, payAmount = payAmount)
        val expiryDate = calculator.calculateExpiryDate(payData)
        assertEquals(expected, expiryDate)
    }
}

이 처럼, RedGreenRefactor 단계로 TDD 를 하다 보면 TDD 가 인터페이스 디자인 결정에 대한 즉각적인 피드백(Immediate feedback for interface design decisions) 을 준다는 느낌을 받는다.