053. (Getting Started with Test-Driven Development) 3. 테스트 코드 작성 순서

3. 테스트 코드 작성 순서

3.1. 테스트 작성 순서

TDD에서 테스트 코드의 작성 순서는 아래 규칙에 따라야 한다.

  • 쉬운 경우에서 어려운 경우로 진행
  • 예외적인 경우에서 정상인 경우로 진행

이번엔 반대로 어려운 경우를 먼저 시작하거나, 정상적인 경우를 먼저 시작하면 어떤 상황을 겪는지 예제를 통해 살펴보도록 하자.

3.1.1. 초반에 복잡한 테스트부터 시작하면 안 되는 이유

앞선 포스팅에서 진행한 암호 강도 검사기 예제를 다시 떠올려보자.

  • 대문자 포함 규칙만 충족하는 경우
  • 모든 규칙을 충족하는 경우
  • 숫자를 포함하지 안혹 나머지 규칙은 충족하는 경우

이 순서대로 TDD를 진행해보자.

먼저 대문자만 포함하는 경우이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PasswordStrengthMeterTest {

private val meter = PasswordStrengthMeter()

private fun assertStrength(password: String?, expected: PasswordStrength) {
val result = meter.meter(password)
assertEquals(expected, result)
}

@Test
fun meetsOnlyUpperCriteria_Then_Weak() {
assertStrength("abcDef", PasswordStrength.WEAK)
}
}

위 테스트를 통과시키기 위해 아래와 같이 무조건 WEAK을 반환하도록 코드를 작성한다.

1
2
3
4
5
6
7
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
return PasswordStrength.WEAK
}

}

이제 모든 규칙을 충족하는 테스트 코드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PasswordStrengthMeterTest {

private val meter = PasswordStrengthMeter()

private fun assertStrength(password: String?, expected: PasswordStrength) {
val result = meter.meter(password)
assertEquals(expected, result)
}

@Test
fun meetsOnlyUpperCriteria_Then_Weak() {
// ...
}

@Test
fun meetsAllCriteria_Then_Weak() {
assertStrength("abcDef12", PasswordStrength.STRONG)
}
}

새로 추가한 meetsAllCriteria_Then_Weak()만을 통과시키기 위해선 결과값을 WEAK에서 STRONG으로 변경해야 한다.

그럼 최초에 작성한 테스트는 실패하게 되고, 둘 다 통과시키기 위해 모든 규칙을 확인하는 코드를 구현해야하는 상황이 벌어진다.

한 번에 완벽한 코드를 만들어내는 것은 물론 이상적인 상황이지만, 이러한 방향은 결국 버그를 야기하는 경우가 대다수이고

이를 해소하기위한 리소스와 추가적인 테스트 코드 작성 비용까지 대가로 치뤄야한다.

3.2. 테스트 작성 순서 연습

이번엔 암호 강도 검사기가 아닌 다른 기능을 개발하면서 테스트 코드의 작성 순서를 익혀보자.

요구사항은 아래와 같다.

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

위의 요구사항을 만료일을 결정하는 기능을 TDD를 기반으로 개발해보자.

만료일을 계산하는 이 기능의 이름은 ExpiryDateCalculator로 명명한다.

이에 따라 먼저 테스트를 위한 코드인 ExpiryDateCalculatorTest를 먼저 생성한다.

1
2
3
class ExpiryDateCalculatorTest {

}

3.2.1. 쉬운 것부터 테스트

테스트 코드를 작성하기 전에 상술한 규칙을 다시 리마인드해보자.

  • 쉬운 경우에서 어려운 경우로 진행
  • 예외적인 경우에서 정상인 경우로 진행

어떤 기능이 가장 쉬울까?

1만원 납부시, 한 달 뒤의 날짜를 만료일로 계산하는 것이 가장 쉬울 것 같다.

이에 대한 테스트 코드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ExpiryDateCalculatorTest {

@Test
@DisplayName("만원_납부하면_한달_뒤가_만료일이_됨")
fun expirationDateAfterPayingTenThousand() {
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)
}

}

2019년 3월 1일에 만원을 납부하면 2019년 4월 1일이 만료일이 되는 테스트 코드를 먼저 작성하였다.

