Engineering
[테스트 주도 개발 시작하기] 3 ~ 4장
unknownomad
2023. 6. 13. 23:21
<3장> 테스트 코드 작성 순서
테스트 코드 작성 순서
- 쉬운 경우 ➡️ 어려운 경우
- 예외적인 경우 ➡️ 정상인 경우
구현하기 쉬운 테스트부터 시작하기
- 모든 조건을 충족하는 경우
- 한 규칙 or 두 규칙을 충족하는 경우
- 모든 조건을 충족하지 않는 경우
예외 상황을 먼저 테스트하기
- 예외 상황에 따른 if-else 구조를 미리 만들어, 코드 구조 단순화
완급 조절
- 테스트 ➡️ 구현 ➡️ 확인
- 정해진 값 리턴
- 값 비교를 이용해 정해진 값 리턴
- 다양한 테스트 추가하며 구현을 일반화
특정 테스트만 통과하도록 테스트 코드 작성
- 상수를 비교해 테스트 통과
- 상수 제거 후 일반화
@Test
void meetsOtherCriteria_except_for_Length_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@A");
assertEquals(PasswordStrength.NORMAL, result);
PasswordStrength result2 = meter.meter("Ab12!c");
assertEquals(PasswordStrength.NORMAL, result2);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if(s.length() < 8) {
return PasswordStrength.NORMAL;
}
return PasswordStrength.STRONG;
}
}
지속적인 리팩토링
- 중복 코드 리팩토링
- 가독성 높이기
- 소프트웨어의 생존을 위해 지속적인 코드 개선은 필수
- 변경하기 쉬운 구조를 가져야 변경이 쉬움
- 리팩토링 통해 이해하고 변경하기 쉽게 코드 개선 ➡️ 변화하는 요구사항을 적은 비용으로 반영 + 소프트웨어 생존 시간 늘려줌
NPE
- Null Pointer Exception
테스트 작성 순서 연습
테스트 추가 시 고려사항
- 구현하기 쉬운 것부터 먼저 테스트
- 예외상황을 먼저 테스트
public class ExpiryDateCalculatorTest {
@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
// case 1
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2019, 3, 1))
.payAmount(10_000)
.build(),
LocalDate.of(2019, 4, 1));
// case 2
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2019, 5, 5))
.payAmount(10_000)
.build(),
LocalDate.of(2019, 6, 5));
}
@Test
void 납부일과_한달_뒤_일자가_같지_않음() {
// LocalDate#plusMonths() 메서드가 알아서 한 달 추가 처리해주기에 예외 발생 x
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2019, 1, 31))
.payAmount(10_000)
.build(),
LocalDate.of(2019, 2, 28));
}
@Test
void 첫_납부일과_만료일_일자가_다를때_만원_납부() {
// case 1
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));
// case 2
PayData payData2 = PayData.builder()
.firstBillingDate(LocalDate.of(2019, 1, 30))
.billingDate(LocalDate.of(2019, 2, 28))
.payAmount(10_000)
.build();
assertExpiryDate(payData, LocalDate.of(2019, 3, 30));
// case 3
PayData payData3 = PayData.builder()
.firstBillingDate(LocalDate.of(2019, 5, 31))
.billingDate(LocalDate.of(2019, 6, 30))
.payAmount(10_000)
.build();
assertExpiryDate(payData3, LocalDate.of(2019, 7, 31));
}
@Test
void 이만원_이상_납부하면_비례해서_만료일_계산() {
// case 1
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2019, 3, 1))
.payAmouont(20_000)
.build(),
LocalDate.of(2019, 5, 1));
// case 2
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2019, 3, 1))
.payAmouont(30_000)
.build(),
LocalDate.of(2019, 6, 1));
}
@Test
void 첫_납부일과_만료일_일자가_다를때_이만원_이상_납부() {
// case 1
assertExpiryDate(
PayData.builder()
.firstBillingDate(LocalDate.of(2019, 1, 31))
.billingDate(LocalDate.of(2019, 2, 28))
.payAmount(20_000)
.build(),
LocalDate.of(2019, 4, 30));
// case 2
assertExpiryDate(
PayData.builder()
.firstBillingDate(LocalDate.of(2019, 1, 31))
.billingDate(LocalDate.of(2019, 2, 28))
.payAmount(40_000)
.build(),
LocalDate.of(2019, 6, 30));
// case 3
assertExpiryDate(
PayData.builder()
.firstBillingDate(LocalDate.of(2019, 3, 31))
.billingDate(LocalDate.of(2019, 4, 30))
.payAmount(30_000)
.build(),
LocalDate.of(2019, 7, 31));
}
@Test
void 십만원을_납부하면_1년_제공() {
assertExpiryDate(
PayData.builder()
.billingDate(LocalDate.of(2019, 1, 28))
.payAmount(100_000)
.build(),
LocalDate.of(2020, 1, 28));
}
private void assertExpiryDate(
PayData payData, LocalDate expectedExpiryDate) {
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate realExpiryDate = cal.calculateExpiryDate(payData);
assertEquals(expectedExpiryDate, realExpiryDate);
}
}
public class ExpiryDateCalculator {
public LocalDate calculateExpiryDate(PayData payData) {
int addedMonths = payData.getPayAmount() == 10_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(isSameDayOfMonth(payData.getFirstBillingDate(), candidateExp)) {
final int dayLenOfCandiMon = lastDayOfMonth(candidateExp);
final int dayOfFirstBilling = payData.getFirstBillingDate().getDayOfMonth();
if(dayLenOfCandiMon < dayOfFirstBilling) {
return candidateExp.withDayOfMonth(dayLenOfCandiMon);
}
return candidateExp.withDayOfMonth(dayOfFirstBilling);
} else {
return candidateExp;
}
}
private boolean isSameDayOfMonth(LocalDate date1, LocalDate date2) {
return date1.getDayOfMonth() != date2.getDayOfMonth();
}
private int lastDayOfMonth(LocalDate date) {
return YearMonth.from(date).lengthOfMonth();
}
}
public class PayData {
private LocalDate firstBillingDate;
private LocalDate billingDate;
private int payAmount;
private PayData() {}
public PayData(LocalDate firstBillingDate, LocalDate billingDate, int payAmount) {
this.firstBillingDate = firstBillingDate;
this.billingDate = billingDate;
this.payAmount = payAmount;
}
public LocalDate getFirstBillingDate() {
return firstBillingDate;
}
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 firstBillingDate(LocalDate firstBillingDate) {
data.firstBillingDate = firstBillingDate;
return this;
}
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;
}
}
}
테스트할 목록 정리
예시의 경우
- 1만 원 납부하면 한 달 뒤가 만료일
- 달의 마지막 날에 납부하면 다음달 마지막 날이 만료일
- 2만 원 납부하면 2개월 뒤가 만료일
- 3만 원 납부하면 3개월 뒤가 만료일
- 10만 원 납부하면 1년 뒤가 만료일
시작이 안 될 때는 단언부터 고민
@Test
void stage1_만원_납부하면_한달_뒤가_만료일이_됨() {
assertEquals(기대하는만료일, 실제만료일);
}
@Test
void stage2_만원_납부하면_한달_뒤가_만료일이_됨() {
assertEquals(LocalDate.of(2019, 8, 9), 실제만료일);
}
@Test
void stage3_만원_납부하면_한달_뒤가_만료일이_됨() {
LocalDate realExpiryDate = 계산하기
assertEquals(LocalDate.of(2019, 8, 9), realExpiryDate);
}
@Test
void stage4_만원_납부하면_한달_뒤가_만료일이_됨() {
LocalDate realExpiryDate = cal.calculateExpiryDate(파라미터);
assertEquals(LocalDate.of(2019, 8, 9), realExpiryDate);
}
@Test
void stage5_만원_납부하면_한달_뒤가_만료일이_됨() {
LocalDate realExpiryDate = cal.calculateExpiryDate(LocalDate.of(2019, 7, 9), 10_000);
assertEquals(LocalDate.of(2019, 8, 9), realExpiryDate);
}
@Test
void stage6_만원_납부하면_한달_뒤가_만료일이_됨() {
ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate realExpiryDate = cal.calculateExpiryDate(LocalDate.of(2019, 7, 9), 10_000);
assertEquals(LocalDate.of(2019, 8, 9), realExpiryDate);
}
<4장> TDD • 기능 명세 • 설계
사용자에게 제공할 기능을 구현할 때 고려사항
기능에 대한 분류
- 입력
- 출력
설계
- 기능 명세
- 기능 명세 구체화 : 요구사항 문서 활용 ➡️ 입력 & 결과 도출
- 도출한 기능 명세를 코드에 반영
설계 과정을 지원하는 TDD
테스트 코드 작성 시 결정해야 할 사항
- 클래스 이름
- 메서드 이름
- 메서드 파라미터
- 실행 결과
TDD 의 역할
- 위 사항들을 결정 = 기본적인 설계 행위
- TDD 자체가 설계는 아니나, TDD 진행 시 테스트 코드를 작성하는 과정에서 일부 설계를 진행하게 됨
TDD 작성 범위
- 테스트를 통과할 만큼만 코드 작성
- 미리 예외 타입 생성 X
- ➡️ 불필요하게 복잡해지는 것 방지
기능 명세 구체화
- 테스트코드 = 구체적인 명세
- 구체적인 예는 개발자가 요구사항을 더 잘 이해할 수 있게 만듦
- 테스트 코드는 바로 실행할 수 있기에, 구체적인 예를 이용해 기능을 바로 실행해볼 수 있음
- ➡️ 유지보수에 큰 도움이 됨
- 테스트 코드를 추적하며 기능을 보완