[TDD] 자동차 경주 TDD 방식으로 구현하기!

728x90

 

> TDD(Test Driven Development) 개발 방식이란? < 

- 테스트 코드를 먼저 작성하여 프로그램이 잘못됐다는 것을 증명하고, 이를 고쳐나가면 프로그램을 개발해 나가는 개발 방법론

=> 잘못된 점을 찾고 수정하는 과정을 계속 반복하며, 더 이상 잘못된 점을 찾을 수 없을 때 프로그램이 올바르다는 의미


 

🔆 자동차 경주
초간단 자동차 경주 게임을 구현한다.

✅ 기능 요구사항
- n대의 자동차는 1칸 정지 or 정지한다.

- 사용자로부터 자동차 이름시도할 횟수를 입력받는다.

- 자동차 이름은 전진하는 자동차를 출력할 때 사용한다.

- 자동차 이름은 쉼표 ,를 기준으로 구분하며 이름은 5자 이하만 가능하다.

- 전진하는 조건 : 0 ~ 9 사이 무작위 값 중 4 이상인 경우

- 게임 완료 후 우승자를 출력한다. (한 명 이상 가능)

- 우승자가 여러 명이면 쉼표 ,를 이용하여 구분한다.

- 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨다.


✅ 입출력 요구사항
- 입력 : 경주할 자동차 이름(,로 구분), 시도할 횟수
- 출력 : 차수별 실행 결과, 단독 우승자 안내 문구, 공동 우승자 안내 문구
- 실행 결과

경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
pobi,woni,jun
시도할 횟수는 몇 회인가요?
5

실행 결과
pobi : -
woni :
jun : -

pobi : --
woni : -
jun : --

pobi : ---
woni : --
jun : ---

pobi : ----
woni : ---
jun : ----

pobi : -----
woni : ----
jun : -----

최종 우승자 : pobi, jun

 

TDD 과정

질문

 

1. 사용자에게 입력받은 자동차 이름 문자열에서 잘 추출하여 데이터로 보관할 수 있나?

  > 한 대, 여러 대

2. 랜덤 값이 올바른 범위에서 생성되나?

3. 조건에 따른 이동 거리 계산이 올바른가?

  > 전진 조건, 정지 조건

4. 자동차 위치가 올바르게 변하나?

5. 우승자를 올바르게 선정하나?

  > 한 명, 여러 명

6. 사용자에게 입력받은 시도 횟수만큼 턴이 제대로 수행되나?

7. 초기 입력이 출력 형식을 준수하나?

8. 각 턴의 실행 결과가 출력 형식을 준수하나?

9. 우승자 결과가 출력 형식을 준수하나?

  > 한 명, 여러 명

10. 자동차 이름이 유효하지 않으면 IllegalArgumentException이 발생되나?

  > 5글자 초과, 중복, 공백

11. 시도할 횟수가 유효하지 않으면 IllegalArgumentException이 발생되나?

  > 숫자 아님, 음수

 

응답

 

1. 사용자에게 입력받은 자동차 이름 문자열에서 잘 추출하여 데이터로 보관할 수 있나?

class RacingServiceTest {
	private RacingService racingService;
    
    @BeforeEach
    void setUp() {
    	racingService = new RacingService("a,b,c", "4");
    }

	@Test
    @DisplayName("입력받은 여러 자동차가 LinkedHashMap으로 올바르게 생성되는지 확인")
    void 자동차_추출_테스트1() {
        racingService.setCarNameInput("aa,bb,cc");
        assertThat(racingService.getCarPositions()).hasSize(3);
        assertThat(racingService.getCarPositions().keySet()).contains("cc");
    }
    
    @Test
    @DisplayName("입력받은 한 자동차가 LinkedHashMap으로 올바르게 생성되는지 확인")
    void 자동차_추출_테스트2() {
        racingService.setCarNameInput("aa");
        assertThat(racingService.getCarPositions()).hasSize(1);
        assertThat(racingService.getCarPositions().keySet()).contains("aa");
    }
}

