3장 테스트 코드 작성 순서
테스트 코드 작성 순서
테스트 코드 작성 순서
- 쉬운 경우에서 어려운 경우로 진행
- 예외적인 경우세서 정상인 경우로 진행
초반에 복잡한 테스트부터 시작하면 안되는 이유
- 초반부터 복잡한 상황을 테스트로 추가하면 해당 테스트를 통과시키기 위해 한 번에 구현해야 하는 코드 증가
- 구현 어려움, 버그 발생 증가, 시간 소모....
구현하기 쉬운 테스트부터 시작하기
- 암호 강도 측정에서 가장 쉬운 것?
- 모든 조건 충족하는 경우 -> 그냥 STRONG 리턴하면 됨
- 모든 조건 충족 X -> 그냥 WEEK 리턴하면 됨
- 모든 조건 충족하는 경우로 시작해보자
- 그 다음으로 쉬운 것?
- 모든 규칙 충족X -> 복잡
- 한 규칙만 충족 -> 그 중에서도 길이 조건이 제일 쉬워보임
- 두 규칙 충족 -> 한 규칙 충족하는 경우 다음으로
- 이런 식으로 점진적으로 수 분내 구현 완료 및 통과시킬 수 있는 테스트 선택해나가기
예외 상황을 먼저 테스트해야 하는 이유
- 코드 구현 후, 예외 상황 반영 -> 구조 변경 및 조건문 중복 추가 등의 복잡한 상황 발생
- 초반에 예외 상황을 테스트하면 이런 가능성 줄어듬 -> 예외 상황에 따른 if-else 문이 미리 만들어져 코드 구조 덜 바뀜, 버그도 줄어듬.
완급 조절
- 한 번에 얼마만큼 코드 작성할 것? -> 아래 단계를 따르기
- 정해진 값을 리턴 : 딱 테스트 통과할 만큼만!
- 값 비교를 이용해서 정해진 값을 리턴
- 다양한 테스트를 추가하면서 구현을 일반화 : ex. 상수 제거하고 일반화하는
지속적인 리팩토링
- 적당한 후보가 보이면 리팩토링 진행
- 코드중복
- 메서드 추출 : 코드 길어질 경우 메서드 이름으로 코드의 의미 표현
- 코드 가독성 높아짐
- 빠른 코드 분석 가능 -> 유지 보수 용이
작성 순서 예제
납부 금액 기준으로 서비스 만료일을 계산하는 기능 구현
- 매달 지불해야하는 유료 서비스
- 서비스 만료일 결정 규칙
- 매달 1만원 선불 납부, 납부일 기준 한 달뒤 만료일
- 2개월 이상 요금 납부가능
- 10만원 납부시 서비스 1년 제공
- 테스트 대상 클래스 이름 생성 : ExpiryDateCalculator
쉬운 것부터 테스트
- 테스트 메서드 추가
가장 쉬운 1만원 납부 시 한 달 뒤 같은 날 = 만료일 계산 택
public class ExpiryDateCalculatorTest { @Test void 만원_납부시_한달_뒤가_만료일이_됨() { LocalDate bilingDate = LocalDate.of(2024,3,1); int payAmount = 10_000; ExpiryDateCalculator cal = new ExpiryDateCalculator(); LocalDate expiryDate = cal.calculateExpiryDate(bilingDate, payAmount); assertEquals(LocalDate.of(2024, 4, 1), expiryDate); } }
테스트 통과 위한 메소드 작성
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount){ return LocalDate.of(2024, 4, 1); } }
예 추가하면서 구현 구체화
동일한 조건의 예 추가하면서 구현 구체화
public class ExpiryDateCalculatorTest { @Test void 만원_납부시_한달_뒤가_만료일이_됨() { LocalDate billingDate = LocalDate.of(2024, 3, 1); int payAmount = 10_000; ExpiryDateCalculator cal = new ExpiryDateCalculator(); LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount); assertEquals(LocalDate.of(2024, 4, 1), expiryDate); // 예 추가 LocalDate billingDate2 = LocalDate.of(2024, 3, 1); int payAmount2 = 10_000; ExpiryDateCalculator cal2 = new ExpiryDateCalculator(); LocalDate expiryDate2 = cal.calculateExpiryDate(billingDate2, payAmount2); assertEquals(LocalDate.of(2024, 4, 1), expiryDate2); } }
실행 -> 실패
비교적 단순하므로 더 이상의 상수 사용 없이 일반화 해보자
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) { return billingDate.plusMonths(1); } }
실행 -> 통과
코드 정리 : 중복 제거
파라미터 2개 객체로 바꿔서 받을까? -> 아직 2개 정도이니 기다려보자
테스트 코드에서 변수 설정과 객체 생성, 검증의 중복 있음
일단 메서드 이용해 중복 제거해보고, 코드가 여전히 자신을 설명하고 있는지 확인해보기
public class ExpiryDateCalculatorTest { @Test void 만원_납부시_한달_뒤가_만료일이_됨() { assertExpiryDate(LocalDate.of(2024, 3, 1), 10000, LocalDate.of(2024, 4, 1)); assertExpiryDate(LocalDate.of(2024, 5, 5), 10000, 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); } }
어떤 것 검증하는지 쉽게 파악 가능, 이 정도면 중복 제거해도 OK!
예외 상황 처리
- 단순히 한 달 추가로 끝나지 않는 경우 (다음달 같은날 != 만료일)
- 납부일 :2019-01-31 -> 만료일: 2019-02-28
- 납부일 :2024-05-31 -> 만료일: 2024-06-30
- 납부일 :2020-01-31 -> 만료일: 2020-02-29
테스트 추가
@Test void 납부일과_한달_뒤_일자가_같지_않음() { assertExpiryDate(LocalDate.of(2019, 1, 31), 10000, LocalDate.of(2019, 2, 28)); }
실행 -> 통과
- LocalDate.plusMonths() 메서드가 알아서 한 달 처리 추가해준 것
나머지 두 개의 테스트도 추가
실행 -> 통과
다음 테스트 상황 : 다시 예외 상황
- 쉬운 예 vs 예외 상황
- 1만원 납부 시의 예외 상황을 마무리하고 2개월 이사 납부하는 경우로 넘어가자
- 첫 납부일이 2019-01-30 이고 만료되는 2019-02-28에 1만원 납부하면 다음 만료일은 2019-03-30
- 첫 납부일이 2019-05-31 이고 만료되는 2019-06-30에 1만원 납부하면 다음 만료일은 2019-07-31
- 테스트 위해서 첫 납부일 필요 -> 기존 코드에 추가
다음 테스트 추가 전 리팩토링
- 첫 납부일을 파라미터로 추가하면서 파라미터가 3개로 늘어남
- 파라미터 개수는 적을수록 가독성과 유지보수에 유리하므로, 세개 이상이면 객체로 바꿔 하나로 줄이는 것 고려해야 함
- 리팩토링 진행
PayData 클래스 생성
public class PayData { private LocalDate billingDate; private int payAmount; public PayData() {} // 생성자 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 PayData data = new PayData(); 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; } } }
ExpiryDateCalculator.calculateExpiryDate() 메서드도 변경
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(PayData payData) { return payData.getBillingDate().plusMonths(1); } }
테스트 코드도 수정
@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) ); }
예외 상황 테스트 진행 계속
다시 테스트 추가
첫 납부일이 2019-01-30 이고 만료되는 2019-02-28에 1만원 납부하면 다음 만료일은 2019-03-30
@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)); }
실행 -> 컴파일 에러
- firstBillingDate() 메서드 추가
- PayData 클래스에 firstBillingDate 변수 추가하고 메서드 추가
실행 -> 새로 추가한 테스트 실패
일단 상수로 통과시키기
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().plusMonths(1); } }
실행 -> 통과 but 앞의 두 테스트 실패 (NPE 발생)
: payData.getFirstBillingDate() 코드가 null이라 발생한 에러null 검사 코드 추가
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(PayData payData) { if (payData.getFirstBillingDate() != null) { if (payData.getFirstBillingDate().equals(LocalDate.of(2019, 1, 31))) { return LocalDate.of(2019, 3, 31); } } return payData.getBillingDate().plusMonths(1); } }
실행 -> 통과
새로운 테스트 추가하여 일반화하기
새로운 사례 추가
실행 -> 실패
구현 코드 일반화
첫 납부일과 납부일의 일자가 다르면 첫 납부일의 일자를 만료일의 일자로 사용
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); } }
실행 -> 성공
새로운 사례 추가
실행 -> 성공
코드 정리: 상수를 변수로
상수1을 변수로 바꾸고 리팩토링
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만원 지불시 만료일은 석 달 뒤
테스트 추가
@Test void 이만원_이상_납부하면_비례해서_만료일_계산() { assertExpiryDate( PayData.builder() .billingDate(LocalDate.of(2019, 3, 1)) .payAmount(20_000) .build(), LocalDate.of(2019, 5, 1) ); }
실행 -> 실패
상수 대신 일반화 시켜 구현
- 구현 클래스
int addedMonths = payData.getPayAmount() / 10_000;
- 구현 클래스
실행 -> 통과
새로운 사례 추가
- 3만원인 경우
실행 -> 통과
예외 상황 테스트 추가
- 첫 납부일과 납부일의 일자가 다를 때
- 첫 납부일 2019-01-31이고 만료되는 2019-02-28에 2만원을 납부하면 다음 만료일은 2019-04-30
테스트 추가
실행 -> 실패 (4월 31일 에러 발생)
조건 추가
다음 조건 추가
후보 만료일이 포함된 달의 마지막 날 < 첫 납부일의 일자
참 : 후보 만료일을 그달의 마지막 날로 조정
public class ExpiryDateCalculator { 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()) { int lastDayOfMonth = YearMonth.from(candidateExp).lengthOfMonth(); if (lastDayOfMonth < payData.getFirstBillingDate().getDayOfMonth()) { return candidateExp.withDayOfMonth(lastDayOfMonth); } return candidateExp.withDayOfMonth(payData.getFirstBillingDate().getDayOfMonth()); } } return payData.getBillingDate().plusMonths(addedMonths); } }
실행 -> 통과
코드 정리
calculateExpiryDate() 메서드
날짜 계산 관련 코드 중복
- 첫 납부일의 일자를 구하는 코드의 중복 제거
후보 만료일이 속한 월의 마지막 일자 구하는 코드 중복 제거
구조 수정: 첫 납부일 존재 여부에 따라 계산 로직이 달라지도록
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(PayData payData) { int addedMonths = payData.getPayAmount() / 10_000; if (payData.getFirstBillingDate() != null) { 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; } } else { return payData.getBillingDate().plusMonths(addedMonths); } } }
실행 -> 통과
구현 코드에서 메서드를 추출
- expiryDateUsingFirstBillingDate() 메서드 추출
다음 테스트: 10개월 요금을 납부하면 1년 제공
테스트 추가
실행 -> 실패
구현 코드 추가
public class ExpiryDateCalculator { public LocalDate calculateExpiryDate(PayData payData) { int addedMonths = payData.getPayAmount() == 100_000 ? 12 : 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; } } }
실행 -> 성공
테스트 목록 정리하기
- TDD를 시작할 때 목록을 미리 정하면 좋음
- 1만원 납부하면 한 달 뒤가 만료일
- 달의 마지막날 납부하면 다음달 마지막 날이 만료일
- 2만원 납부하면 2개월 뒤가 만료일
- 3만원 납부하면 3개월 뒤가 만료일
- 10만원 납부하면 1년 뒤가 만료일
- 1만원 납부하면 한 달 뒤가 만료일
- 그 다음 어떤 테스트가 구현이 쉬울지 or 예외적인지 생각
- 새로운 테스트 사례 발견시 목록에 추가해두기
- 리팩토링하기
- 범위가 너무 큰 리팩토링의 경우 미뤄두고 테스트 통과시키는데 집중
시작이 안될 때는 단언부터 고민
- 시작이 잘 안될 떄는 검증하는 코드부터 작성
구현이 막히면
- 과감하게 코드 지우고 다시 시작
- 순서 되돌아보고 순서 바꿔서 다음을 상기하며 진행
- 쉬운 테스트, 예외적인 테스트
- 완급 조절
⌈테스트 주도 개발 시작하기⌋ 책 스터디를 진행하며 작성한 글입니다.
책 출처: 최범균, ⌈테스트 주도 개발 시작하기⌋, 가메출판사