개발일기
3단계 RacingCar TDD 본문
우선 나는 레이싱 게임을 구현하는 TDD 과제를 받았다.
이를 통해서 어디서 부터 작성해야하는지 고민하였고 실패 코드 먼저 작성하려고 하는데 감이 안잡혀서 요구사항을 먼저 작성하였다.
## 기능 구현
* 초간단 자동차 경주 게임을 구현한다.
* 주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다.
* 사용자는 몇 대의 자동차로 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다.
* 전진하는 조건은 0에서 9 사이에서 random 값을 구한 후 random 값이 4이상일 경우이다.
* 자동차의 상태를 화면에 출력한다. 어느 시점에 출력할 것인지에 대한 제약은 없다.
* 사용자에게 받은 자동차 대수가 2대 이하면 게임을 진행할 수 없다.
* 시도할 회수가 0이하이면 게임을 진행할 수 없다.
### 기능 구현
* 각 자동차에 이름을 부여할 수 있다. 자동차 이름은 5자를 초과할 수 없다.
* 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다.
* 자동차 이름은 쉼표(,)를 기준으로 구분한다.
* 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한명 이상일 수 있다.
## Domain 별 기능 사항
### Car Domain
- [✅] 자동차의 이동과 현재 위치 관리를 담당
- [✅] 자동차가 4이상 인 경우 앞으로 전진 로직
### GenerateRandom Domain
- [✅] 자동차 경주 게임에서 10이하 랜덤 숫자를 생성
### InputView Domain
- [✅] 사용자로부터 입력
- [✅] 자동차 이름은 쉼표(,) 로 구분
- [✅] 자동차 이름 5자 초과 로직
### ResultView Domain
- [✅] 각 라운드의 레이싱 게임의 결과 자동차 이름과 전진 여부 로직
### RacingGame Domain
- [✅] 자동차 경주 게임을 진행
- [✅ ] 게임 종료 후 우승자 알려주는 로직
레이싱 게임을 구현하기 위해서 작성한 Readme.md 파일이다. 이를 통해서 각각 어떤 기능을 구현하고 어떤 기능을 테스트 코드를 작성해야하는지에 대해서 고민할 수 있게 되었다. 그러면 각각 도메인 단위로 기능 명세서를 작성하였고, 각각을 어떻게 테스트할지 어떻게 테스트하면 조금 더 쉽게 테스트 코드를 작성할 수 있을지에 대해 고민하였다.
랜덤 값의 경우 테스트 코드로 작성한다고 가정하였을 때, 굉장히 테스트하기 불편하고 어떤 값이 들어올지 확인할 수 없기 때문에 테스트하기가 어렵다. 이러한 것을 최상위 구조로 올려서 테스트를 한다면, 조금 더 단위 테스트를 작성할 수 있는 범위가 좋다.
Mocking을 사용하여 테스트 할 수 있겠지만 Mocking을 사용하지 않을 수 있는 부분을 Mocking 사용하여 테스트 할 수 밖에 없게 만든다면 이건 문제가 되는거 같다. 최대한 자바 코드에서 단위 테스트를 작성하려고 노력하는 것이 테스트 코드이며 Mocking은 마지막으로 사용해야함.
이제 각각 코드를 살펴보자. 우선 아직 완벽한 코드가 아닌걸 알아주었으면 좋겠다. 나또한 많은 고민을 하여 과제 테스트를 진행하였지만 아직 부족한 부분이 많다.
CarTest.java
package racing.car;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import racing.car.car.Car;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class CarTest {
@Test
void 초기값_0_테스트() {
Car car = new Car("seou");
assertThat(car).extracting("position").isEqualTo(0);
}
@Test
@DisplayName("move 메서드는 3 이상인 경우 position은 증가하지 않는다.")
void 앞으로_이동_4이하() {
Car car = new Car("baj");
car.move(3);
assertThat(car).extracting("position").isEqualTo(0);
}
@Test
@DisplayName("move 메서드는 4 이상인 경우 position이 1증가 된다.")
void 앞으로_이동_4이상() {
Car car = new Car("don");
car.move(6);
assertThat(car).extracting("position").isEqualTo(1);
}
@Test
@DisplayName("자동차_이름이_5글자_미만_성공")
void 자동차_이름_5글자_미만_성공() {
String name = "leo";
Car car = new Car(name);
assertThat(car).isNotNull();
assertThat(car.getName()).isEqualTo(name);
}
@Test
@DisplayName("자동차_이름이_5글자_초과_에러")
void 자동차_이름_5글자_초과_에러() {
assertThatThrownBy(() -> {
new Car("hanseounggyun");
}).isInstanceOf(RuntimeException.class).hasMessageMatching("자동차 이름이 5자글자를 초과하였습니다.");
}
}
해당 코드는 CarTest 자바 코드이다. 이 코드를 통해서 Car의 End Point에서 작성하였다. 이를 통해서는 테스트 코드의 엔드포인트를 테스트 하면서 범위 끝에서 어떤 식으로 작성할 수 있는지 알 수 있고 보통 엔드 포인트 지점에서 에러나는 경우도 있기에 해당 방식으로 테스트를 진행해주었다.
코드에서 보았듯이 성공 코드가 있다면, 나는 반드시 실패 코드를 작성해주었다.
Car 코드
package racing.car.car;
public class Car implements Movable {
private static final int MOVE_THRESHOLD = 4;
private final String name;
private final String OVER_MESSAGE_ERROR = "자동차 이름이 5자글자를 초과하였습니다.";
private int position;
public Car(String name) {
checkName(name);
this.name = name;
}
// 생성자에서 name을 반드시 받도록 강제
public Car(String name, int position) {
checkName(name);
this.name = name;
this.position = position;
}
@Override
public void move(int number) {
if (isMoveAllowed(number)) {
position++;
}
}
public int getPosition() {
return position;
}
public boolean isSame(int max) {
return max == position;
}
public String getName() {
return name;
}
private boolean isMoveAllowed(int number) {
return number >= MOVE_THRESHOLD;
}
private void checkName(String name) {
if (name.length() > 5) {
throw new RuntimeException(OVER_MESSAGE_ERROR);
}
}
}
나는 이런식으로 작성하였다. 여기서 많은 고민을 하면서 작성하였다. 사실 나는 TDD방식으로 구현하지 못하였다. TDD는 먼저 실패 코드를 작성해야하는데 나는 프로덕션 코드 먼저 작성했다. 그 이유는 대표적으로 요구사항을 적었음에도 불구하고 아직 테스트 코드를 작성하는 데에 익숙하지 않기 때문에 테스트코드가 작성이 되지않았다.
그래서 위에와 같은 프로덕션 코드를 먼저 작성하였으며 해당 클래스를 작성할 때, 내가 생각했던 것은 이 클래스가 해당 메서드를 들고 있는 책임을 부여해야하는가? 였다. 사실 이부분이 말이 쉽지 엄청어렵다.
그래서 많은 피드백을 받았고 해당 클래스의 구조로 작성하게되었다.
Movable 인터페이스 코드
package racing.car.car;
public interface Movable {
void move(int number);
}
사실 인터페이스를 작성한 것은 오버스펙이다. why? 왜 오버스펙인가 어차피 여러 클래스가 인터페이스를 상속받을 일이 없음 덕분에 오버스펙이되어버림. 나는 추후를 고려하여 만들었지만 이러한 코드는 나중에 변경해도 된다는 피드백을 받았다.
솔직히 오래 걸리는 일이 아니기에 필요할 때 만들어도 되겠네라는 생각을 할 수 있었다.
프로그래머가 가장 경계해야 할 일은 오버스펙이라고 한다.
RacingGame 코드
package racing.car.game;
import racing.car.car.Car;
import racing.car.random.GenerateRandom;
import racing.car.ui.InputView;
import racing.car.ui.ResultView;
import racing.car.winner.Winner;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class RacingGame implements Game {
private static final Scanner scanner = new Scanner(System.in);
private static final InputView INPUT_VIEW = new InputView(scanner);
private static final ResultView RESULT_VIEW = new ResultView();
private static final String INVALID_CAR_COUNT_MESSAGE = "게임을 진행하려면 자동차가 최소 2대 있어야 합니다.";
private static final String INVALID_TRY_COUNT_MESSAGE = "게임을 진행하려면 시도 횟수는 1 이상이어야 합니다.";
private static final GenerateRandom GENERATE_RANDOM = new GenerateRandom();
@Override
public void play() {
INPUT_VIEW.carQuestion();
String[] carNames = INPUT_VIEW.inputCar();
List<Car> cars = new ArrayList<>();
initializeCars(carNames, cars);
INPUT_VIEW.tryQuestion();
int tryCount = validateTryCount(INPUT_VIEW.inputTry());
for (int i = 0; i < tryCount; i++) {
simulateRaceRound(cars);
}
RESULT_VIEW.outputWinnerView(Winner.getWinnerInfo(max(cars), cars));
}
public void simulateRaceRound(List<Car> cars) {
for (Car car : cars) {
car.move(GENERATE_RANDOM.random());
}
RESULT_VIEW.outputView(cars);
}
public int max(List<Car> cars) {
int max = 0;
for (Car car : cars) {
max = Math.max(max, car.getPosition());
}
return max;
}
public void initializeCars(String[] carNames, List<Car> cars) {
for (int i = 0; i < carNames.length; i++) {
cars.add(new Car(carNames[i]));
}
}
public int validateCarCount(int carCount) {
if (carCount < 2) {
throw new RuntimeException(INVALID_CAR_COUNT_MESSAGE);
}
return carCount;
}
public int validateTryCount(int tryCount) {
if (tryCount <= 0) {
throw new RuntimeException(INVALID_TRY_COUNT_MESSAGE);
}
return tryCount;
}
}
각각을 상수를 통해서 한번만 가져와서 사용할 수 있게 만들었고, depth가 1이 넘지 않도록 만들었다.
그러고 메소드 하나에 하나의 책임을 부여하고자 노력하였으며 검증하기 어려운 테스트코드를 작성하지 않고자 노력하였다.
하지만 play메소드 같은 경우는 검증을 하기 어렵지만, 해당 메서드는 굳이 검증할 필요가 없다 why? 아래에 있는 것들을 다 검증한다면 굳이 검증하지 않아도 play메서드는 기능적으로 정상작동한다고 볼 수 있기 때문이다.
package racing.game;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import racing.car.car.Car;
import racing.car.game.RacingGame;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class RacingGameTest {
private RacingGame game;
@BeforeEach
void setUp() {
game = new RacingGame();
}
@Test
@DisplayName("initializeCars 메서드는 각 Car 객체를 올바르게 초기화해야 한다.")
void initializeCars_메서드_테스트() {
List<Car> cars = new ArrayList<>();
String[] carNames = {"leo", "seoun"};
game.initializeCars(carNames, cars);
assertThat(cars).extracting(Car::getName).contains("leo", "seoun");
}
@Test
@DisplayName("validateCarCount 메서드는 유효한 자동차 개수 입력 시 해당 값을 반환해야 한다.")
void validateCarCount_메서드_테스트_성공() {
int carCount = 2;
int result = game.validateCarCount(carCount);
assertThat(result).isEqualTo(2);
}
@Test
@DisplayName("validateCarCount 메서드는 자동차 개수가 2 미만일 경우 예외를 발생시켜야 한다.")
void validateCarCount_메서드_테스트_실패() {
int carCount = 0;
assertThatThrownBy(() -> {
game.validateCarCount(carCount);
})
.isInstanceOf(RuntimeException.class)
.hasMessageMatching("게임을 진행하려면 자동차가 최소 2대 있어야 합니다.");
}
@Test
@DisplayName("validateTryCount 메서드는 유효한 시도 횟수 입력 시 해당 값을 반환해야 한다.")
void validateTryCount_메서드_테스트_성공() {
int tryCount = 1;
int result = game.validateTryCount(tryCount);
assertThat(result).isEqualTo(1);
}
@Test
@DisplayName("validateTryCount 메서드는 시도 횟수가 1 미만일 경우 예외를 발생시켜야 한다.")
void validateTryCount_메서드_테스트_실패() {
int tryCount = 0;
assertThatThrownBy(() -> {
game.validateTryCount(tryCount);
})
.isInstanceOf(RuntimeException.class)
.hasMessageMatching("게임을 진행하려면 시도 횟수는 1 이상이어야 합니다.");
}
}
위에 코드는 레이싱 게임 코드입니다.
랜덤 클래스
package racing.car.random;
public class GenerateRandom implements Random {
private static final int SPEED_MAX = 10;
private static final java.util.Random RANDOM_GENERATOR = new java.util.Random();
@Override
public int random() {
return RANDOM_GENERATOR.nextInt(SPEED_MAX);
}
}
위에서 말했듯이 랜덤 클래스를 만들어줌으로서 조금 더 쉽게 테스트코드를 작성하기 위하여 만들었다.
package racing.car.ui;
import java.util.Scanner;
public class InputView {
private static final String CAR_QUESTION = "경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).";
private static final String TRY_QUESTION = "시도할 횟수는 몇 회 인가요?";
private final Scanner scanner;
public InputView(Scanner scanner) {
this.scanner = scanner;
}
public String[] inputCar() {
return splitTry(scanner.next());
}
public int inputTry() {
return scanner.nextInt();
}
public String[] splitTry(String tryInput) {
return tryInput.split(",");
}
public void carQuestion() {
System.out.println(CAR_QUESTION);
}
public void tryQuestion() {
System.out.println(TRY_QUESTION);
}
}
InputView이다 나는 여기서 굳이 input output은 테스트를 하지 않았지만 욕심을 내어 테스트 코드를 구현하였다.
package racing.ui;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import racing.car.ui.InputView;
import java.util.InputMismatchException;
import java.util.Scanner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class InputViewTest {
@Test
@DisplayName("inputTry 메서드 실행 시, 입력한 숫자를 반환해야한다.")
void input_Try_메서드_테스트() {
Scanner scanner = new Scanner("3");
InputView view = new InputView(scanner);
int number = view.inputTry();
assertThat(number).isEqualTo(3);
}
@Test
@DisplayName("inputTry 메서드 실행 후, 문자열 입력시 InputMismatchException 예외 발생")
void input_Try_메서드_문자_입력_예외_테스트() {
Scanner scanner = new Scanner("asb");
InputView view = new InputView(scanner);
assertThatThrownBy(view::inputTry).isInstanceOf(InputMismatchException.class);
}
@Test
@DisplayName("splitTry 메서드 테스트 이름 문자열 쉼표 구분시 String 배열에 저장")
void splitTry_메서드_테스트() {
String input = "leo,bara,bake";
String[] carNames = input.split(",");
assertThat(carNames).isEqualTo(new String[]{"leo", "bara", "bake"});
}
}
Winner코드이다.
package racing.car.winner;
import racing.car.car.Car;
import java.util.List;
import java.util.stream.Collectors;
public class Winner {
public static List<String> getWinnerInfo(int max, List<Car> cars) {
return cars.stream().filter(car -> car.isSame(max)).map(Car::getName).collect(Collectors.toList());
}
}
처음에 피드백을 받은게 우승자 구하는 코드가 다른 코드랑 같이 있다보니, 테스트 구현을 하기 어려웠다. 하지만, 메서드와 클래스를 분리하니 너무 쉽게 테스트 코드를 작성할 수 있었다. 이러한 하나에하나에 재미를 느끼니깐 너무 재밌다.
package racing.winner;
import org.junit.jupiter.api.Test;
import racing.car.car.Car;
import racing.car.winner.Winner;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class WinnerTest {
@Test
void winner(){
int max = 5;
List<Car> cars = List.of(new Car("seou", 5), new Car("poi", 3));
List<String> winnerInfo = Winner.getWinnerInfo(max, cars);
assertThat(winnerInfo).contains("seou");
}
}
'TDD' 카테고리의 다른 글
감면 혜택 및 취등록세 계산 관련 테스트 코드 작성기 (0) | 2025.04.18 |
---|---|
다시 작성해보는 TDD RacingCar (0) | 2024.11.19 |
4단계 : racing car 리팩토링 (6) | 2024.10.28 |
TDD 1단계 구현 (0) | 2024.09.27 |