스터디/테스트 주도 개발 시작하기

3장 테스트 코드 작성 순서

yjzini 2024. 9. 13. 22:25

테스트 코드 작성 순서

테스트 코드 작성 순서

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

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

  • 초반부터 복잡한 상황을 테스트로 추가하면 해당 테스트를 통과시키기 위해 한 번에 구현해야 하는 코드 증가
  • 구현 어려움, 버그 발생 증가, 시간 소모....

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

  • 암호 강도 측정에서 가장 쉬운 것?
    • 모든 조건 충족하는 경우 -> 그냥 STRONG 리턴하면 됨
    • 모든 조건 충족 X -> 그냥 WEEK 리턴하면 됨
  • 모든 조건 충족하는 경우로 시작해보자
  • 그 다음으로 쉬운 것?
    • 모든 규칙 충족X -> 복잡
    • 한 규칙만 충족 -> 그 중에서도 길이 조건이 제일 쉬워보임
    • 두 규칙 충족 -> 한 규칙 충족하는 경우 다음으로
  • 이런 식으로 점진적으로 수 분내 구현 완료 및 통과시킬 수 있는 테스트 선택해나가기

예외 상황을 먼저 테스트해야 하는 이유

  • 코드 구현 후, 예외 상황 반영 -> 구조 변경 및 조건문 중복 추가 등의 복잡한 상황 발생
  • 초반에 예외 상황을 테스트하면 이런 가능성 줄어듬 -> 예외 상황에 따른 if-else 문이 미리 만들어져 코드 구조 덜 바뀜, 버그도 줄어듬.

완급 조절

  • 한 번에 얼마만큼 코드 작성할 것? -> 아래 단계를 따르기
    1. 정해진 값을 리턴 : 딱 테스트 통과할 만큼만!
    2. 값 비교를 이용해서 정해진 값을 리턴
    3. 다양한 테스트를 추가하면서 구현을 일반화 : ex. 상수 제거하고 일반화하는

지속적인 리팩토링

  • 적당한 후보가 보이면 리팩토링 진행
    • 코드중복
    • 메서드 추출 : 코드 길어질 경우 메서드 이름으로 코드의 의미 표현
  • 코드 가독성 높아짐
  • 빠른 코드 분석 가능 -> 유지 보수 용이


작성 순서 예제

납부 금액 기준으로 서비스 만료일을 계산하는 기능 구현

  • 매달 지불해야하는 유료 서비스
  • 서비스 만료일 결정 규칙
    • 매달 1만원 선불 납부, 납부일 기준 한 달뒤 만료일
    • 2개월 이상 요금 납부가능
    • 10만원 납부시 서비스 1년 제공
  • 테스트 대상 클래스 이름 생성 : ExpiryDateCalculator

쉬운 것부터 테스트

  1. 테스트 메서드 추가
  • 가장 쉬운 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);
          }
      }
  1. 테스트 통과 위한 메소드 작성

     public class ExpiryDateCalculator {
         public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount){
             return LocalDate.of(2024, 4, 1);
         }
     }

예 추가하면서 구현 구체화

  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);
         }
     }
  2. 실행 -> 실패

  3. 비교적 단순하므로 더 이상의 상수 사용 없이 일반화 해보자

     public class ExpiryDateCalculator {
         public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
             return billingDate.plusMonths(1);
         }
     }
  4. 실행 -> 통과

코드 정리 : 중복 제거

  • 파라미터 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
  1. 테스트 추가

     @Test
     void 납부일과_한달_뒤_일자가_같지_않음() {
         assertExpiryDate(LocalDate.of(2019, 1, 31), 10000, LocalDate.of(2019, 2, 28));
     }
  2. 실행 -> 통과

    • LocalDate.plusMonths() 메서드가 알아서 한 달 처리 추가해준 것
  3. 나머지 두 개의 테스트도 추가

  4. 실행 -> 통과

다음 테스트 상황 : 다시 예외 상황

  • 쉬운 예 vs 예외 상황
  • 1만원 납부 시의 예외 상황을 마무리하고 2개월 이사 납부하는 경우로 넘어가자
    • 첫 납부일이 2019-01-30 이고 만료되는 2019-02-28에 1만원 납부하면 다음 만료일은 2019-03-30
    • 첫 납부일이 2019-05-31 이고 만료되는 2019-06-30에 1만원 납부하면 다음 만료일은 2019-07-31
  • 테스트 위해서 첫 납부일 필요 -> 기존 코드에 추가