이 테스트를 통과시키기 위해 calculateExpiryDate()LocalDate.of(2019, 4, 1) 객체를 반환하도록 작성하자.

1
2
3
4
5
6
7
8
9
class ExpiryDateCalculator {

fun calculateExpiryDate(billingDate: LocalDate, payAmount: Int): LocalDate =
LocalDate.of(
2019,
4,
1
)
}

이제 테스트를 통과한다.

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

이제 동일 조건의 예시를 추가하면서 구현을 일반화해보자.

이번엔 납부일에 2019년 5월 5일을 추가해서 2019년 6월 6일의 기대값을 부여한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ExpiryDateCalculatorTest {

@Test
@DisplayName("만원_납부하면_한달_뒤가_만료일이_됨")
fun expirationDateAfterPayingTenThousand() {
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)

val billingDate2 = LocalDate.of(2019, 5, 5)
val payAmount2 = 10_000

val calculator2 = ExpiryDateCalculator()
val expiryDate2 = calculator2.calculateExpiryDate(billingDate2, payAmount2)

assertEquals(LocalDate.of(2019, 6, 5), expiryDate2)
}

}

calculateExpiryDate() 메서드는 LocalDate.of(2019, 4, 1)만을 반환하고 있으므로 테스트는 실패한다.

다소 일반적인 로직이므로 아래와 같이 코드를 구현해보자.

1
2
3
4
5
6
7
8
class ExpiryDateCalculator {

fun calculateExpiryDate(
billingDate: LocalDate,
payAmount: Int
): LocalDate = billingDate.plusMonths(1)

}

이번엔 테스트를 모두 통과했다.

3.2.3. 코드 정리 : 중복 제거

다음 테스트 코드를 추가하기전에 리팩토링을 수행하자.

테스트 코드를 살펴보면 납부일과 납부액을 정하고, 만료일을 계산하는 반복적인 코드가 눈에 들어온다.

아래와 같이 중복을 제거해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class ExpiryDateCalculatorTest {

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

@Test
@DisplayName("만원_납부하면_한달_뒤가_만료일이_됨")
fun expirationDateAfterPayingTenThousand() {
assertExpiryDate(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 10_000,
expectedExpiryDate = LocalDate.of(2019, 4, 1)
)

assertExpiryDate(
billingDate = LocalDate.of(2019, 5, 5),
payAmount = 10_000,
expectedExpiryDate = LocalDate.of(2019, 6, 5)
)
}

}

테스트가 모두 성공했다면 성공적인 중복 제거라고 볼 수 있다.

3.2.4. 예외 상황 처리

쉬운 경우를 처리했으니, 이번엔 예외 상황을 처리해보자.

아래와 같이 단순히 한 달 추가로 끝나지않는 경우가 예외 상황에 해당한다.

  • 납부일이 2019년 1월 31일이고, 납부액이 1만원이면 만료일은 2019년 2월 28일이다.
  • 납부일이 2019년 5월 31일이고, 납부액이 1만원이면 만료일은 2019년 6월 30일이다.
  • 납부일이 2020년 1월 31일이고, 납부액이 1만원이면 만료일은 2020년 2월 29일이다.

날짜 관련 기능을 개발할때 무조건 고려해야하는 예외 사항인 윤달과 각 월별의 날이 다른 경우이다.

먼저 납부일이 2019년 1월 31일이고, 납부액이 1만원이면 만료일은 2019년 2월 28일인 경우를 테스트 코드로 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ExpiryDateCalculatorTest {

// ...

@Test
@DisplayName("납부일과 만료일의 일자가 같지 않음")
fun billingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
billingDate = LocalDate.of(2019, 1, 31),
payAmount = 10_000,
expectedExpiryDate = LocalDate.of(2019, 2, 28)
)
}

}

각 월별의 날이 다른 경우를 이미 LocalDate 클래스의 plusMonths()가 고려해두었기 때문에 테스트가 통과하였다.

