본문 바로가기
교육 및 책/TDD, 클린 코드 with Java

TDD, 클린 코드 - 자동차 경주

by oneny 2023. 4. 13.

자동차 경주 피드백

Car

public class Car {

  private int distance;

  public Car() {
    this.distance = 0;
  }

  public int distance() {
    return distance;
  }

  // 람다식으로 표현할 수 있도록 GameStrategy를 Funtional Inteface로 만들었는데 이 부분에 대해 따봉 받았다!
  // Predicate Interface와 고민했는데 movable 메서드로 좀 더 의도적으로 표현하기 위해 GameStrategy Interface를 만들었다.
  public void forward(GameStrategy racingStrategy) {
    if (racingStrategy.movable()) {
      distance++;
    }
  }

  // 도메인 모델에서 출력(Presentation Layer)과 결합이 생겼다고 하셨다.
  // View의 경우, 출력 형식에 따라 변경이 잦기도 하고, Console -> Web 등으로 구조적 변경이 이루어질 경우 도메인 모델의 변경이 불가피하기 때문에 의존을 제거하자!
  // 이 부분에 대해서 ResultView로 빼야겠다.
  public void printDistance() {
    String printedDistance = "";

    for (int i = 0; i < distance; i++) {
      printedDistance += "-";
    }

    System.out.println(printedDistance);
  }
}

 

ResultView

public class ResultView {
  // 응답결과를 표현하는 Presentation Layer 역할을 main 메서드를 호출하는 application 역할을 수행하도록 만들어 버렸다.
  // Racing을 ResultView로 만드는 것이 더 나은 표현일 것 같다.
  public static void main(String[] args) {
    Racing racing = new Racing(InputView.inputCarNumber(), InputView.inputTryNumber());

    racing.play();
  }
}

 

InputViewTest

// reflection을 활용하여 테스트하기 보다는 System.setIn 메서드 등을 통해서 Scanner input 값을 세팅하고 테스트하는 방향도 있다.
// private 메서드에 대해 테스트가 필요하다면 객체를 분리할 시점은 아닌지 고민도 해보는 것이 좋다고 하셨다.
// 이 부분은 util.NumberValidator로 만들어 분리해야 겠다.
@ParameterizedTest(name = "입력한 수에 따라 양수이면 true, 아니면 false 확인 테스트")
@CsvSource(value = {"1:true", "7:true", "0:false", "-1:false"}, delimiter = ':')
public void givenNumber_WhenValidateNumber_ThenTrueOrFalse(int input, boolean expected) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
  assertThat(getIsPositive().invoke(null, input)).isEqualTo(expected);
}

private Method getIsPositive() throws NoSuchMethodException {
  Method method = InputView.class.getDeclaredMethod("isPositive", int.class);
  method.setAccessible(true);
  return method;
}

 

자동차 경주 리팩토링

InputView로부터 분리한 NumberValidator

public class NumberValidator {

  private static final int POSITIVE_MINIMUM = 1;

  private NumberValidator() {
  }

  public static int validatedPositive(int number) {
    if (!isPositive(number)) {
      throw new IllegalArgumentException("1회 이상만 입력 가능합니다.");
    }

    return number;
  }

  private static boolean isPositive(int number) {
    return number >= POSITIVE_MINIMUM;
  }
}

util 패키지에 NumberValidator 유틸성 클래스로 분리함으로써 테스트가 하기 쉬운 구조로 만들었다.

 

NumberValidatorTest

public class NumberValidatorTest {

  @ParameterizedTest(name = "양수를 입력하는 경우 해당 양수 반환 테스트")
  @ValueSource(ints = {1, 3, 6, 9})
  public void validatedPositive_양수_반환(int input) {
    assertThat(validatedPositive(input)).isEqualTo(input);
  }

  @ParameterizedTest(name = "0이나 음수를 입력하는 경우 IllegalArgumentException 발생 테스트")
  @ValueSource(ints = {0, -1, -7, -9})
  public void validatedPositive_IllegalArgumentException_발생(int input) {
    assertThatThrownBy(() -> validatedPositive(input))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("1회 이상만 입력 가능합니다.");
  }
}