RacingService 생성자setCarNameInput, getCarPositions 를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingService 클래스에 테스트를 위해 임의로 carNameInput을 설정하는 setCarNameInput와 각각의 자동차 이름과 위치를 담는 carPositions를 가져오는 getCarPositions를 만들어주어 해당 테스트가 green을 띄도록 해준다.

 

public class RacingService {
    private final HashMap<String, Integer> carPositions = new HashMap<>();
    private static final String CAR_INPUT_DELIMITER = ",";
    private static final int INITIAL_POSITION = 0;

    private final int maxTurn;
    
    public RacingService(String carNameInput, String maxTurnInput) {
        initializeCars(carNameInput);
        this.maxTurn = Integer.parseInt(maxTurnInput);
    }
    
    private void initializeCars(String carNameInput) {
        for (String carName : carNameInput.split(CAR_INPUT_DELIMITER)) {
            carPositions.put(carName, INITIAL_POSITION);
        }
    }
    
    public void setCarNameInput(String carNameInput) {
        carPositions.clear();
        initializeCars(carNameInput);
    }
    
    public HashMap<String, Integer> getCarPositions() {
        return new HashMap<>(carPositions);
    }
}

생성자를 통해 carNameInputmaxTurnInput을 입력받는다.

carNameInputsplit 해서 carNamecarPosition을 키와 값으로 LinkedHashMap에 담아주는 initializeCars 메서드를 생성하여 생성자 안에서 호출한다.

 

[🔥짚고 넘어갈 부분!!]

- HashMap을 선택한 이유

키와 값을 쌍으로 저장하기 위하여 HashMap으로 정하였다. 근데 추후에 LinkedHashMap으로 변경된다.

HashMap은 저장을 하며 순서를 기억하지 않아 순서가 임의로 바뀔 수도 있다. 나중에 우승자 출력이 여러 명일 때, 입력받은 순서를 기억하여 그 순서대로 출력하기 위해 LinkedHashMap로 변경하게 되었다.

 

- getCarPositions에서 새로운 HashMap 반환하는 이유

carPositions를 그대로 return하게 되면, 주소값이 전달되므로 불변성에 문제가 생길 수 있다. 따라서 새로운 객체를 반환하여 불변성을 유지하였다.

 

2. 랜덤 값이 올바른 범위에서 생성되나?

class RacingServiceTest {
...
    @Test
    @DisplayName("랜덤 값이 올바른 범위(0-9)에 있는지 확인")
    void 랜덤_값_테스트() {
        int result = racingService.createRandomValue();
        assertThat(result).isBetween(0, 9);
    }
}

createRandomValue를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingService 클래스에 랜덤 값을 생성하는 createRandomValue를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingService {
...
    private static final int RANDOM_MIN = 0;
    private static final int RANDOM_MAX = 9;

	public int createRandomValue() {
        return Randoms.pickNumberInRange(RANDOM_MIN, RANDOM_MAX);
    }
}

RANDOM_MINRANDOM_MAX 사이 랜덤 값을 생성해 주어 반환한다.

 

3. 조건에 따른 이동 거리 계산이 올바른가?

class RacingServiceTest {
...
	@Test
    @DisplayName("전진 조건에 따른 이동 거리 계산이 올바른지 확인")
    void 이동_거리_테스트1() {
        int result = racingService.calculateMoveDistance(5);
        assertThat(result).isEqualTo(1);
    }
    
    @Test
    @DisplayName("정지 조건에 따른 이동 거리 계산이 올바른지 확인")
    void 이동_거리_테스트2() {
        int result = racingService.calculateMoveDistance(1);
        assertThat(result).isEqualTo(0);
    }
}

calculateMoveDistance를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingService 클래스에 이동 거리를 계산하는 calculateMoveDistance를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingService {
...
    private static final int MOVE_THRESHOLD = 4;
    private static final int MOVE_STEP = 1;
    private static final int STOP_STEP = 0;
    
    public int calculateMoveDistance(int randomValue) {
        if (randomValue >= MOVE_THRESHOLD) {
            return MOVE_STEP;
        }
        return STOP_STEP;
    }
}