남은 두 개의 예외 상황도 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ExpiryDateCalculatorTest {

// ...

@Test
@DisplayName("납부일과 만료일의 일자가 같지 않음")
fun billingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
billingDate = LocalDate.of(2019, 1, 31),
payAmount = 10_000,
expectedExpiryDate = LocalDate.of(2019, 2, 28)
)

assertExpiryDate(
billingDate = LocalDate.of(2019, 5, 31),
payAmount = 10_000,
expectedExpiryDate = LocalDate.of(2019, 6, 30)
)

assertExpiryDate(
billingDate = LocalDate.of(2020, 1, 31),
payAmount = 10_000,
expectedExpiryDate = LocalDate.of(2020, 2, 29)
)
}

}

모두 테스트 케이스를 통과한다.

기능 개발시 검증된 라이브러리를 사용해야하는 이유가 바로 이것이다.

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

이번엔 또 다른 경우를 고려해보자.

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

위 경우에서 파생되는 예외 상황은 아래와 같다.

  • 첫 납부일이 2019년 1월 31일이고, 만료되는 2019년 2월 28일에 1만원을 납부하면 다음 만료일은 2019년 3월 31일이다.
  • 첫 납부일이 2019년 1월 30일이고, 만료되는 2019년 2월 28일에 1만원을 납부하면 다음 만료일은 2019년 3월 30일이다.
  • 첫 납부일이 2019년 5월 31일이고, 만료되는 2019년 6월 30일에 1만원을 납부하면 다음 만료일은 2019년 7월 31일이다.

쉬운 예시는 한 번에 2개월 이상의 요금을 납부하는 경우지만, 예외 상황은 1달 단위로 요금을 납부하는 경우이다.

지금까지의 테스트가 1개월 요금 지불을 기준으로 하므로, 2개월 이상 요금 지불을 테스트하는 것이 좋을 것 같다.

이에 대한 테스트를 진행하려면 첫 납부일이 필요하다.

따라서 기존 코드에 첫 납부일을 추가해보자.

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

지금까지는 납부일과 요금이라는 2개의 파라미터를 받아서 처리해왔다.

이제 첫 납부일을 받을 수 있도록 파라미터를 추가해 총 3개의 파라미터를 넘겨받아야한다.

다른 방법으로는 첫 납부일, 납부일, 요금의 속성을 가진 객체를 넘겨받는 것이다.

파라미터의 증가는 달갑지않으므로 객체를 넘겨받는 방식으로 변경해보자.

첫 납부일, 납부일, 요금의 속성을 가진 객체 PayData를 추가한다.

1
2
3
4
data class PayData(
val billingDate: LocalDate,
val payAmount: Int,
)

이제 ExpiryDateCalculator 클래스도 변경하자.

1
2
3
4
5
6
7
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate = payData.billingDate.plusMonths(1)

}

프로덕션 코드가 변경되었으니 테스트 코드도 이에 맞추어 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class ExpiryDateCalculatorTest {

private fun assertExpiryDate(
payData: PayData,
expectedExpiryDate: LocalDate
) {
val calculator = ExpiryDateCalculator()
val expiryDate = calculator.calculateExpiryDate(payData)
assertEquals(expectedExpiryDate, expiryDate)
}

@Test
@DisplayName("만원_납부하면_한달_뒤가_만료일이_됨")
fun expirationDateAfterPayingTenThousand() {

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 4, 1)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 5, 5),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 6, 5)
)
}

@Test
@DisplayName("납부일과 만료일의 일자가 같지 않음")
fun billingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 1, 31),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 2, 28)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 5, 31),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 6, 30)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2020, 1, 31),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2020, 2, 29)
)
}

}

3.2.7. 예외 상황 테스트 진행 계속

원래 처리하려고 했던 예외 상황을 다시 가져와보자.

  • 첫 납부일이 2019년 1월 31일이고, 만료되는 2019년 2월 28일에 1만원을 납부하면 다음 만료일은 2019년 3월 31일이다.
  • 첫 납부일이 2019년 1월 30일이고, 만료되는 2019년 2월 28일에 1만원을 납부하면 다음 만료일은 2019년 3월 30일이다.
  • 첫 납부일이 2019년 5월 31일이고, 만료되는 2019년 6월 30일에 1만원을 납부하면 다음 만료일은 2019년 7월 31일이다.