따로 private 메서드를 public으로 만들어야 하나 고민할 필요도 없어졌고, 테스트하는데에도 훨씬 수월해졌다.

 

RacingGame

public class RacingGame {
  private final Cars cars;

  public RacingGame(int carNumber) {
    this.cars = initialCars(carNumber);
  }

  public GameResult play(int tryNumber) {
    GameResult result = new GameResult();

    for (int i = 0; i < tryNumber; i++) {
      cars.forwardCars();
      result.addRecord(Cars.copyCars(cars));
    }

    return result;
  }

  private static Cars initialCars(int carNumber) {
    List<Car> cars = new ArrayList<>();

    for (int i = 0; i < carNumber; i++) {
      cars.add(new Car());
    }

    return new Cars(cars);
  }
}

InputView에서 사용자의 입력값을 받아 메시지를 RacingGame에 메시지를 보내 자동차게임 로직이 돌아가도록 구성했다. 
그리고 이후 GameResult에서 매 회 턴들에 대해 기록을 하고 결과를 낼 수 있도록 저장하기 위해 각 Cars 객체를 copy하여 저장할 수 있도록 copyCars 메서드를 작성했다.

copyCars 메서드
  public static Cars copyCars(Cars cars) {
    return new Cars(cars.cars().stream()
            .map(Cars::copyCar)
            .collect(Collectors.toList()));
  }

  private static Car copyCar(Car car) {
    return new Car(car.distance());
  }

 

RacingGameTest

public class RacingGameTest {

  private Cars cars;

  @BeforeEach
  public void setUp() {
    List<Car> carList = new ArrayList<>();

    carList.add(new Car());
    carList.add(new Car());
    carList.add(new Car());

    cars = new Cars(carList);
  }

  @Test
  public void play_3번_시도() {
    cars.forwardCars();
    cars.forwardCars();
    cars.forwardCars();

    assertThat(cars.cars().get(0).distance()).isBetween(0, 3);
  }
}

RacingGameTest에서는 3개의 자동차를 cars에 저장하여 forwardCars() 메서드를 통해 랜덤으로 전진하고 0번째 엘리먼트가 0 ~ 3만큼 전진했는지 확인하는 테스트를 작성했다.

 

GameResult

public class GameResult {
  private final List<Cars> record;

  public GameResult() {
    record = new ArrayList<>();
  }

  public List<Cars> record() {
    return Collections.unmodifiableList(record);
  }

  public void addRecord(Cars cars) {
    record.add(cars);
  }
}

그리고 GameResult에는 매회 턴마다의 Cars 객체를 저장할 수 있도록 작성하여 추후에는 최종승리자를 판별할 수 있는 메서드를 추가할 예정이다.

 

ResultView

public class ResultView {

  private ResultView() {}

  public static void show(GameResult result) {
    System.out.println();
    System.out.println("실행 결과");

    for (Cars cars : result.record()) {
     printDistance(cars);

     System.out.println();
    }
  }

  private static void printDistance(Cars cars) {
    cars.cars().forEach((car) -> System.out.println(car.printedDistance()));
  }
}

다시 돌아온 ResultView에서는 InputView에서 입력값을 받아 RacingGame에서 자동차 게임 로직이 돌아가고 GameResult에서 매 턴 결과를 저장하여 다시 ResultView에서 그 결과들을 보여줄 수 있도록 구성했다.

 

RacingGameApplication

public class RacingGameApplication {
  public static void main(String[] args) {
    int carNumber = InputView.inputCarNumber();
    int tryNumber = InputView.inputTryNumber();

    RacingGame racingGame = new RacingGame(carNumber);
    GameResult result = racingGame.play(tryNumber);
    ResultView.show(result);
  }
}

마지막 application으로 마무리하여 3단계를 통과했다! :)