다음 테스트 추가 전 리팩토링

  • 첫 납부일을 파라미터로 추가하면서 파라미터가 3개로 늘어남
  • 파라미터 개수는 적을수록 가독성과 유지보수에 유리하므로, 세개 이상이면 객체로 바꿔 하나로 줄이는 것 고려해야 함
  • 리팩토링 진행
  1. 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;
             }
         }
     }
  2. ExpiryDateCalculator.calculateExpiryDate() 메서드도 변경

     public class ExpiryDateCalculator {
         public LocalDate calculateExpiryDate(PayData payData) {
             return payData.getBillingDate().plusMonths(1);
         }
     }
  3. 테스트 코드도 수정

     @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)
         );
     }

예외 상황 테스트 진행 계속

  1. 다시 테스트 추가

    • 첫 납부일이 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));
        }
      
  2. 실행 -> 컴파일 에러

    • firstBillingDate() 메서드 추가
    • PayData 클래스에 firstBillingDate 변수 추가하고 메서드 추가
  3. 실행 -> 새로 추가한 테스트 실패

  4. 일단 상수로 통과시키기

     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);
         }
     }
  5. 실행 -> 통과 but 앞의 두 테스트 실패 (NPE 발생)
    : payData.getFirstBillingDate() 코드가 null이라 발생한 에러

  6. 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);
         }
     }
  7. 실행 -> 통과

  8. 새로운 테스트 추가하여 일반화하기

    • 새로운 사례 추가

    • 실행 -> 실패

    • 구현 코드 일반화

      • 첫 납부일과 납부일의 일자가 다르면 첫 납부일의 일자를 만료일의 일자로 사용

          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만원 지불시 만료일은 석 달 뒤
  1. 테스트 추가

     @Test
     void 이만원_이상_납부하면_비례해서_만료일_계산() {
         assertExpiryDate(
             PayData.builder()
                 .billingDate(LocalDate.of(2019, 3, 1))
                 .payAmount(20_000)
                 .build(),
             LocalDate.of(2019, 5, 1)
         );
     }
  2. 실행 -> 실패

  3. 상수 대신 일반화 시켜 구현

    • 구현 클래스
      • int addedMonths = payData.getPayAmount() / 10_000;
  4. 실행 -> 통과

  5. 새로운 사례 추가

    • 3만원인 경우
  6. 실행 -> 통과

예외 상황 테스트 추가

  • 첫 납부일과 납부일의 일자가 다를 때
    • 첫 납부일 2019-01-31이고 만료되는 2019-02-28에 2만원을 납부하면 다음 만료일은 2019-04-30
  1. 테스트 추가

  2. 실행 -> 실패 (4월 31일 에러 발생)

  3. 조건 추가

    • 다음 조건 추가

      • 후보 만료일이 포함된 달의 마지막 날 < 첫 납부일의 일자

        • 참 : 후보 만료일을 그달의 마지막 날로 조정

          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);
            }
          }
  4. 실행 -> 통과

코드 정리

  • 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년 제공

  1. 테스트 추가

  2. 실행 -> 실패

  3. 구현 코드 추가

     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;
             }
         }
     }
  4. 실행 -> 성공


테스트 목록 정리하기

  • TDD를 시작할 때 목록을 미리 정하면 좋음
    • 1만원 납부하면 한 달 뒤가 만료일
      • 달의 마지막날 납부하면 다음달 마지막 날이 만료일
    • 2만원 납부하면 2개월 뒤가 만료일
    • 3만원 납부하면 3개월 뒤가 만료일
    • 10만원 납부하면 1년 뒤가 만료일
  • 그 다음 어떤 테스트가 구현이 쉬울지 or 예외적인지 생각
  • 새로운 테스트 사례 발견시 목록에 추가해두기
  • 리팩토링하기
    • 범위가 너무 큰 리팩토링의 경우 미뤄두고 테스트 통과시키는데 집중

시작이 안될 때는 단언부터 고민

  • 시작이 잘 안될 떄는 검증하는 코드부터 작성

구현이 막히면

  • 과감하게 코드 지우고 다시 시작
  • 순서 되돌아보고 순서 바꿔서 다음을 상기하며 진행
    • 쉬운 테스트, 예외적인 테스트
    • 완급 조절

⌈테스트 주도 개발 시작하기⌋ 책 스터디를 진행하며 작성한 글입니다.

책 출처: 최범균, ⌈테스트 주도 개발 시작하기⌋, 가메출판사