PayData 클래스를 만들었지만 아직 첫 납부일이 없으므로 프로퍼티로 추가해보자.

1
2
3
4
5
data class PayData(
val firstBillingDate: LocalDate = LocalDate.of(2019, 1, 31),
val billingDate: LocalDate,
val payAmount: Int,
)

파라미터가 추가되어서, 컴파일 오류가 발생하니 이를 수정해야한다.

먼저 첫 납부일이 없는 기존 테스트 케이스를 대비하기 위해 default parameter를 적용해두었다.

이제 테스트 코드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ExpiryDateCalculatorTest {

@Test
@DisplayName("첫 납부일과 만료일 일자가 다를때 만원 납부")
fun paymentTenThousandWhenFirstBillingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 31),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 3, 31)
)
}
}

이제 프로덕션 코드를 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
if (payData.firstBillingDate == LocalDate.of(2019, 1, 31)) {
return LocalDate.of(2019, 3, 31)
}

return payData.billingDate.plusMonths(1)
}

}

이제 새로 추가한 테스트 코드는 통과하지만, 기존에 납부일이 2019년 1월 31일인 경우의 테스트 케이스가 실패한다.

PayData 클래스를 아래와 같이 변경하자.

1
2
3
4
5
data class PayData(
val firstBillingDate: LocalDate? = null,
val billingDate: LocalDate,
val payAmount: Int,
)

이제 모든 테스트가 통과된다.

이제 새로운 예외 상황을 추가해보자.

  • 첫 납부일이 2019년 1월 30일이고, 만료되는 2019년 2월 28일에 1만원을 납부하면 다음 만료일은 2019년 3월 30일이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ExpiryDateCalculatorTest {
// ...

@Test
@DisplayName("첫 납부일과 만료일 일자가 다를때 만원 납부")
fun paymentTenThousandWhenFirstBillingDateAndExpiryDateNotSameDay() {
// ...

assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 30),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 3, 30)
)
}
}

위 테스트 케이스는 아래와 같이 실패한다.

1
2
3
org.opentest4j.AssertionFailedError: 
Expected :2019-03-30
Actual :2019-03-28

테스트를 통과할만큼만 구현해보도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(1)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(1)
}

}

이제 모든 테스트가 통과한다.

이제 다음 예외 상황을 추가하자.

  • 첫 납부일이 2019년 5월 31일이고, 만료되는 2019년 6월 30일에 1만원을 납부하면 다음 만료일은 2019년 7월 31일이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ExpiryDateCalculatorTest {
@Test
@DisplayName("첫 납부일과 만료일 일자가 다를때 만원 납부")
fun paymentTenThousandWhenFirstBillingDateAndExpiryDateNotSameDay() {
// ...

assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 5, 31),
billingDate = LocalDate.of(2019, 6, 30),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 7, 31)
)
}
}

새로 추가한 예외 상황도 테스트를 통과함을 알 수 있다.

3.2.8. 코드 정리 : 상수를 변수로

다시 정리할 코드가 있는지 살펴보자.

이번엔 프로덕션 코드인 ExpiryDateCalculator 클래스를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(1)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(1)
}

}

아래에서 한 달 뒤의 날짜를 연산할 때 상수 1을 사용하고 있다.

1
payData.billingDate.plusMonths(1)

이를 아래와 같이 변수로 변경하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = 1
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(monthsToAdd)
}
}

상수를 변수로 변경한 뒤에도 테스트가 모두 성공했다면 다음 단계로 넘어가자.

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

이제 쉬운 테스트를 추가해보자.

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

통합해서 테스트 코드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
class ExpiryDateCalculatorTest {
@Test
@DisplayName("2만원 이상 납부시, 만료일이 비례해서 증가한다.")
fun paymentOverTwentyThousandWhenExpiryDateIncreases() {
assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 20_000,
),
expectedExpiryDate = LocalDate.of(2019, 5, 1)
)
}
}

테스트 코드를 수행하면 아래와 같이 실패한다.

1
2
3
org.opentest4j.AssertionFailedError: 
Expected :2019-05-01
Actual :2019-04-01