randomValue를 받아 4이상이면 1(전진할 거리), 4 미만이면 0(전진할 거리) 을 반환한다.

 

4. 자동차 위치가 올바르게 변하나?

class RacingServiceTest {
...
    @Test
    @DisplayName("자동차의 위치가 올바르게 변하는지 확인")
    void 자동차_위치_테스트() {
        int beforePosition = racingService.getCarPositions().get("a");
        racingService.moveCar("a", 4);

        assertThat(racingService.getCarPositions().get("a")).isGreaterThan(beforePosition);
    }
}

moveCar를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingService 클래스에 carNamerandomValue를 받아 해당하는 거리만큼 이동하는 moveCar를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingService {
...
    public void moveCar(String carName, int randomValue) {
        int moveDistance = calculateMoveDistance(randomValue);
        carPositions.put(carName, carPositions.get(carName) + moveDistance);
    }
}

calculateMoveDistance를 통해 randomValue로부터 이동할 거리를 받고, carName에 이동할 거리를 더해준다.

 

[🔥짚고 넘어갈 부분!!]

- carPositionsput해도 되나?

hashMap은 중복된 키를 가질 수 없으므로 carNamecarPosition 데이터를 넣어도 hashMap에 추가되는 것이 아닌, 기존의 carName 값에서 새로운 값으로 업데이트된다.


5. 우승자를 올바르게 선정하나?

class RacingServiceTest {
...
    @Test
    @DisplayName("최대 이동 거리를 가진 한 명의 사람을 우승자로 올바르게 반환하는지 확인")
    void 한_명의_우승자_테스트() {
        racingService.moveCar("a", 4);
        racingService.moveCar("b", 2);
        racingService.moveCar("c", 1);

        String[] result = racingService.getWinners();
        assertThat(result).containsExactly("a");
    }

    @Test
    @DisplayName("최대 이동 거리를 가진 여러 명의 사람을 우승자로 올바르게 반환하는지 확인")
    void 여러_명의_우승자_테스트() {
        racingService.moveCar("a", 4);
        racingService.moveCar("b", 8);
        racingService.moveCar("c", 1);

        String[] result = racingService.getWinners();
        assertThat(result).containsExactly("a", "b");
    }
}

getWinners를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingService 클래스에 우승자를 반환하는 getWinners를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingService {
...
    public String[] getWinners() {
        int maxPosition = getMaxPosition();

        Set<String> winners = new HashSet<>();
        for (Map.Entry<String, Integer> carPosition : carPositions.entrySet()) {
            if (carPosition.getValue() == maxPosition) {
                winners.add(carPosition.getKey());
            }
        }
        return winners.toArray(new String[0]);
    }
    
    private int getMaxPosition() {
        int maxPosition = INITIAL_POSITION;

        for (Integer curPosition : carPositions.values()) {
            if (curPosition > maxPosition) {
                maxPosition = curPosition;
            }
        }
        return maxPosition;
    }
}

carPositions를 통해 maxPosition을 알려주는 getMaxPosition 메서드를 생성한다.

winners를 담는 HashSet을 생성하여 각 carPositionmaxPositioncarName을 담아 배열로 변환하여 반환한다.

 

[🔥짚고 넘어갈 부분!!]

- HashSet을 선택한 이유

키를 저장하고 탐색하기 쉽게 하기 위하여 HashSet으로 정하였다. 근데 추후에 LinkedList으로 변경된다.

HashSet은 저장을 하며 순서를 기억하지 않아 순서가 임의로 바뀔 수도 있다. 나중에 우승자 출력이 여러 명일 때, 입력받은 순서를 기억하여 그 순서대로 출력하기 위해 LinkedList로 변경하게 되었다.


6. 사용자에게 입력받은 시도 횟수만큼 턴이 제대로 수행되나?

