Engineering

[테스트 주도 개발 시작하기] 3 ~ 4장

unknownomad 2023. 6. 13. 23:21

<3장> 테스트 코드 작성 순서

 

테스트 코드 작성 순서

  • 쉬운 경우 ➡️ 어려운 경우
  • 예외적인 경우 ➡️ 정상인 경우

 

구현하기 쉬운 테스트부터 시작하기

  1. 모든 조건을 충족하는 경우
  2. 한 규칙 or 두 규칙을 충족하는 경우
  3. 모든 조건을 충족하지 않는 경우

 

예외 상황을 먼저 테스트하기

  • 예외 상황에 따른 if-else 구조를 미리 만들어, 코드 구조 단순화

 

완급 조절

  • 테스트 ➡️ 구현 ➡️ 확인
  • 정해진 값 리턴
  • 값 비교를 이용해 정해진 값 리턴
  • 다양한 테스트 추가하며 구현을 일반화

 

특정 테스트만 통과하도록 테스트 코드 작성

  1. 상수를 비교해 테스트 통과
  2. 상수 제거 후 일반화
@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 • 기능 명세 • 설계

 

사용자에게 제공할 기능을 구현할 때 고려사항

기능에 대한 분류

  • 입력
  • 출력

 

설계

  1. 기능 명세
  2. 기능 명세 구체화 : 요구사항 문서 활용 ➡️ 입력 & 결과 도출
  3. 도출한 기능 명세를 코드에 반영

 

설계 과정을 지원하는 TDD

 

테스트 코드 작성 시 결정해야 할 사항

  • 클래스 이름
  • 메서드 이름
  • 메서드 파라미터
  • 실행 결과

 

TDD 의 역할

  • 위 사항들을 결정 = 기본적인 설계 행위
  • TDD 자체가 설계는 아니나, TDD 진행 시 테스트 코드를 작성하는 과정에서 일부 설계를 진행하게 됨

 

TDD 작성 범위

  • 테스트를 통과할 만큼만 코드 작성
  • 미리 예외 타입 생성 X
  • ➡️ 불필요하게 복잡해지는 것 방지

 

기능 명세 구체화

  • 테스트코드 = 구체적인 명세
  • 구체적인 예는 개발자가 요구사항을 더 잘 이해할 수 있게 만듦
  • 테스트 코드는 바로 실행할 수 있기에, 구체적인 예를 이용해 기능을 바로 실행해볼 수 있음
  • ➡️ 유지보수에 큰 도움이 됨
  • 테스트 코드를 추적하며 기능을 보완