테스트 통과를 위해 납부 금액에 따라 몇 개월씩 더할지 동적으로 계산해서 변수에 넣어주도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(monthsToAdd)
}
}

이제 테스트는 통과한다.

몇 가지 사례를 더 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class ExpiryDateCalculatorTest {

// ...

@Test
@DisplayName("2만원 이상 납부시, 만료일이 비례해서 증가한다.")
fun paymentOverTwentyThousandWhenExpiryDateIncreases() {
assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 20_000,
),
expectedExpiryDate = LocalDate.of(2019, 5, 1)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 30_000,
),
expectedExpiryDate = LocalDate.of(2019, 6, 1)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 50_000,
),
expectedExpiryDate = LocalDate.of(2019, 8, 1)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 3, 1),
payAmount = 70_000,
),
expectedExpiryDate = LocalDate.of(2019, 10, 1)
)
}
}

모두 테스트를 통과함을 확인했다면 다음 단계로 넘어가자.

3.2.10. 예외 상황 테스트 추가

또 다른 예외 상황을 추가해보자.

이번에 추가살 상황은 아래와 같다.

  • 첫 납부일이 2019년 1월 31일이고, 만료되는 2019년 2월 28일에 2만원을 납부하면 다음 만료일은 2019년 4월 30일이다.

테스트 코드를 먼저 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ExpiryDateCalculatorTest {
@Test
@DisplayName("첫 납부일과 만료일 일자가 다를때 2만원 이상 납부")
fun paymentOverTwentyThousandWhenFirstBillingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 31),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 20_000,
),
expectedExpiryDate = LocalDate.of(2019, 4, 30)
)
}
}

위 테스트 코드는 아래 오류와 함께 실패한다.

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

4월은 30일까지 있는데, 프로덕션 코드의 결과값이 4월 31일로 지정되기때문에 오류가 발생한 것을 알 수 있다.

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

  • 후보 만료일이 포함된 달의 마지막 날이 첫 납부일의 일자보다 작은 경우

이제 프로덕션 코드를 수정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
if (YearMonth.from(candidateExp).lengthOfMonth() < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(YearMonth.from(candidateExp).lengthOfMonth())
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(monthsToAdd)
}
}

이제 테스트 코드는 통과한다.

이제 다른 사례를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ExpiryDateCalculatorTest {

// ...

@Test
@DisplayName("첫 납부일과 만료일 일자가 다를때 2만원 이상 납부")
fun paymentOverTwentyThousandWhenFirstBillingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 31),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 20_000,
),
expectedExpiryDate = LocalDate.of(2019, 4, 30)
)

assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 31),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 40_000,
),
expectedExpiryDate = LocalDate.of(2019, 6, 30)
)

assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 3, 31),
billingDate = LocalDate.of(2019, 4, 30),
payAmount = 30_000,
),
expectedExpiryDate = LocalDate.of(2019, 7, 31)
)
}
}

추가한 예외 상황도 모두 통과함을 확인했다면 이제 코드를 정리해보자.

3.2.11. 코드 정리

다시 프로덕션 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
if (YearMonth.from(candidateExp).lengthOfMonth() < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(YearMonth.from(candidateExp).lengthOfMonth())
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(monthsToAdd)
}
}

날짜 계산 관련된 코드들이 중복되어 다소 복잡도가 증가했음을 확인할 수 있다.

제일 먼저 후보 만료일이 속한 월의 마지막 일자를 구하는 코드의 중복을 제거해보자.

중복인 코드는 아래와 같다.

1
YearMonth.from(candidateExp).lengthOfMonth()

이를 별도의 상수로 분리해서 아래와 같이 중복을 제거한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
if (payData.firstBillingDate.dayOfMonth != candidateExp.dayOfMonth) {
val dayLengthOfCandidateMonth = YearMonth.from(candidateExp).lengthOfMonth()
if (dayLengthOfCandidateMonth < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(dayLengthOfCandidateMonth)
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(payData.firstBillingDate.dayOfMonth)
}
}

return payData.billingDate.plusMonths(monthsToAdd)
}
}