class RacingServiceTest {
...
    @Test
    @DisplayName("입력받은 시도 횟수만큼 턴이 수행되는지 확인")
    void 시도_횟수_확인_테스트() {
        racingService.startRace();
        int result = racingService.currentTurn;

        assertThat(result).isEqualTo(4);
    }
}

startRacecurrentTurn을 찾을 수 없어 테스트에 실패하게 된다.

=> RacingService 클래스에 랜덤 값을 생성하는 시도할 횟수만큼 턴이 실행되는 레이스를 시작하는 startRace과 현재 턴 횟수를 담는 currentTurn 변수를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingService {
...
    private final LinkedList<HashMap<String, Integer>> raceResult = new LinkedList<>();
    public int currentTurn = 0;

    public void startRace() {
        while (currentTurn < maxTurn) {
            raceResult.add(executeTurn());
            currentTurn++;
        }
    }
    
    private HashMap<String, Integer> executeTurn() {
        for (String carName : carPositions.keySet()) {
            int randomValue = createRandomValue();
            moveCar(carName, randomValue);
        }
        return new HashMap<>(carPositions);
    }
}

한 턴동안 모든 자동차의 위치가 업데이트된 carPosotions를 반환하는 executeTurn 메서드를 생성한다.

startRaceLinkedList로 만든 raceResult 각각의 실행 결과인 executeTurn 결과를 넣어준다.


7. 초기 입력이 출력 형식을 준수하나?

public class RacingIOTest {
    private RacingService racingService;

    private ByteArrayOutputStream outputStreamCaptor;
    private PrintStream standardOut;

    @BeforeEach
    void setUp() {
        racingService = new RacingService("a,b,c", "4");

        standardOut = System.out;
        outputStreamCaptor = new ByteArrayOutputStream();
        System.setOut(new PrintStream(outputStreamCaptor));
    }

    @AfterEach
    void restoresStreams() {
        System.setOut(standardOut);
        System.out.println(getOutput());
    }

    protected String getOutput() {
        return outputStreamCaptor.toString();
    }
}

우선 터미널에 출력되는 결과를 확인하기 위해 위의 코드를 작성해 준다. 

(대충 터미널에 입력되는 결과를 중간에 가로채 테스트 코드에서 확인하고, 다 실행한 후에 다시 원래대로 바꿔주는 내용이다.)

 

public class RacingIOTest {
...
    @Test
    @DisplayName("초기 입력 프롬프트가 출력 형식을 준수하는지 확인")
    void 초기_입력_프롬프트_테스트() {
        RacingIO.promptCarNameInput();
        RacingIO.promptMaxTurnInput();

        assertThat(getOutput()).contains("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)");
        assertThat(getOutput()).contains("시도할 횟수는 몇 회인가요?");
    }
}

RacingIO클래스와 promptCarNameInput, promptMaxTurnInput을 찾을 수 없어 테스트에 실패하게 된다.

=> RacingIO 클래스에 carName을 입력받고, 문구를 출력하는 promptCarNameInputmaxTurn을 입력 받고, 문구를 출력하는promptMaxTurnInput 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingIO {
    private enum Messages {
        CAR_NAME_INPUT_PROMPT("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"),
        MAX_TURN_INPUT_PROMPT("시도할 횟수는 몇 회인가요?"),

        private final String message;

        Messages(String message) {
            this.message = message;
        }

        public String getMessage() {
            return message;
        }
    }
    
    public static void promptCarNameInput() {
        System.out.println(Messages.CAR_NAME_INPUT_PROMPT.getMessage());
    }

    public static void promptMaxTurnInput() {
        System.out.println(Messages.MAX_TURN_INPUT_PROMPT.getMessage());
    }    
}

초기 입력에 필요한 문구들을 enum을 사용하여 상수로 만들고, 이를 각각 출력한다.


8. 각 턴의 실행 결과가 출력 형식을 준수하나?

