
우아한테크코스 레벨1의 첫번째 미션은 자동차 미션이다.
이 미션의 목표는 단위테스트이다. 단위테스트란 응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트이다.
기능 요구사항은 우아한테크코스 6기 프리코스와 거의 동일하고 자율적으로 기능을 추가할 수 있다.
단위 테스트에 초점을 맞춰 도메인과 그것들의 테스트 설명을 먼저 하고, 그 외의 프로그램 구조적인 부분을 분리해서 회고하려 한다.
우선 도메인들에 대해 설명하겠다.
자동차 경주 게임을 구현할 때 제일 먼저 생각나는 것은 역시 자동차이다.
자동차는 이름과 위치를 가지고, 0~9의 값 중 4가 나오는 경우 움직인다.
단위 테스트를 위해서 이름과 위치, 움직이는 조건을 분리했다.
자동차 이름과 위치는 원시값 포장해서 CarName, Position을 만들었다.
이것의 장점으로 검증 코드를 자동차에서 CarName, Position으로 분리해서 유지보수성이 좋아졌다.
Position의 경우 value에 대해서 equals, hashCode를 Override하고, Comparable interface를 받아 compareTo를 Override해서 value값으로 위치를 비교할 수 있었다. Car 또한 compareTo를 Override해서 position 값으로 순서를 비교할 수 있게 했다.
CarName과 Position과 같이 원시값 포장한 것들은 Car에서만 사용하기 때문에 car 하위 폴더에 넣어주었다. 내가 생각하기엔 이게 중요한 부분인 것 같다.
자동차는 0~9 중 4가 나왔을 경우 앞으로 간다.
자동차가 특정 조건에 따라 움직인다에 의존하는 것 같아서 MovingStrategy를 만들어서 분리했다.
MovingStrategy는 리턴 타입이 boolean인 move 메서드를 가진다.
특정 조건(0~9 중 4)을 분리하고, 자동차가 앞으로 가거나, 안가거나를 받고 싶었다.
0~9 중 4가 나오는 경우가 아니라 "CarStatus == A 인 경우 앞으로 간다"라는 조건으로 변경될 수 있기 때문에 boolean으로 해줬다.
NumberGenerator라는 인터페이스가 있고, 그것을 구현한 RandomNumberGenerator가 있다.
MovingStrategy를 구현한 DefaultMovingStrategy는 NumberGenerator라는 필드를 가진다. 랜덤 숫자가 얻는 것 또한 갈아끼울 수 있게 작성하고자 했다.
자동차들을 모아놓은 RaceParticipants는 일급 컬렉션이다. 생성자와 getter에서 주소값에 따라 변하는 것을 막아주었다. 이름을 Cars나 RacingCars로 할 수 있었겠지만 의미있는 이름으로 정하고 싶었다.
마지막으로 RaceResults는 자동차들이 한 번 움직일 때마다 그 위치를 기록하고, 마지막에 우승자를 결정하기 위해서 만들었다. 경주를 기록하고 결과로 주지 않으면 I/O 작업이 너무 빈번하게 일어나기도 하고, 경주 결과를 얻는 것과 경주 결과를 출력하는 것에 의존이 생겨서 분리했다.
다음은 도메인의 테스트에 대해서 설명하겠다.
일반적인 예외 테스트는 간단하니 생략한다.
초점을 맞춘 부분은 "특정 조건일 경우 자동차가 간다"와 "모든 자동차들을 움직인다" 의 경우이다.
MovingStrategy의 canMove는 boolean이기 때문에 한 번 밖에 쓸 수 없다.
그래서 다음과 같이 movableList를 만들어서 canMove를 사용할 때마다 리스트의 특정 인덱스의 값을 가져오게 했다.
public class MockMovingStrategy implements MovingStrategy {
private final List<Boolean> movableList;
private int currentIndex = 0;
public MockMovingStrategy(final List<Boolean> movableList) {
this.movableList = new ArrayList<>(movableList);
}
public MockMovingStrategy() {
this.movableList = new ArrayList<>();
}
@Override
public boolean canMove() {
if (currentIndex >= movableList.size()) {
throw new IllegalStateException("더 이상 이동할 수 없습니다.");
}
return movableList.get(currentIndex++);
}
}
움직인다라는 것을 리스트로 만들어서 여러번 움직임을 테스트할 수 있었다.
@Test
void 자동차_움직임_성공() {
// given
final List<Boolean> movableList = List.of(true, false, true, false, true);
final Car car = new Car("car", new MockMovingStrategy(movableList));
// when
for (int i = 0; i < movableList.size(); i++) {
car.move();
}
// then
assertThat(car.getPosition()).isEqualTo(3);
}
도메인 외적인 프로그램 구조적인 코드에 대해 설명하겠다.
우선 나는 InputView나 OutputView나 움직임 전략, 숫자 생성 전략 등등을 interface로 만들었고, 대부분의 생성자들에서 interface로 받는다.
그래서 AppConfig만으르 조작해서 프로그램을 바꿀 수 있게 의도했다.
public class AppConfig {
private AppConfig() {
}
public static InputView consoleInputView() {
return new ConsoleInputView();
}
public static OutputView consoleOutputView() {
return new ConsoleOutputView();
}
public static NumberGenerator randomNumberGenerator() {
return new RandomNumberGenerator();
}
public static MovingStrategy defaultMovingStrategy() {
return new DefaultMovingStrategy(randomNumberGenerator());
}
public static RacingController racingController() {
return new RacingController(
consoleInputView(),
consoleOutputView(),
defaultMovingStrategy()
);
}
}
예외 처리에 대해서 IllegalArgumentException을 상속받은 BaseException을 만들고 BaseException을 상속받아 예외들을 만들어 사용했다. ErrorMessage들은 enum으로 만들어 관리했다.
package racingcar.exception;
public class BaseException extends IllegalArgumentException {
private static final String PREFIX = "[ERROR]";
public BaseException(final String message) {
super(String.format("%s %s", PREFIX, message));
}
}
public enum ErrorMessage {
INPUT_NOT_A_NUMBER("입력 값은 숫자여야 합니다."),
INVALID_CAR_NAME_LENGTH(String.format("자동차 이름은 %d자 이하여야 합니다.", MAX_NAME_LENGTH)),
INVALID_RACE_COUNT_RANGE(String.format("시도 횟수는 %d~%d이어야 합니다.", MIN_RACE_COUNT, MAX_RACE_COUNT)),
DUPLICATE_CAR_NAMES("중복된 자동차 이름이 존재합니다."),
INVALID_POSITION(String.format("위치는 %d 이상이어야 합니다.", MIN_POSITION)),
INVALID_CAR_NAME_FORMAT("자동차 이름에 한글, 영어, 숫자만 가능합니다."),
;
private final String message;
ErrorMessage(final String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
request, response dto도 사용했다.
내가 생각하는 dto의 역할은 controller <-> view나 domain <-> view의 의존 관계를 줄이기 위해서 사용한다고 생각한다.
public record RaceParticipantsRequest(String input) {
public RaceParticipants toRaceParticipants(final MovingStrategy movingStrategy) {
final List<Car> cars = InputUtils.splitByComma(input).stream()
.map(carName -> new Car(carName, movingStrategy))
.toList();
return new RaceParticipants(cars);
}
}
public record RaceWinnersResponse(List<String> raceWinners) {
public static RaceWinnersResponse from(final List<Car> raceWinners) {
final List<String> raceWinnersResponse = raceWinners.stream()
.map(Car::getName)
.toList();
return new RaceWinnersResponse(raceWinnersResponse);
}
}
위와 같이 view가 주고 싶은 코드를 dto에 주고, controller가 받고 싶은 코드를 dto에서 받는다. 또는 controller가 주고 싶은 코드만 dto에 주고 view가 받고 싶은 코드를 dto에서 받는다.
자동차 미션을 진행하면서, 좀 더 객체지향적으로 사고할 수 있었고, 어떻게 분리해야 테스트를 쉽게 할 수 있는지 알 수 있었다.
자동차 코드는 다음에서 확인할 수 있다.