변경 후에도 테스트 코드는 모두 통과하고 있음을 확인할 수 있다.

이번엔 첨 납부일의 일자를 구하는 코드의 중복을 제거해보자.

1
payData.firstBillingDate.dayOfMonth

중복 제거한 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
val dayOfFirstBillingDate = payData.firstBillingDate.dayOfMonth
if (dayOfFirstBillingDate != candidateExp.dayOfMonth) {
val dayLengthOfCandidateMonth = YearMonth.from(candidateExp).lengthOfMonth()
if (dayLengthOfCandidateMonth < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(dayLengthOfCandidateMonth)
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(dayOfFirstBillingDate)
}
}

return payData.billingDate.plusMonths(monthsToAdd)
}
}

변경 후에도 테스트 코드는 모두 통과한다.

코드를 좀 더 정리할지, 다음 테스트 케이스를 추가할지 고민해보자.

중복을 제거했지만 여전히 코드의 가독성에는 확신이 들지않으므로 한 번 더 정리해보자.

이 비즈니스 로직의 첫 번째 분기는 첫 납부일의 존재 유무이므로 이를 개선해보도록 하자.

먼저 원활한 로직 분리를 위해 else 블럭을 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
val dayOfFirstBillingDate = payData.firstBillingDate.dayOfMonth
if (dayOfFirstBillingDate != candidateExp.dayOfMonth) {
val dayLengthOfCandidateMonth = YearMonth.from(candidateExp).lengthOfMonth()
if (dayLengthOfCandidateMonth < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(dayLengthOfCandidateMonth)
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(dayOfFirstBillingDate)
} else {
return candidateExp
}
} else {
return payData.billingDate.plusMonths(monthsToAdd)
}
}
}

테스트를 통과한다면 첫 납부일에 해당하는 영역을 들어내서 별도의 메서드인 expiryDateUsingFirstBillingDate()로 분리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
return expiryDateUsingFirstBillingDate(payData, monthsToAdd)
} else {
return payData.billingDate.plusMonths(monthsToAdd)
}
}

private fun expiryDateUsingFirstBillingDate(
payData: PayData,
monthsToAdd: Long,
): LocalDate {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
val dayOfFirstBillingDate = payData.firstBillingDate!!.dayOfMonth
if (dayOfFirstBillingDate != candidateExp.dayOfMonth) {
val dayLengthOfCandidateMonth = YearMonth.from(candidateExp).lengthOfMonth()
if (dayLengthOfCandidateMonth < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(dayLengthOfCandidateMonth)
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(dayOfFirstBillingDate)
} else {
return candidateExp
}
}
}

이제 핵심 비즈니스 로직인 calculateExpiryDate() 메서드가 다소 명확해졌으며, 테스트 코드도 전부 통과한다.

이 다음엔 몇 가지 보조 메서드를 추가해보자.

첫 납부일의 일자와, 예상 납부일의 일자를 비교하는 메서드를 isSameDayOfMonth()로 분리한 뒤 적용하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
return expiryDateUsingFirstBillingDate(payData, monthsToAdd)
} else {
return payData.billingDate.plusMonths(monthsToAdd)
}
}

private fun expiryDateUsingFirstBillingDate(
payData: PayData,
monthsToAdd: Long,
): LocalDate {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
val dayOfFirstBillingDate = payData.firstBillingDate!!.dayOfMonth
if (isSameDayOfMonth(payData.firstBillingDate, candidateExp).not()) {
val dayLengthOfCandidateMonth = YearMonth.from(candidateExp).lengthOfMonth()
if (dayLengthOfCandidateMonth < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(dayLengthOfCandidateMonth)
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(dayOfFirstBillingDate)
} else {
return candidateExp
}
}

private fun isSameDayOfMonth(date1: LocalDate, date2: LocalDate): Boolean {
return date1.dayOfMonth == date2.dayOfMonth
}
}

테스트가 모두 통과했다면

이번엔 특정 월의 일자를 구하는 메서드를 lastDayOfMonth() 메서드로 분리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ExpiryDateCalculator {

fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = payData.payAmount / 10_000L
if (payData.firstBillingDate != null) {
return expiryDateUsingFirstBillingDate(payData, monthsToAdd)
} else {
return payData.billingDate.plusMonths(monthsToAdd)
}
}