public class RacingIOTest {
...
    @Test
    @DisplayName("각 턴의 실행 결과가 출력 형식을 준수하는지 확인")
    void 실행_결과_출력_형식_테스트() {
        LinkedList<HashMap<String, Integer>> raceResult = new LinkedList<>();

        updateAndRecordMovement("a", 4, raceResult);
        updateAndRecordMovement("a", 4, raceResult);
        updateAndRecordMovement("b", 4, raceResult);
        updateAndRecordMovement("c", 1, raceResult);

        RacingIO.printRaceResult(raceResult);

        assertThat(getOutput()).contains("실행 결과");
        assertThat(getOutput()).contains("a : -");
        assertThat(getOutput()).contains("a : --");
        assertThat(getOutput()).contains("b : -");
        assertThat(getOutput()).contains("c : ");
    }
    
    private void updateAndRecordMovement(String carName, int randomValue, LinkedList<HashMap<String, Integer>> raceResult) {
        racingService.moveCar(carName, randomValue);
        raceResult.add(new HashMap<>(racingService.getCarPositions()));
    }
}

printRaceResult를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingIO 클래스에 raceResult를 출력 형식에 맞게 출력하는 printRaceResult를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingIO {
    private enum Messages {
        CAR_NAME_INPUT_PROMPT("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"),
        MAX_TURN_INPUT_PROMPT("시도할 횟수는 몇 회인가요?"),
        TURN_RESULT_PROMPT("실행 결과");
        ...
    }
    
    public static void printRaceResult(LinkedList<HashMap<String, Integer>> raceResult) {
        System.out.println();
        System.out.println(Messages.TURN_RESULT_PROMPT.getMessage());

        for (HashMap<String, Integer> turnResult : raceResult) {
            printTurnResult(turnResult);
            System.out.println();
        }
    }
    
    private static void printTurnResult(HashMap<String, Integer> turnResult) {
        turnResult.forEach((carName, carPosition) ->
                System.out.println(carName + " : " + "-".repeat(carPosition))
        );
        System.out.println();
    }
}

enumTURN_RESULT_PROMPT를 추가해 준다.

각 턴을 담는 raceResult를 순회하며, 턴 결과를 이름 : -- 형식으로 출력해 주는 printTurnResult를 호출하는 printRaceResult를 생성한다.


9. 우승자 결과가 출력 형식을 준수하나?

public class RacingIOTest {
...
    @Test
    @DisplayName("우승자 결과가 출력 형식을 준수하는지 확인")
    void 우승자_출력_형식_테스트() {
        LinkedList<HashMap<String, Integer>> raceResult = new LinkedList<>();

        updateAndRecordMovement("a", 4, raceResult);
        updateAndRecordMovement("b", 4, raceResult);

        RacingIO.promptWinner(racingService.getWinners());

        assertThat(getOutput()).contains("최종 우승자 : a, b");
    }
    
    @Test
    @DisplayName("우승자가 여러 명일 때 우승자 입력 순서대로 출력하는지 확인")
    void 우승자_출력_순서_테스트() {
        racingService.setCarNameInput("a,aa,aaa");
        LinkedList<HashMap<String, Integer>> raceResult = new LinkedList<>();

        updateAndRecordMovement("a", 4, raceResult);
        updateAndRecordMovement("aa", 4, raceResult);
        updateAndRecordMovement("aaa", 4, raceResult);

        RacingIO.promptWinner(racingService.getWinners());

        assertThat(getOutput()).contains("최종 우승자 : a, aa, aaa");
    }
}

promptWinner를 찾을 수 없어 테스트에 실패하게 된다.

=> RacingIO 클래스에 우승자를 출력 형식대로 출력하는 promptWinner를 생성하여 해당 테스트가 green을 띄도록 해준다.

 

public class RacingIO {
    private static final String WINNER_DELIMITER = ", ";

    private enum Messages {
        CAR_NAME_INPUT_PROMPT("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"),
        MAX_TURN_INPUT_PROMPT("시도할 횟수는 몇 회인가요?"),
        TURN_RESULT_PROMPT("실행 결과"),
        WINNER_PROMPT("최종 우승자 : ");
        ...
    }

