⭐️ 들어가기 전
우아한테크코스 레벨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으로 해줬다.
🍀 경주 참여자들
자동차들을 모아놓은 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에서 받는다.
⭐️ 결론
자동차 미션을 진행하면서, 좀 더 객체지향적으로 사고할 수 있었고, 어떻게 분리해야 테스트를 쉽게 할 수 있는지 알 수 있었다.
자동차 코드는 다음에서 확인할 수 있다.
'우아한테크코스 6기 백엔드' 카테고리의 다른 글
[우아한테크코스 6기 백엔드] 방탈출 예약 관리 회고 (0) | 2024.12.19 |
---|---|
[우아한테크코스 6기 백엔드] 체스 회고 (0) | 2024.12.19 |
[우아한테크코스 6기 백엔드] 블랙잭 회고 (3) | 2024.12.19 |
[우아한테크코스 6기 백엔드] 사다리 타기 회고 (0) | 2024.12.19 |
[우아한테크코스 6기 백엔드] 최종 합격 후기 (4) | 2024.12.19 |