테스트 주도로 개발하기

TDD 시작하기

테스트 주도 개발!
요구사항, 결과 출력 화면만 보고 필요 객체, 설계를 예상하면서 만드는 궁예가 되지말자
기능 상 아주 작은 부분으로 쪼개서 실패 테스트 케이스를 작성하고 성공 케이스로 만들기위해 설계, 코드 리팩토링을 하자
한꺼번에 설계를 해두고 개발하는게 아니라 작은 기능부터 설계하고 개발하면서 설계를 리팩토링 하는 방식으로 개발하자는 것!

TDD로 개발해보기

  • 코드스쿼드 프로그래밍 미션을 일부 가져와서 TDD 예시를 들어보려한다.
  • 로또 프로그램 진행 화면 : 당첨번호 6개를 입력받아 로또 인스턴스를 생성해야하는 과정이다.
지난 주 당첨 번호를 입력해 주세요.
1, 2, 3, 4, 5, 6
  • 기능 분석하기 사용자로부터 6개의 숫자를 입력 받는다.

  • 테스트 코드를 짜두고 설계, 구현 완성하기 문자열로 입력 받는데, 숫자(현재 단계에선 최종 리턴 타입은 아직 정하지않음)로 만들려면 split 해야한다.

public class InputViewUtilsTest {
   
   @Test
   public void 문자열_하나씩_쪼개기() {
      String numbers = "1, 2, 3, 4, 5, 6";
      assertEqauls(new String[]{"1", "2", "3", "4", "5" ,"6"}, splitNumbers(numbers));
   }
}

public class InputViewUtils {
    
   public static String[] splitNumbers(String numbersMessage) {
      return numbersMessage.split(",\\s*");
   }
}
  • 테스트 코드 구성 먼저 : ParserTest의 이름도 처음 테스트 코드를 짤 때는 중요하지않음
  • 자연스럽게 설계가 된다 : 성공 케이스로 바꾸기 위해서 누군가(객체) 메소드를 맡아야하고, 메소드를 구현해야한다.
  1. split 결과를 숫자로 변환해야한다.
public class InputViewUtilsTest {
    
   @Test
   public void 문자열_하나씩_쪼개기() {
      String numbers = "1, 2, 3, 4, 5, 6";
      assertEqauls(new String[]{"1", "2", "3", "4", "5" ,"6"}, splitNumbers(numbers));
   }
       
   @Test
   public void 문자열배열_숫자로_변환하기() {
      String[] numbers = {"1", "2", "3", "4", "5" ,"6"};
      assertThat(Arrays.asList(numbers), convertNumbersMessage(numbers));
   }
}

public class InputViewUtils {
    
   public static String[] splitNumbers(String numbersMessage) {
      return numbersMessage.split(",\\s*");
   }
    
   public static int convertToNumber(String number) {
      return Integer.parseInt(number);
   }
    
   public static List<Integer> convertNumbersMessage(String[] numbers) {
      List<Integer> convertedNumbers = new ArrayList<>();
      for (String number : numbers) {
         convertedNumbers.add(convertToNumber(number));
      }
   }  
}
  • 먼저 실패 테스트 케이스를 작성해두고 리턴 타입을 정해야한다.

  • 변환하려고 봤더니, 사용자 입력 중 숫자가 아닌 다른 문자가 입력되었을 수도 있다. 예외 처리를 구현해야해!

public class InputViewUtilsTest {
    
   @Test
   public void 문자열_하나씩_쪼개기() {
      String numbers = "1, 2, 3, 4, 5, 6";
      assertEqauls(new String[]{"1", "2", "3", "4", "5" ,"6"}, splitNumbers(numbers));
   }
    
   @Test
   public void 문자열배열_숫자로_변환하기() {
      String[] numbers = {"1", "2", "3", "4", "5" ,"6"};
      assertThat(Arrays.asList(numbers), convertNumbersMessage(numbers));
   }
    