    public static void promptWinner(String[] winners) {
        String joinedWinners = String.join(WINNER_DELIMITER, winners);
        System.out.println(Messages.WINNER_PROMPT.getMessage() + joinedWinners);
    }
}

enumWINNER_PROMPT를 추가해 준다.

우승자를 담는 배열 winnersWINNER_DELEMITERjoin하여 출력해 준다.

 

[🔥짚고 넘어갈 부분!!]

- HashMap과  HashSet으로 저장하면 안 됐다!!!

만약에 자동차 이름이 a, aa, aaa이고 모두 우승자라면 최종 우승자 : a, aa, aaa로 출력되지 않고 최종 우승자 : aa, aaa, a로 출력되는 것을 확인했다. 찾아보니 HashMapHashSet은 저장을 하며 순서를 기억하지 않아 순서가 임의로 바뀌어 저장될 수도 있었다는 것을 알게 되었다. 예시를 보면 공동 우승자가 발생하면 입력한 순서대로 출력을 하기 때문에, 입력받은 순서를 기억하여 그 순서대로 출력해야 해서 LinkedHashMapLinkedList로 데이터 저장 방식을 바꿔주어 순서를 기억하도록 하였다.

 

10. 자동차 이름이 유효하지 않으면 IllegalArgumentException이 발생되나?

public class ValidatorTest {
    private RacingService racingService;

