테스트 코드 작성 순서
- 쉬운 경우에서 어려운 경우로 진행
- 예외적인 경우에서 정상인 경우로 진행
초반에 복잡한 테스트부터 시작하면 안 되는 이유
- 초반부터 다양한 조합을 검사하는 복잡한 로직을 작성하면 해당 테스트를 통과하기 위해 한 번에 구현해야 할 코드가 많아진다.
- 테스트를 통과시키기 위해 복잡한 규칙을 구현해야 함⇒ 막막해진다.
- 한 번에 완벽한 코드를 만들면 좋겠지만, 모두가 슈퍼 개발자가 아니다.
- 오히려 한 번에 많은 코드를 만들다 보면 버그를 더 많이 만들게 되고, 버그를 잡느라 시간을 허비하게됨
구현하기 쉬운 테스트부터 시작하기
- 구현이 가장 쉬운 경우부터 시작하면 빠르게 테스트 통과 가능
- 2장의 암호 강도 측정 예를 들면 모든 조건을 충족하는 경우
- 단순히 STRONG을 반환하기만 하면 됨
- 모든 조건을 충족하지 않는 경우를 들 수 있음
- 반대로 단순히 WEEK를 반환하면 됨.
- 2장의 암호 강도 측정 예를 들면 모든 조건을 충족하는 경우
- 그리고 다음으로 쉬운 경우를 테스트 한면 된다.
- 예를 들면 한 규칙 충족여부를 검사하는 테스트이다.
- 길이가
8글자 미만이고나머지 규칙은 충족하는 암호의 강도는NORMAL을 반환 - 반대로
8글자 이상인 규칙만 충족하고나머지는 충족하지 않는강도는WEEK를 반환 - 위의 경우 길이가 8글자 이상인지 여부만 판단하는 로직을 작성하면 됨
- 길이가
- 이런 식으로 각각의 경우에 한 조건 씩 검증하는 테스트를 수행하고 테스트를 통과시키면서 다음으로 쉬운 경우를 추가해 가는 것이 유리하다.
- 예를 들면 한 규칙 충족여부를 검사하는 테스트이다.
- 한 번에 구현하는 시간이 짧아지면 디버깅에 유리하여 문제가 발생해도 원인을 빠르게 찾을 수 있음
예외 상황을 먼저 테스트해야 하는 이유
- 다양한 예외 상황은 복잡한 if- else 블록을 동반하는 경우가 많음
- 예외를 고려하지 않은 코드는 예외 상황을 반영하려면 코드의 구조를 뒤집거나 코드 중간에 예외 상황을 처리하기 위해 조건문을 중복 해서 추가하는 일이 벌어짐
- 이는 코드를 더 복잡하게 만들어 버그 발생 가능성 증가 시킴
- 따라서,
초반에 예외 상황을 테스트해서 이런 가능성을 줄여야 함. - TDD를 하는 동안 예외 상황을 찾고 테스트에 반영하면 예외 상황을 처리하지 않아 발생하는 버그도 줄여줌.
완급 조절
- TDD를 처음 접할 때는 다음 단계에 따라 TDD를 익히면 됨
정해진 값을 리턴값 비교를 이용해서정해진 값을 리턴다양한 테스트를 추가하면서구현을 일반화
- 단계적으로 나아가는 연습이 필요
- 테스트→구현→확인
지속적인 리팩토링
- 테스트를 통과한 뒤에는 리팩토링을 진행
- 매번 리팩토링을 진행해야 하는 것은 아님, 적당한 후보에만 리팩토링 진행
- 코드 중복은 대표적인 리팩토링 대상
- 코드가 길어지면 메소드 추출과 같은 기법을 사용해서 메서드 이름으로 코드의 의미를 표현
- TDD를 진행하는 과정에서 지속적으로 리팩토링을 진행하면 코드 가독성이 높아진다.
- 코드의 가독성이 높을수록 개발자는 코드를 더 빠르게 분석하고 수정 요청이 있을 때 변경할 코드를 더욱 빠르게 찾을 수 있다.
- 코드의 변경의 어려움을 줄여주어 향후 유지보수에 도움이 됨
- 소프트웨어의 생존 시간이 길어질수록 지속적인 개선이 필요하며, 이를 위해 코드 변경이 자유로워야 하며, 이를 통해 요구사항을 제때 반영해야 한다.
- 코드가 잘 변경되려면 변경하기 쉬운 구조를 가져야하며, 이를 위한 과정이
리팩토링이다.
테스트 대상 코드의 리팩토링시점
- 상수를 변수로 바꾸거나 변수 이름을 변경하는 것과 같은 작은 리팩토링은 발견하면 바로 수행
- 메서드 추출과 같이 메서드의 구조에 영향을 주는 리팩토링은 큰 틀에서 구현 흐름이 눈에 들어오기 시작한 뒤 진행한다.
- 구조가 더 명확해진 후 메서드 추출 리팩토링 시도해야함
테스트 작성 순서 연습
- 매달 비용을 지불해야 사용 가능한 유로 서비스가 있을 때, 다음 규칙에 따라 서비스 만료일을 결정함
- 서비스를 사용하려면 매달 1만 원을 선불로 납부한다. 납부일 기중으로 한 달 뒤가 서비스 만료일이 됨
- 2개월 이상 요금을 납부 할 수 있다.
- 10만 원을 납부하면 서비스를 1년 제공한다.
- 다음과 같이 납부한 금액 기준으로 서비스 만료일을 계산하는 기능을 TDD로 구현한다면
- 먼저 테스트 클래스 이름을 정한다
- 만료일을 뜻하는
expiry date+ 계산을 의미하는Calculator를 붙여ExpiryDateCalculator로 정함
1
2
3
4
package chap03
public class ExpiryDateCalculatorTest {
}
쉬운 것 부터 테스트
- 이제 테스트 메서드를 추가. 테스트를 추가할 때는 다음 두가지 고려한다.
- 구현하기 쉬운 것 부터 먼저 테스트
- 예외 상황을 먼저 테스트
만료일 계산기에서는 1만 원을 납부하면 한 달 뒤 같은 날을 만료일로 계산하는 것이 가장 쉬움
- ex) 2024년 5월 1일에 1만 원을 납부하면, 만료일은 2024년 6월 1일이 된다.
- 계산에 필요한 값은 납부일과 납부액이고 결과는 계산된 만료일이다.
1
2
3
4
5
6
7
8
9
10
11
12
public class ExpiryDateCalculatorTest {
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨(){
LocalDate billingDate = LocalDate.of(2024,5,1);
int payAmount = 10_000;
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate expiryDate = cal.calculateExpiryDate(billingDate,payAmount);
assertEquals(LocalDate.of(2024,6,1),expiryDate);
}
}
- 테스트를 통과시키려면 ExpiryDateCalculator# calculateExpiryDate()메서드가 2024-05-01에 해당하는 LocalDate를 리턴하면 됨.
1
2
3
4
5
6
7
8
9
package chap03
import java.time.LocalDate;
public class ExpiryDateCalculator {
public calculateExpiryDate(LocalDate billingDate, int payAmount){
return LocalDate.of(2024,6,1);
}
}
예를 추가하면서 구현을 일반화
- 이제 동일 조건의 예를 추가하면서 구현을 일반화시킨다.
- 먼저 1만 원을 납부하는 예를 하나 더 추가한다.
- ex2) 2024-04-03
- 만료일은 2024-05-03이어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨(){
LocalDate billingDate = LocalDate.of(2024,5,1);
int payAmount = 10_000;
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate expiryDate = cal.calculateExpiryDate(billingDate,payAmount);
assertEquals(LocalDate.of(2024,6,1),expiryDate);
//추가 예시
LocalDate billingDate2 = LocalDate.of(2024,4,3);
int payAmount2 = 10_000;
ExpiryDateCalculator cal2 = new ExpiryDateCalculator();
LocalDate expiryDate2 = cal2.calculateExpiryDate(billingDate2,payAmount2);
assertEquals(LocalDate.of(2024,5,3),expiryDate2);
}
두 번째 assertEquals()에서 검증에 실패한다. 이제 구현을 고민할 차례
이 테스트를 통과시키기 위해 한 번 더 상수를 사용할지, 아니면 바로 구현을 일반화 할지?
이 예는 비교적 단순하므로 바로 구현을 일반화해도 된다. 아래 코드는 테스트를 통과시키기 위해 변경한 구현
1
2
3
4
5
public class ExpiryDateCalculator {
public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount){
return billingDate.plusMonth(1);
}
}
위처럼 코드를 수정하고 테스트 통과 확인
코드 정리: 중복 제거
리팩토링할 시간!
ExpiryDateCalculator 클래스부터 보자
calculateExpiryDate() 메서드는 파라미터가 두 개이다.
아직은 파라미터가 두 개다.
파라미터가 더 많으면 객체 형태로 바꿔서 파라미터를 한 개로 만들겠지만, 아직 파라미터가 더 추가될지 알 수 없다.
발생하지도 않았는데 미리 단정 지어 코드를 수정할 필요는 없다.
테스트에 정리할 코드는 없을지? 테스트 메서드에는 다음 형태의 중복이 존재
1
2
3
4
5
6
7
LocalDate billingDate = 납부일;
int payAmount = 납부액;
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);
assertEquals(기대값, expiryDate);
보통은 중복을 제거하는 것이 좋지만, 테스트 코드의 중복 제거는 고민이 필요함
- 왜냐면 각 테스트 메서드는 스스로 무엇을 테스트하는지 명확하게 설명할 수 있어야 검증 하기 때문이다.
- 테스트 코드의 구현 중복을 기계적으로 제거하면 자칫 테스트 메서드가 검증하고 싶은 내용을 알아보기 힘들 수 있다.
일단 중복 제거를 해보고 테스트 코드가 여전히 자신을 설명하고 있는지 확인해본다.
메서드를 이용해서 중복을 제거한 결과이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ExpiryDateCalculatorTest {
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
assertExpiryDate(
LocalDate.of(2024, 3, 1),
10_000,
LocalDate.of(2024, 4, 1)
);
assertExpiryDate(
LocalDate.of(2024, 5, 5),
10_000,
LocalDate.of(2024, 6, 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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void 납부일과_한달_뒤_일자가_같지_않음() {
assertExpiryDate(
LocalDate.of(2024, 1, 31),
10_000,
LocalDate.of(2024, 2, 29)
);
assertExpiryDate(
LocalDate.of(2020, 1, 30),
10_000,
LocalDate.of(2020, 2, 29)
);
assertExpiryDate(
LocalDate.of(2020, 5, 31),
10_000,
LocalDate.of(2020, 6, 30)
);
}
이 경우 LocalDate#plusMonth() 메서드가 알아서 한 달 추가 처리를 해준 것
다음 테스트 선택: 다시 예외 상황
그 다음으로 쉬운 예외 선택
- 2만원을 지불하면 만료일이 두 달 뒤가 된다.
- 3만원을 지불하면 만료일이 세 달 뒤가 된다.
다음 테스트를 추가하기 전에 리팩토링
만료일을 계산하는데 필요한 값이 세 개로 늘었다. 다음을 고민할 때가 됨.
- calculateExpiryDate 메서드의 파라미터로 첫 납부일 추가
- 첫 납부일, 납부일, 납부액을 담은 객체를 calculateExpiryDate 메서드에 전달
첫 납부일을 파라미터로 추가하면 파라미터가 세 개로 늘어난다.
- 파라미터 개수는 적을수록 코드 가독성과 유지보수에 유리하므로 메서드의 파라미터 개수가 세 개 이상이면 객체로 바꿔 한 개로 줄이는 것을 고려해야 한다.
- 이 장에서는 calculateExpiryDate메서드에 전달할 파라미터를 객체로 바꾸는 리팩토링을 먼저하고 그다음에 테스트를 추가 할 것
리팩토링을 위해 먼저 추가 된 PayDate 클래스
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
package chap03;
import java.time.LocalDate;
public class PayData {
private LocalDate billingDate;
private int payAmount;
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 LocalDate billingDate;
private int payAmount;
public Builder billingDate(LocalDate billingDate) {
this.billingDate = billingDate;
return this;
}
public Builder payAmount(int payAmount) {
this.payAmount = payAmount;
return this;
}
public PayData build() {
return new PayData(billingDate, payAmount);
}
}
}
테스트 부분도 수정
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
public class ExpiryDateCalculatorTest {
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2024, 3, 1))
.payAmount(10_000)
.build(),
LocalDate.of(2024, 4, 1)
);
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2024, 5, 5))
.payAmount(10_000)
.build(),
LocalDate.of(2024, 6, 5)
);
}
@Test
void 납부일과_한달_뒤_일자가_같지_않음() {
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2024, 1, 31))
.payAmount(10_000)
.build(),
LocalDate.of(2024, 2, 29)
);
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2020, 1, 30))
.payAmount(10_000)
.build(),
LocalDate.of(2020, 2, 29)
);
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2020, 5, 31))
.payAmount(10_000)
.build(),
LocalDate.of(2020, 6, 30)
);
}
private void assertExpiryDate(PayData payData, LocalDate expectedExpiryDate) {
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate realExpiryDate = cal.calculateExpiryDate(payData);
assertEquals(expectedExpiryDate, realExpiryDate);
}
}
예외 상황 테스트 진행 계속
첫 납부일 기중으로 만료일 계산을 위해 리팩토링 했으니 테스트를 계속 진행
- 첫 납부일이 2019-01-31이고 만료되는 2019-02-28에 1만 원을 납부하면 다음 만료일은 2019-03-31이다.
1
2
3
4
5
6
7
8
9
10
@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));
}
테스트는 실패하였다. 구현 코드를 수정해보자.
1 2 3 4 5 6 7 8
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().plugMonths(1); } }
첫 번째 테스트는 통과했지만 두 테스트가 실패했다.
- getFirstbillingDate가 null 체크를 구현 코드에 추가하였다.
상수를 이용해서 테스트를 통과시켰으니 구현을 일반화할 차례다.
1 2 3 4 5 6 7 8 9 10 11 12 13
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(PayData payData) { if(payData.getFirstBillingDate() != null) { LocalDate candidateExp = payDate.getBillingDate().plusMonths(1); if(payData.getFirstBillingDate().getDayOfMonth() != candidateExp.getDatOhMonth()) { return candidateExp.withDayOfMonth( payData.getFirstBillingDate().getDayOfMonth()); } } return payData.getBillingDate().plugMonths(1); } }
테스트는 통과하였다.
코드 정리: 상수를 변수로
- plugMonths(1) 를 사용하였다. 1은 만료일을 계산할 때 추가할 개월 수를 의미한다.
- 상수 1을 변수로 변경하자
1
2
3
4
5
6
7
8
9
10
11
12
public class ExpiryDateCalculator {
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);
}
}
다음 테스트 선택: 쉬운 테스트
다음 테스트를 선택하자.
- 2만 원을 지불하면 만료일이 두 달 뒤가 된다.
- 3만 원을 지불하면 만료일이 석 달 뒤가 된다.
테스트 코드를 추가해 보자
1 2 3 4 5 6 7 8 9
@Test void 이만원_이상_납부하면_비례해서_만료일_계산() { assertExpiryDate( PayData.builder() .billingDate(LocalDate.of(2019, 3, 1)) .payAmount(20_000) .build(), LocalDate.of(2019, 5, 1)); }
구현 코드를 수정해보자
1 2 3 4 5 6 7 8 9 10 11 12 13 14
public LocalDate calculateExpiryDate(PayData payData) { // addedMonths를 금액에 따라 수정 int addedMonths = payData.getPayAmount() / 10_000; if(payDate.getFirstBillingDate() != null) { LocalDate candidateExp = payDate.getBillingDate().plusMonths(addedMonths); if(payData.getFirstBillingDate().getDayOfMonth() != candidateExp.getDatOhMonth()) { return candidateExp.withDayOfMonth( payData.getFirstBillingDate().getDayOfMonth()); } } return payData.getBillingDate().plugMonths(addedMonths); }
예외 상황 테스트 추가
이번에 추가할 상황은 첫 납부일과 납부일의 일자가 다를 때 2만 원이상 납부한 경우이다.
- 첫 납부일이 2019-01-31이고 만료되는 2019-02-28에 2만원을 납부하면 다음 만료일은 2019-04-30이다.
테스트 코드를 추가하자
1 2 3 4 5 6 7 8 9 10
@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)); }
이는 익셉션이 발생한다. 왜냐하면 4월에는 31일이 없는데 31일로 설정해서 발생한 것임을 알 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
public LocalDate calculateExpiryDate(PayData payData) { int addedMonths = payData.getPayAmount() / 10_000; if(payData.getFirstBillingDate() != null) { LocalDate candidateExp = payDate.getBillingDate().plusMonths(addedMonths); if(payData.getFirstBillingDate().getDayOfMonth() != candidateExp.getDatOhMonth()) { // 날짜가 잘못 계산되어 익셉션이 발생한다. return candidateExp.withDayOfMonth( payData.getFirstBillingDate().getDayOfMonth()); } } return payData.getBillingDate().plusMonths(addedMonths); }
이 테스트를 통과시키려면 다음 조건을 확인해야 한다.
- 후보 만료일이 포함된 달의 마지막 날 < 첫 납부일의 일자
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(PayData payData) { // addedMonths를 금액에 따라 수정 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); } }
시작이 안 될 때는 단언부터 고민
테스트 코드를 작성하다 보면 시작이 잘 안 될 때가 있다. 이럴 땐 검증하는 코드부터 작성하기 시작하면 도움이 된다. 예를 들어 만료일 계산 기능의 경우 만료일을 검증하는 코드부터 작성해 보는 것이다.
- 먼저 만료일을 어떻게 표현할지 결정해야 한다.
- 만료일이므로 날짜를 표현하는 타입을 선택하면 좋을 것 같다.
- 다음은 실제 만료일을 바꿀 차례다. 이 값은 만료일을 실제로 계산한 결과값을 갖는 변수로 바꿀 수 있다.
- 어떤 객체의 메서드를 실행해서 계산 기능을 실행하도록 하자
- 이제 두 가지를 정해야 한다.
- cal의 타입 : 간단한 만료일 계산을 뜻하는 ExpiryDateCalculator로 정했다.
- 파라미터 타입 : 만원을 납부했을 때 한 달 뒤가 만료일이 되는지를 테스트할 것이므로 납무일과 납부액을 전달한다.
- 이렇게 테스트 코드를 어떻게 작성할지 감을 못 잡겠다면 검증 코드부터 시작해보자.
구현이 막히면
TDD를 작성하다 보면 어떻게 해야 할지 생각이 잘 나지 않거나 무언가 잘못한 것 같은 느낌이 들것이다. 이럴 땐 과감하게 코드를 지우고 미련 없이 다시 시작한다. 어떤 순서로 테스트 코드를 작성했는지 돌이켜보고 순서를 바꿔서 다시 진행한다. 다시 진행할때에는 다음을 상기한다.
- 쉬운 테스트, 예외적인 테스트
- 완급 조절