   // 예외가 예상되는 부분에 대해 테스트 케이스를 작성해두고 제대로 캐치하기위해 리팩토링을 한다.
   @Test(expected = IllegalArgumentException.class)
   public void 문자열배열_중_숫자아닌_요소포함() {
      String[] numbers = {"1", "2", "3", "hello", "5" ,"6"};
      convertNumbersMessage(numbers);
   }
}

public class InputViewUtils {
    
   public static String[] splitNumbers(String numbersMessage) {
      return numbersMessage.split(",\\s*");
   }
    
   public static int convertToNumber(String number) {
      try {
         return Integer.parseInt(number);
      } catch (IllegalArgumentException e) {
         throw new IllegalArgumentException("숫자만 입력해야합니다.");
      }
   }
    
   public static List<Integer> convertNumbersMessage(String[] numbers) throws Illegal...{
        
   }  
}
  • 유틸리티 메소드를 호출 하는 쪽에서 예외처리를 할 수 있도록 예외를 던져버린다.
  • 중복된 입력 등등 위처럼 하나씩 잘게잘게 생각하면서 구현해나가는 것이 핵심이다.
  1. 번호를 쪼개었다고 가정하고, 로또로 만들어야한다.
    • 로또 번호 길이가 6개인지 체크해야하는데, 유틸리티가 판단해야할까? 로또 번호 길이가 바뀐다면?
    • 번호 범위는 1 ~ 45인데, 해당하지않는 숫자가 있을 수 있다. 유틸리티가 판단할 수 있을까? 로또 번호의 범위가 바뀐다면?
public class Input {
    
}

public class LottoTest {

   @Test(expected = IllegalArgumentException.class)
   public void 로또_구성숫자_길이_부족() {
      new Lotto(Arrays.asList(7, 19, 3, 24, 38, 34, 5, 2));		
   }
    
   @Test(expected = IllegalArgumentException.class)
   public void 로또_숫자_범위아닌_숫자포함() {
      new Lotto(Arrays.asList(0, -5, 46, 3, 6, 19));
   }
}

public class Lotto {
   private static final int MIN = 1;
   private static final int MAX = 45;
   private static final int NUM = 6;
   private List<Integer> numbers;

   public Lotto(List<Integer> numbers) {
      if (isInvalidLengthNumbers(numbers)) {
         throw new IllegalAragumentException(LOTTO_NUM + "개 숫자 입력해야함");
      }
    
      if (isIncludeOutRangeNumber(numbers)) {
         throw new IllegalArgumentException("유효하지않은 범위의 숫자가 포함되어있음")
      }
      this.numbers = numbers;
   }
    
   private static boolean isInvalidLengthNumbers(List<Integer> numbers) {
      return numbers.size() != NUM;
   }
    
   private static boolean isIncludeOutRangeNumber(List<Integer> numbers) {
      return numbers.stream().anyMatch(number -> number < MIN || number > MAX);
   }
}

  • 한 단계씩 만들어나가면서 클래스 분리를 하기위해 던져야할 질문 : 얘가 해야할까? 변경 상황이 닥쳤을 때 어떻게되지?
  • 앞서 언급했듯 궁예처럼 이런 클래스가 필요할거야! 하지않고, 그 역할이 필요한 상황에 추가해나간다.

이런 점이 좋더라

  1. 작게 작게 생각하게되고, 일단 기능이 되게끔 개발해두고 예외처리를 하지않고 필요한 예외처리를 하니 부담이 없어진다.
  2. 코딩하는게 게임처럼 되어버림, 테스트 케이스 성공시키기 게임
  3. 경험하기 전에는 설계를 막 해두고 개발하다가 이게 정말 아닌 것 같으면 git reset을 부르짖었지만…… 작게 작게 테스트하면서 개발하니깐 필요한 클래스 만들기, 메소드 분리가 자연스럽게 된다.
  4. 리팩토링을 의식적으로 이때해야지! 가 아니라 틈틈이 리팩토링을 하니 후처리 작업이라고 따로 부를게 없어진다.

레퍼런스