    @Test
    @DisplayName("자동차 이름이 5글자 초과인 경우")
    void 자동차_이름_유효성_테스트1() {
        assertThatThrownBy(() -> {
            racingService = new RacingService("aaaaaaa,bbbbbbb,ccccccc,ddddddd", "4");
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }
    
    @Test
    @DisplayName(" 자동차 이름이 중복된 경우")
    void 자동차_이름_유효성_테스트2() {
        assertThatThrownBy(() -> {
            racingService = new RacingService("a,a,b,c,d", "4");
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }
    
    @Test
    @DisplayName("자동차 이름이 공백인 경우")
    void 자동차_이름_유효성_테스트3() {
        assertThatThrownBy(() -> {
            racingService = new RacingService("a,,,b,,,", "4");
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

발생하는 예외가 IllegalArgumentException가 아니어서 테스트에 실패하게 된다.

=> Validator 클래스에 자동차 이름을 검사하는 validateCarName를 생성하여 IllegalArgumentException을 발생시켜 해당 테스트가 green을 띄도록 해준다.

 

public class Validator {
    private static final int MAX_LENGTH = 5;

    private enum ErrorMessage {
        MUST_BE_5_CHARACTERS_OR_LESS("자동차 이름이 5글자 이하여야 합니다."),
        MUST_BE_UNIQUE("자동차 이름이 중복되지 않아야 합니다."),
        MUST_BE_FILLED("자동차 이름이 공백이지 않아야 합니다.");

        private final String message;

        ErrorMessage(String message) {
            this.message = message;
        }

        public String getMessage() {
            return message;
        }
    }
 
    public static void validateCarName(LinkedHashMap<String, Integer> carPositions, String carName) {
        validateNotEmpty(carName);
        validateLength(carName);
        validateUnique(carPositions, carName);
    }

    public static void validateMaxTurn(String maxTurn) {
        validateIsNumber(maxTurn);
    }

    private static void validateUnique(LinkedHashMap<String, Integer> carPositions, String carName) {
        if (carPositions.containsKey(carName)) {
            throw new IllegalArgumentException(ErrorMessage.MUST_BE_UNIQUE.getMessage());
        }
    }

    private static void validateLength(String carName) {
        if (carName.length() > MAX_LENGTH) {
            throw new IllegalArgumentException(ErrorMessage.MUST_BE_5_CHARACTERS_OR_LESS.getMessage());
        }
    }    
}

발생하는 에러 메시지를 enum으로 만들어 주고, 각각 조건을 설정하여 유효하지 않으면 IllegalArgumentException을 설정한 에러 메시지와 함께 출력하도록 하였다.

 

public class RacingService {
...
    private void initializeCars(String carNameInput) {
        for (String carName : carNameInput.split(CAR_INPUT_DELIMITER, -1)) {
            Validator.validateCarName(carPositions, carName);
            carPositions.put(carName, INITIAL_POSITION);
        }
    }
}

그리고 RacingService 클래스에 초기화하는 함수에서 carNamevalidateCarName를 통해 유효성 검사를 하도록 추가해 주었다.

 

또한 자동차 이름 입력값을 split할 때 빈 문자열이면 배열에 포함되지 않아 split의 두 번째 인자에 -1을 넣어 빈 문자열도 배열에 담기도록 하였다. 

 

11. 시도할 횟수가 유효하지 않으면 IllegalArgumentException이 발생되나?

public class ValidatorTest {
...
    @Test
    @DisplayName("시도할 횟수가 숫자가 아닌 경우")
    void 시도할_횟수_유효성_테스트1() {
        assertThatThrownBy(() -> {
            racingService = new RacingService("a,b,c", "a");
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }
    
    @Test
    @DisplayName("시도할 횟수가 음수인 경우")
        void 시도할_횟수_유효성_테스트1() {
        assertThatThrownBy(() -> {
            racingService = new RacingService("a,b,c", "-1");
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

발생하는 예외가 IllegalArgumentException가 아니어서 테스트에 실패하게 된다.

=> Validator 클래스에 시도할 횟수를 검사하는 validateMaxTurn를 생성하여 IllegalArgumentException을 발생시켜 해당 테스트가 green을 띄도록 해준다.

 

public class Validator {
 private static final int MAX_LENGTH = 5;

    private enum ErrorMessage {
        MUST_BE_5_CHARACTERS_OR_LESS("자동차 이름이 5글자 이하여야 합니다."),
        MUST_BE_UNIQUE("자동차 이름이 중복되지 않아야 합니다."),
        MUST_BE_FILLED("자동차 이름이 공백이지 않아야 합니다."),
        MUST_BE_NUMBER("시도할 횟수가 숫자여야 합니다."),
        MUST_BE_ZERO_OR_MORE("시도할 횟수가 0 이상이어야 합니다.");
    ...
    }
    
    public static void validateMaxTurn(String maxTurn) {
        validateIsNumber(maxTurn);
    }
    
    private static void validateIsNumber(String maxTurn) {
        try {
            int maxTurnToInt = Integer.parseInt(maxTurn);
            validateNotNegative(maxTurnToInt);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(ErrorMessage.MUST_BE_NUMBER.getMessage());
        }
    }

    private static void validateNotNegative(int maxTurn) {
        if (maxTurn < 0) {
            throw new IllegalArgumentException(ErrorMessage.MUST_BE_ZERO_OR_MORE.getMessage());
        }
    }
}

발생하는 에러 메시지를 enum으로 만들어 주고, 각각 조건을 설정하여 유효하지 않으면 IllegalArgumentException을 설정한 에러 메시지와 함께 출력하도록 하였다.

 

 

[🔥짚고 넘어갈 부분!!]

- try 문 안에서 error를 NumberFormatException이라고 한 이유?

validateIsNumber에서 catch 할 에러에 Exception e라고 적고, validateNotNegative에서 에러가 발생한다고 하자. 그러면 catch가 모든 error를 잡아내므로 validateNotNegative에서 발생한 에러를 catch 하여 MUST_BE_ZERO_OR_MORE 메시지가 아닌 MUST_BE_NUMBER 메시지로 전달될 것이다. 따라서 NumberFormatException이라고 명시해 주면 Integer.parseInt(maxTurn)으로 발생한 에러만 잡고, validateNotNegative에서 던지는 IllegalArgumentException은 잡지 못하게 된다.

 

public class RacingService {
...
    public RacingService(String carNameInput, String maxTurnInput) {
        initializeCars(carNameInput);
        Validator.validateMaxTurn(maxTurnInput);
        this.maxTurn = Integer.parseInt(maxTurnInput);
    }
    ...
}

그리고 RacingService 클래스의 생성자 함수에서 maxTurnvalidateMaxTurn를 통해 유효성 검사를 하도록 추가해 주었다.

 

정제

 

public class ValidatorTest {
...
    @ParameterizedTest(name = "{1}")
    @CsvSource({
            "'aaaaaaa,bbbbbbb,ccccccc,ddddddd', 자동차 이름이 5글자 초과인 경우",
            "'a,a,b,c,d', 자동차 이름이 중복된 경우",
            "'a,,,b,,,', 자동차 이름이 공백인 경우"
    })
    void 자동차_이름_유효성_테스트(String carNameInput, String description) {
        assertThatThrownBy(() -> {
            racingService = new RacingService(carNameInput, "4");
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @ParameterizedTest(name = "{1}")
    @CsvSource({
            "'a', 시도할 횟수가 숫자가 아닌 경우",
            "'-1', 시도할 횟수가 음수인 경우"
    })
    void 시도할_횟수_유효성_테스트(String maxTurnInput, String description) {
        assertThatThrownBy(() -> {
            racingService = new RacingService("a,b,c", maxTurnInput);
            racingService.startRace();
        }).isInstanceOf(IllegalArgumentException.class);
    }
}
class RacingServiceTest {
...
    @ParameterizedTest(name = "{3}")
    @CsvSource({
            "'aa,bb,cc', 3, 'cc', 입력받은 여러 자동차를 LinkedHashMap 으로 생성",
            "'aa', 1, 'aa', 입력받은 한 대의 자동차를 LinkedHashMap 으로 생성"
    })
    @DisplayName("자동차 이름 입력에 따라 LinkedHashMap이 올바르게 생성되는지 확인")
    void 자동차_추출_테스트(String carNameInput, int size, String carName, String description) {
        racingService.setCarNameInput(carNameInput);
        assertThat(racingService.getCarPositions()).hasSize(size);
        assertThat(racingService.getCarPositions().keySet()).contains(carName);
    }
...
    @ParameterizedTest(name = "{2}")
    @CsvSource({
            "5, 1, 전진 조건에 따른 이동해야 할 거리 확인",
            "1, 0, 정지 조건에 따른 이동해야 할 거리 확인"
    })
    @DisplayName("전진 및 정지 조건에 따른 이동 거리 계산이 올바른지 확인")
    void 이동_거리_테스트(int randomValue, int expected, String description) {
        int result = racingService.calculateMoveDistance(randomValue);
        assertThat(result).isEqualTo(expected);
    }
...
}

JUnit5 ParameterizedTest를 사용하여 중복된 패턴을 제거하고 유사한 테스트를 그룹화하였다. 

 


public class Application {
    public static void main(String[] args) {
        new Application().runGame();
    }

    private void runGame() {
        String carNameInput = getCarName();
        String maxTurnInput = getMaxTurn();

        RacingService racingService = new RacingService(carNameInput, maxTurnInput);

        racingService.startRace();
        RacingIO.printRaceResult(racingService.getRaceResult());
        RacingIO.promptWinner(racingService.getWinners());
    }

    private String getCarName() {
        RacingIO.promptCarNameInput();
        return RacingIO.getInput();
    }

    private String getMaxTurn() {
        RacingIO.promptMaxTurnInput();
        return RacingIO.getInput();
    }
}
public class RacingIO {
...
    public static String getInput() {
        return readLine();
    }
...
}

RacingIO에 입력 받는 getInput을 추가하고, 이를 이용하여 입출력을 받고 RacingService 클래스의 비즈니스 로직을 실행하며 최종 결과를 RacingIO를 통해 결과를 출력해주는 Application 클래스도 구현해주었다.

etc-image-0

 

모든 테스트 케이스를 통과하였다 ~~~~~~~~~ !!

 

728x90