private fun expiryDateUsingFirstBillingDate(
payData: PayData,
monthsToAdd: Long,
): LocalDate {
// 후보 만료일 계산
val candidateExp = payData.billingDate.plusMonths(monthsToAdd)
val dayOfFirstBillingDate = payData.firstBillingDate!!.dayOfMonth
if (isSameDayOfMonth(payData.firstBillingDate, candidateExp).not()) {
val dayLengthOfCandidateMonth = lastDayOfMonth(candidateExp)
if (dayLengthOfCandidateMonth < payData.firstBillingDate.dayOfMonth) {
// candidateExp이 가지고 있는 월의 마지막 날이, 첫 납부일의 일자보다 적은 경우
return candidateExp.withDayOfMonth(dayLengthOfCandidateMonth)
}
// 첫 납부일의 일자와 후보 만료일의 일자가 다른 경우, 첫 납부일의 일자를 후보 만료일의 일자로 사용한다.
return candidateExp.withDayOfMonth(dayOfFirstBillingDate)
} else {
return candidateExp
}
}

private fun isSameDayOfMonth(date1: LocalDate, date2: LocalDate): Boolean {
return date1.dayOfMonth == date2.dayOfMonth
}

private fun lastDayOfMonth(date: LocalDate): Int {
return YearMonth.from(date).lengthOfMonth()
}
}

어느정도 가독성도 확보했고, 테스트도 모두 통과한다면 다음 단계로 넘어가자.

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

이제 10만원을 납부하면 서비스를 10개월이 아닌 1년을 제공한다는 요구사항을 구현해보자.

테스트 코드를 먼저 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ExpiryDateCalculatorTest {

// ...

@Test
@DisplayName("10만원을 납부하면 1년동안 서비스 제공")
fun paymentOneHundredThousandThenProvidedForOneYear() {
assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 1, 28),
payAmount = 100_000,
),
expectedExpiryDate = LocalDate.of(2020, 1, 28)
)
}
}

현재 프로덕션 코드 상태로 위의 테스트 케이스는 아래와 같이 실패한다.

1
2
3
org.opentest4j.AssertionFailedError: 
Expected :2020-01-28
Actual :2019-11-28

테스트를 통과시키는 가장 쉬운 방법은 지불한 금액이 10만원인 경우를 비교하는 것이다.

아래 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun calculateExpiryDate(
payData: PayData
): LocalDate {
var monthsToAdd: Long = if (payData.payAmount == 100_000) {
12
} else {
payData.payAmount / 10_000L
}

if (payData.firstBillingDate != null) {
return expiryDateUsingFirstBillingDate(payData, monthsToAdd)
} else {
return payData.billingDate.plusMonths(monthsToAdd)
}
}

// ...
}

이제 테스트를 통과한다.

또 다른 예외 사항이 무엇이 있을까?

2020년 2월 29일처럼 윤달의 마지막 날에 10만월 납부하거나, 13만원을 납부하는 경우 등 무궁무진하다.

이들 사례를 검증하는 테스트 케이슬르 먼저 작성하고 진행하면 좀 더 강건한 프로그램이 될 것이다.

3.3. 테스트할 목록 정리하기

TDD를 시작할 땐, 테스트할 목록을 미리 정리하면 좋다.

아래는 그 예시이다.

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

나열했다면 어떤 테스트가 구현이 쉬울지 생각해보고, 어떤 테스트가 예외적일지 상상해본다.

시간을 조금 들여서 구현의 난이도나 구조를 검토하면 다음 테스트 대상을 선택할 때도 도움이 된다.

테스트 과정에서 새로운 테스트 사례를 발견하면 그 사례를 목록에 추가해서 놓치지않도로 ㄱ해야한다.

다만, 테스트 목록을 작성했다고해서 모든 테스트를 한 번에 작성해서는 안된다.

한 번에 작성하는 테스트 코드가 늘어날수록 프로덕션 코드의 리팩토링을 어렵게하기 때문이다.