diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..1722da3deb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+- fix(controller): 객체 dto 변환 후 view에 전달하는 로직 추가 (62ceabb)
+- fix(view): 객체 아닌 dto로 받아 출력하도록 수정 (1f0ba7f)
+- fix(mapper): Mapper 클래스를 통한 객체 > dto 변환 로직 추가 (21b6292)
+- fix(dto): dip 규칙 준수를 위해 cars 객체에 대한 dto 추가 (463939e)
+- fix(dto): dip 규칙 준수를 위해 car 객체에 대한 dto 추가 (3ab8974)
+- chore(CHCANGELOG): 작성한 커밋 메세지 전체 내용 추가 (05ad46f)
+- test(service): RacingService 클래스 단위 테스트 추가 (6cf6583)
+- test(domain): RandomMovePolicy 클래스 단위 테스트 추가 (67936d1)
+- test(domain): WinnerCalculator 클래스 단위 테스트 추가 (abbe596)
+- test(domain): Cars 클래스 단위 테스트 추가 (47b57ee)
+- test(domain): Car 클래스 단위 테스트 추가 (d43a9f7)
+- feat: 프로그램 실행을 위한 진입점 추가 (02a37a4)
+- feat(error): IllegalArgumentException 발생 시 나오는 에러 메세지 추가 (3527baa)
+- feat(controller): RacingController 클래스 구현 및 게임 실행 흐름 관리 (1026c07)
+- feat(view): 사용자의 입출력 처리를 위한 view 클래스 구현 (3f5ce98)
+- feat(view): 사용자에게 입출력 받기 위한 메세지 리소스 추가 (5f3238c)
+- feat(service): RacingService 클래스 구현 및 게임 로직 제공 (dc4acfa)
+- feat(domain): WinnerCalculator로 우승 자동차 판단 구현 (4e9b658)
+- feat(domain): RandomMovePolicy로 이동 조건 구체화 (ad439ca)
+- feat(util): 랜덤값 생성기 RandomGenerator 추가 (a92b8d7)
+- feat(domain): MovePolicy 인터페이스 정의로 이동 조건 추상화 (5d3890d)
+- feat(domain): Cars 일급 컬렉션 도입 및 리스트 관련 로직 캡슐화 (7c0a3c0)
+- feat(domain): Car 클래스 구현 (9644837)
+- docs(README): 기능 정리 및 예외 조건 등 요구사항 분석 README 추가 (5405b2d)
\ No newline at end of file
diff --git a/README.md b/README.md
index d0286c859f..ae55bce3ac 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,162 @@
# java-racingcar-precourse
+
+### 🔑 기능 정리
+
+1. 사용자에게 자동차 경주할 자동차 이름들과 경주 차수를 받는다.
+2. 경주 차수 동안 n대의 자동차가 조건에 따라, 전진 또는 멈춘 상황을 출력한다.
+3. 게임이 끝난 후 가장 멀리 이동해 우승한 자동차를 출력한다.
+
+---
+
+### ✅ 구현 조건
+
+#### 입력 조건
+
+자동차 이름
+
+- [ ] 경주할 자동차 이름과 경주 차수 2가지 입력 필요
+- [ ] 자동차 이름은 `,` 기준으로 구분
+- [ ] 자동차 이름은 1자 이상 5자 이하만 가능함
+- [ ] 사용성을 고려해 자동차 이름에 공백이 있는 경우 없는 것으로 처리함
+
+경주 차수
+
+- [ ] 경주 차수는 숫자여야 함
+- [ ] 경주 차수는 양의 정수여야 함
+- [ ] 사용자가 잘못된 값을 입력한 경우 `IllegalArgumentException`을 발생 후 종료되어야 함
+
+#### 동작 조건
+
+- [ ] 경주는 1명이서 할 수 없음
+- [ ] 입력된 경주 차수 동안 n대의 자동차는 전진 또는 멈출 수 있음
+ - [ ] 전진 조건: 0 ~ 9 사이에서 추출한 무작위 값이 4 이상인 경우
+- [ ] 각 경주마다 각 자동차의 이동 결과를 출력
+- [ ] 우승자는 한 명 이상일 수 있음
+ - [ ] 우승 조건: 가장 많이 전진한 자동차
+
+#### 출력 조건
+
+- [ ] 자동차 이름은 `,` 기준으로 구분
+- [ ] 전진하는 자동차를 출력할 때마다, 전진한 자동차 이름과 이동거리를 출력 (ex `pobi : --`)
+- [ ] 자동차 경주 게임 완료 후, 누가 우승했는지 출력 (ex `최종 우승자 : pobi`)
+- [ ] 공동 우승자의 경우 `,`를 기준으로 출력 (ex `최종 우승자 : pobi, jun`)
+- [ ] 우승자 출력 시, 입력 순서로 출력해야 함
+
+---
+
+### 🚨 예외(IllegalArgumentException) 발생 조건
+
+- 잘못된 값을 입력한 경우 예외 발생
+- 입력해야 하는 값: `자동차 이름`, `경주 차수`
+
+자동차 이름
+
+1. 자동차 이름이 공백이면 예외 발생
+2. 자동차 이름은 5자 초과면 예외 발생
+3. 자동차 이름 중복 입력 시 예외 발생
+4. 자동차 이름 목록에 이름이 1개일 경우 예외 발생
+
+경주 차수
+
+1. 경주 차수에 공백 입력 시 예외 발생
+2. 경주 차수에 숫자가 아닌 값 입력 시 예외 발생(`NumberFormatException`이 발생하는 경우)
+3. 경주 차수에 0 입력 시 예외 발생
+4. 경주 차수에 음수 입력 시 예외 발생
+
+---
+
+### 🧪 테스트 목록
+
+##### **CarTest**
+
+* [ ] 성공: 이름 길이 1~5자 허용, 생성 가능
+* [ ] 성공: 이동 조건 충족 시 위치 1 증가
+* [ ] 성공: 이동 조건 미충족 시 위치 유지
+* [ ] 실패: 이름이 공백이거나 5자 초과면 예외 발생 → `IllegalArgumentException`
+
+---
+
+##### **CarsTest**
+
+* [ ] 성공: 여러 자동차 동시에 이동 가능
+* [ ] 성공: 라운드 진행 시 이동 거리 누적
+* [ ] 실패: 자동차 리스트 비어있음 → `IllegalArgumentException`
+* [ ] 실패: 자동차 1대만 입력 → `IllegalArgumentException`
+* [ ] 실패: 이름 중복 → `IllegalArgumentException`
+
+---
+
+##### **RandomMovePolicyTest**
+
+* [ ] 성공: 랜덤값 4 이상이면 실제 정책에서 true 반환 가능
+
+---
+
+##### **WinnerCalculatorTest**
+
+* [ ] 성공: 최대 이동 거리 계산 및 우승자 필터링
+* [ ] 실패: 자동차 리스트 비어있음 → `IllegalArgumentException`
+
+---
+
+##### **RacingServiceTest**
+
+* [ ] 성공: 자동차 이름 입력 후 trim 적용
+* [ ] 성공: 양의 정수 경주 차수 설정 가능
+* [ ] 성공: 여러 라운드 후 위치 누적
+* [ ] 성공: 우승자 리스트 반환, 입력 순서 유지
+* [ ] 실패: 자동차 이름 입력 null/공백 → `IllegalArgumentException`
+* [ ] 실패: 경주 차수 0, 음수, 숫자 아님 → `IllegalArgumentException`
+
+---
+
+### 📑 구현 순서
+
+#### 1. 기본 세팅
+
+ - jdk 21 버전 설정
+ - java style guide 설정
+
+#### 2. 경주할 자동차 이름, 경주 차수 입력받기
+
+ - readLine() 으로 문자열 입력 받기
+ - 경주할 자동차 이름 및 경주 차수 입력 유효성 검사 (예외 발생 조건 참고)
+ - 경주할 자동차 이름의 경우 , 기준으로 분할해 입력 처리하기
+
+#### 3. 실행 결과 출력하기
+
+ - 0~9 사이의 수를 랜덤하게 뽑고, 4 이상인 경우 전진으로 처리(그 외는 멈춤으로 처리)
+ - 전진 여부 확인 후 전진한 횟수 증가시키기
+ - 경주 차수만큼 실행 결과 출력하기
+
+#### 4. 최종 우승자 발표하기
+
+ - 1명인 경우 그냥 출력 / 다수인 경우 , 로 구분하여 출력
+
+---
+
+### 📄 예시 입출력
+
+```
+경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
+pobi,woni,jun
+시도할 횟수는 몇 회인가요?
+3
+
+실행 결과
+pobi : -
+woni :
+jun : -
+
+pobi : --
+woni : -
+jun : --
+
+pobi : ---
+woni : --
+jun : ---
+
+최종 우승자 : pobi, jun
+```
+
+
diff --git a/src/main/java/racingcar/Application.java b/src/main/java/racingcar/Application.java
index a17a52e724..16ad1a47ff 100644
--- a/src/main/java/racingcar/Application.java
+++ b/src/main/java/racingcar/Application.java
@@ -1,7 +1,18 @@
package racingcar;
+import racingcar.controller.RacingController;
+import racingcar.domain.RandomMovePolicy;
+import racingcar.service.RacingService;
+import racingcar.view.InputView;
+import racingcar.view.OutputView;
+
public class Application {
public static void main(String[] args) {
- // TODO: 프로그램 구현
+ RacingController controller = new RacingController(
+ new InputView(),
+ new OutputView(),
+ new RacingService(new RandomMovePolicy())
+ );
+ controller.run();
}
}
diff --git a/src/main/java/racingcar/controller/RacingController.java b/src/main/java/racingcar/controller/RacingController.java
new file mode 100644
index 0000000000..481865d090
--- /dev/null
+++ b/src/main/java/racingcar/controller/RacingController.java
@@ -0,0 +1,48 @@
+package racingcar.controller;
+
+import racingcar.domain.Cars;
+import racingcar.dto.CarsDto;
+import racingcar.mapper.CarsMapper;
+import racingcar.service.RacingService;
+import racingcar.view.InputView;
+import racingcar.view.OutputView;
+
+public class RacingController {
+
+ private final InputView inputView;
+ private final OutputView outputView;
+ private final RacingService racingService;
+
+ public RacingController(InputView inputView, OutputView outputView, RacingService racingService) {
+ this.inputView = inputView;
+ this.outputView = outputView;
+ this.racingService = racingService;
+ }
+
+ public void run() {
+ // 입력 값 받아오기 (경주할 자동차 이름, 경주 차수)
+ String namesInput = inputView.readCarNames();
+ int roundCount = inputView.readRoundCount();
+
+ // 입력값 유효성 검증
+ RacingService.validateInputNamesIsNotNullAndEmpty(namesInput);
+ RacingService.validateInputRoundCountIsPlus(roundCount);
+
+ // 자동차 이름 값 검증 및 Cars 객체 생성
+ racingService.initCars(namesInput);
+
+ // 경주 차수에 따른 결과 출력
+ outputView.printRoundResultTitle();
+ for (int i = 0; i < roundCount; i++) {
+ // 변경된 cars 받아 dto로 변환
+ Cars cars = racingService.playRound();
+ CarsDto carsDto = CarsMapper.toDto(cars);
+
+ // 경주 라운드 실행 결과 출력
+ outputView.printRoundResult(carsDto);
+ }
+
+ // 경주 승자 출력
+ outputView.printWinners(racingService.getWinners());
+ }
+}
diff --git a/src/main/java/racingcar/domain/Car.java b/src/main/java/racingcar/domain/Car.java
new file mode 100644
index 0000000000..0411a934ca
--- /dev/null
+++ b/src/main/java/racingcar/domain/Car.java
@@ -0,0 +1,42 @@
+package racingcar.domain;
+
+import static racingcar.error.ErrorMessages.CAR_NAME_SIZE_INVALID;
+
+public class Car {
+ private final String name;
+ private final int location;
+
+ private static final int MAX_NAME_LENGTH = 5;
+
+ public Car(String name) {
+ validateName(name);
+ this.name = name;
+ this.location = 0;
+ }
+
+ private Car(String name, int location) {
+ this.name = name;
+ this.location = location;
+ }
+
+ private void validateName(String name) {
+ if (name == null || name.isBlank() || name.length() > MAX_NAME_LENGTH) {
+ throw new IllegalArgumentException(CAR_NAME_SIZE_INVALID.getMessage());
+ }
+ }
+
+ public Car move(boolean isMovable) {
+ if (isMovable) {
+ return new Car(name, location + 1);
+ }
+ return this;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getLocation() {
+ return location;
+ }
+}
diff --git a/src/main/java/racingcar/domain/Cars.java b/src/main/java/racingcar/domain/Cars.java
new file mode 100644
index 0000000000..e7442af7a2
--- /dev/null
+++ b/src/main/java/racingcar/domain/Cars.java
@@ -0,0 +1,53 @@
+package racingcar.domain;
+
+import static racingcar.error.ErrorMessages.CARS_ARE_EMPTY;
+import static racingcar.error.ErrorMessages.CARS_NAME_DUPLICATED;
+import static racingcar.error.ErrorMessages.CARS_SIZE_INVALID;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class Cars {
+ private final List cars;
+
+ public Cars(List cars) {
+ validateListNotNullAndEmpty(cars);
+ validateInputNameIsMoreThanOne(cars);
+ validateUniqueNames(cars);
+ this.cars = cars;
+ }
+
+ private static void validateListNotNullAndEmpty(List cars) {
+ if (cars == null || cars.isEmpty()) {
+ throw new IllegalArgumentException(CARS_ARE_EMPTY.getMessage());
+ }
+ }
+
+ private static void validateInputNameIsMoreThanOne(List cars) {
+ if (cars.size() < 2) {
+ throw new IllegalArgumentException(CARS_SIZE_INVALID.getMessage());
+ }
+ }
+
+ private static void validateUniqueNames(List cars) {
+ long distinctCount = cars.stream()
+ .map(Car::getName)
+ .distinct()
+ .count();
+
+ if (distinctCount != cars.size()) {
+ throw new IllegalArgumentException(CARS_NAME_DUPLICATED.getMessage());
+ }
+ }
+
+ public Cars moveAll(MovePolicy movePolicy) {
+ List moved = this.cars.stream()
+ .map(car -> car.move(movePolicy.canMove()))
+ .collect(Collectors.toList());
+ return new Cars(moved);
+ }
+
+ public List getCars() {
+ return cars;
+ }
+}
diff --git a/src/main/java/racingcar/domain/MovePolicy.java b/src/main/java/racingcar/domain/MovePolicy.java
new file mode 100644
index 0000000000..c5bd48cca3
--- /dev/null
+++ b/src/main/java/racingcar/domain/MovePolicy.java
@@ -0,0 +1,5 @@
+package racingcar.domain;
+
+public interface MovePolicy {
+ boolean canMove();
+}
diff --git a/src/main/java/racingcar/domain/RandomMovePolicy.java b/src/main/java/racingcar/domain/RandomMovePolicy.java
new file mode 100644
index 0000000000..89f72af3a9
--- /dev/null
+++ b/src/main/java/racingcar/domain/RandomMovePolicy.java
@@ -0,0 +1,14 @@
+package racingcar.domain;
+
+import racingcar.util.RandomGenerator;
+
+public class RandomMovePolicy implements MovePolicy {
+ private static final int MIN = 0;
+ private static final int MAX = 9;
+ private static final int MIN_MOVE_TRIGGER = 4;
+
+ @Override
+ public boolean canMove() {
+ return RandomGenerator.generate(MIN, MAX) >= MIN_MOVE_TRIGGER;
+ }
+}
diff --git a/src/main/java/racingcar/domain/WinnerCalculator.java b/src/main/java/racingcar/domain/WinnerCalculator.java
new file mode 100644
index 0000000000..fe2ff4386b
--- /dev/null
+++ b/src/main/java/racingcar/domain/WinnerCalculator.java
@@ -0,0 +1,21 @@
+package racingcar.domain;
+
+import static racingcar.error.ErrorMessages.CARS_ARE_EMPTY;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class WinnerCalculator {
+
+ public static List calculateWinners(Cars cars) {
+ int maxLocation = cars.getCars().stream()
+ .mapToInt(Car::getLocation)
+ .max()
+ .orElseThrow(() -> new IllegalArgumentException(CARS_ARE_EMPTY.getMessage()));
+
+ return cars.getCars().stream()
+ .filter(car -> car.getLocation() == maxLocation)
+ .map(Car::getName)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/racingcar/dto/CarDto.java b/src/main/java/racingcar/dto/CarDto.java
new file mode 100644
index 0000000000..d3def93c96
--- /dev/null
+++ b/src/main/java/racingcar/dto/CarDto.java
@@ -0,0 +1,5 @@
+package racingcar.dto;
+
+public record CarDto(String name, int location) {
+}
+
diff --git a/src/main/java/racingcar/dto/CarsDto.java b/src/main/java/racingcar/dto/CarsDto.java
new file mode 100644
index 0000000000..b2cafdfc00
--- /dev/null
+++ b/src/main/java/racingcar/dto/CarsDto.java
@@ -0,0 +1,14 @@
+package racingcar.dto;
+
+import java.util.List;
+
+public record CarsDto(List carDtoList) {
+ public CarsDto {
+ // 불변 리스트로 복사해서 저장
+ carDtoList = List.copyOf(carDtoList);
+ }
+
+
+}
+
+
diff --git a/src/main/java/racingcar/error/ErrorMessages.java b/src/main/java/racingcar/error/ErrorMessages.java
new file mode 100644
index 0000000000..46eaabf511
--- /dev/null
+++ b/src/main/java/racingcar/error/ErrorMessages.java
@@ -0,0 +1,23 @@
+package racingcar.error;
+
+
+public enum ErrorMessages {
+
+ ROUND_COUNT_INPUT_IS_NOT_PLUS("경주 차수는 양수여야 합니다."),
+ ROUND_COUNT_INPUT_MUST_VALID_NUMBER("경주 차수는 숫자여야 합니다."),
+ CAR_NAME_INPUT_IS_EMPTY("자동차 이름이 입력되지 않았습니다."),
+ CAR_NAME_SIZE_INVALID("자동차 이름은 1~5자여야 합니다."),
+ CARS_ARE_EMPTY("자동차 목록이 비어있습니다."),
+ CARS_SIZE_INVALID("자동차 이름은 두 개 이상이어야 합니다."),
+ CARS_NAME_DUPLICATED("자동차 이름이 중복됩니다.");
+
+ private final String message;
+
+ ErrorMessages(String message) {
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/racingcar/mapper/CarsMapper.java b/src/main/java/racingcar/mapper/CarsMapper.java
new file mode 100644
index 0000000000..51fe7e1b9c
--- /dev/null
+++ b/src/main/java/racingcar/mapper/CarsMapper.java
@@ -0,0 +1,15 @@
+package racingcar.mapper;
+
+import java.util.List;
+import racingcar.domain.Cars;
+import racingcar.dto.CarDto;
+import racingcar.dto.CarsDto;
+
+public class CarsMapper {
+ public static CarsDto toDto(Cars cars) {
+ List carDtoList = cars.getCars().stream()
+ .map(car -> new CarDto(car.getName(), car.getLocation()))
+ .toList();
+ return new CarsDto(carDtoList);
+ }
+}
diff --git a/src/main/java/racingcar/service/RacingService.java b/src/main/java/racingcar/service/RacingService.java
new file mode 100644
index 0000000000..a72a0a58d5
--- /dev/null
+++ b/src/main/java/racingcar/service/RacingService.java
@@ -0,0 +1,51 @@
+package racingcar.service;
+
+import static racingcar.error.ErrorMessages.CAR_NAME_INPUT_IS_EMPTY;
+import static racingcar.error.ErrorMessages.ROUND_COUNT_INPUT_IS_NOT_PLUS;
+
+import java.util.Arrays;
+import java.util.List;
+import racingcar.domain.Car;
+import racingcar.domain.Cars;
+import racingcar.domain.MovePolicy;
+import racingcar.domain.WinnerCalculator;
+
+public class RacingService {
+ public static final String NAME_SEPARATOR = ",";
+ private Cars cars;
+ private final MovePolicy movePolicy;
+
+ public RacingService(MovePolicy movePolicy) {
+ this.movePolicy = movePolicy;
+ }
+
+ public void initCars(String namesInput) {
+ List cars = Arrays.stream(namesInput.split(NAME_SEPARATOR))
+ .map(String::trim)
+ .map(Car::new)
+ .toList();
+
+ this.cars = new Cars(cars);
+ }
+
+ public static void validateInputNamesIsNotNullAndEmpty(String namesInput) {
+ if (namesInput == null || namesInput.isBlank()) {
+ throw new IllegalArgumentException(CAR_NAME_INPUT_IS_EMPTY.getMessage());
+ }
+ }
+
+ public static void validateInputRoundCountIsPlus(int roundCount) {
+ if (roundCount <= 0) {
+ throw new IllegalArgumentException(ROUND_COUNT_INPUT_IS_NOT_PLUS.getMessage());
+ }
+ }
+
+ public Cars playRound() {
+ cars = cars.moveAll(movePolicy);
+ return cars;
+ }
+
+ public List getWinners() {
+ return WinnerCalculator.calculateWinners(cars);
+ }
+}
diff --git a/src/main/java/racingcar/util/RandomGenerator.java b/src/main/java/racingcar/util/RandomGenerator.java
new file mode 100644
index 0000000000..10430ba0ab
--- /dev/null
+++ b/src/main/java/racingcar/util/RandomGenerator.java
@@ -0,0 +1,10 @@
+package racingcar.util;
+
+import camp.nextstep.edu.missionutils.Randoms;
+
+public class RandomGenerator {
+
+ public static int generate(int min, int max) {
+ return Randoms.pickNumberInRange(min, max);
+ }
+}
diff --git a/src/main/java/racingcar/view/InputView.java b/src/main/java/racingcar/view/InputView.java
new file mode 100644
index 0000000000..8f9f3c3a63
--- /dev/null
+++ b/src/main/java/racingcar/view/InputView.java
@@ -0,0 +1,25 @@
+package racingcar.view;
+
+import static racingcar.error.ErrorMessages.ROUND_COUNT_INPUT_MUST_VALID_NUMBER;
+import static racingcar.view.ViewMessages.INPUT_VIEW_QUESTION_CAR_NAMES;
+import static racingcar.view.ViewMessages.INPUT_VIEW_QUESTION_ROUND_COUNT;
+
+import camp.nextstep.edu.missionutils.Console;
+
+public class InputView {
+
+ public String readCarNames() {
+ System.out.println(INPUT_VIEW_QUESTION_CAR_NAMES);
+ return Console.readLine();
+ }
+
+ public int readRoundCount() {
+ System.out.println(INPUT_VIEW_QUESTION_ROUND_COUNT);
+ try {
+ return Integer.parseInt(Console.readLine());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(ROUND_COUNT_INPUT_MUST_VALID_NUMBER.getMessage(), e);
+ }
+ }
+
+}
diff --git a/src/main/java/racingcar/view/OutputView.java b/src/main/java/racingcar/view/OutputView.java
new file mode 100644
index 0000000000..70dc40f804
--- /dev/null
+++ b/src/main/java/racingcar/view/OutputView.java
@@ -0,0 +1,26 @@
+package racingcar.view;
+
+import static racingcar.view.ViewMessages.OUTPUT_VIEW_FINAL_RESULT_WINNER;
+import static racingcar.view.ViewMessages.OUTPUT_VIEW_FINAL_RESULT_WINNER_SEPARATOR;
+import static racingcar.view.ViewMessages.OUTPUT_VIEW_ROUND_RESULT_MOVE_SYMBOL;
+import static racingcar.view.ViewMessages.OUTPUT_VIEW_ROUND_RESULT_TITLE;
+
+import java.util.List;
+import racingcar.dto.CarsDto;
+
+public class OutputView {
+ public void printRoundResultTitle() {
+ System.out.println(OUTPUT_VIEW_ROUND_RESULT_TITLE.getMessage());
+ }
+
+ public void printRoundResult(CarsDto cars) {
+ cars.carDtoList().forEach(car -> System.out.println(car.name() + " : "
+ + OUTPUT_VIEW_ROUND_RESULT_MOVE_SYMBOL.getMessage().repeat(car.location())));
+ System.out.println();
+ }
+
+ public void printWinners(List winners) {
+ System.out.println(OUTPUT_VIEW_FINAL_RESULT_WINNER.getMessage() +
+ String.join(OUTPUT_VIEW_FINAL_RESULT_WINNER_SEPARATOR.getMessage(), winners));
+ }
+}
diff --git a/src/main/java/racingcar/view/ViewMessages.java b/src/main/java/racingcar/view/ViewMessages.java
new file mode 100644
index 0000000000..1d5b019943
--- /dev/null
+++ b/src/main/java/racingcar/view/ViewMessages.java
@@ -0,0 +1,22 @@
+package racingcar.view;
+
+
+public enum ViewMessages {
+
+ INPUT_VIEW_QUESTION_CAR_NAMES("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"),
+ INPUT_VIEW_QUESTION_ROUND_COUNT("시도할 횟수는 몇 회인가요?"),
+ OUTPUT_VIEW_ROUND_RESULT_TITLE("\n실행 결과"),
+ OUTPUT_VIEW_ROUND_RESULT_MOVE_SYMBOL("-"),
+ OUTPUT_VIEW_FINAL_RESULT_WINNER("최종 우승자 : "),
+ OUTPUT_VIEW_FINAL_RESULT_WINNER_SEPARATOR(", ");
+
+ private final String message;
+
+ ViewMessages(String message) {
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/src/test/java/racingcar/domain/CarTest.java b/src/test/java/racingcar/domain/CarTest.java
new file mode 100644
index 0000000000..6b64c6e55a
--- /dev/null
+++ b/src/test/java/racingcar/domain/CarTest.java
@@ -0,0 +1,45 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@DisplayName("Car 클래스 단위 테스트")
+class CarTest {
+
+ @Test
+ @DisplayName("성공: 이름 길이 1~5자면 생성 가능")
+ void 성공_이름_길이_1_5자_허용_생성_가능() {
+ assertThatCode(() -> new Car("pobi"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("성공: 이동 조건 충족 시 위치 1 증가")
+ void 성공_이동_조건_충족시_위치_증가() {
+ Car car = new Car("pobi");
+ Car moved = car.move(true);
+ assertThat(moved.getLocation()).isEqualTo(car.getLocation() + 1);
+ }
+
+ @Test
+ @DisplayName("성공: 이동 조건 미충족 시 위치 유지")
+ void 성공_이동_조건_미충족시_위치_유지() {
+ Car car = new Car("pobi");
+ Car stayed = car.move(false);
+ assertThat(stayed.getLocation()).isEqualTo(car.getLocation());
+ }
+
+ @ParameterizedTest(name = "이름이 \"{0}\"이면 IllegalArgumentException 발생")
+ @ValueSource(strings = {"", " ", "abcdef"})
+ @DisplayName("실패: 이름이 공백이거나 5자 초과면 예외 발생")
+ void 실패_잘못된_이름_입력(String name) {
+ assertThatThrownBy(() -> new Car(name))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/src/test/java/racingcar/domain/CarsTest.java b/src/test/java/racingcar/domain/CarsTest.java
new file mode 100644
index 0000000000..aa5269f30c
--- /dev/null
+++ b/src/test/java/racingcar/domain/CarsTest.java
@@ -0,0 +1,61 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("Cars 클래스 단위 테스트")
+class CarsTest {
+
+ static Cars cars;
+
+ @BeforeAll
+ static void setup() {
+ cars = new Cars(List.of(new Car("a"), new Car("b")));
+ }
+
+ @Test
+ @DisplayName("성공: 여러 자동차 동시에 이동 가능")
+ void 성공_여러_자동차_동시에_이동_가능() {
+ MovePolicy movePolicy = () -> true;
+ Cars moved = cars.moveAll(movePolicy);
+ assertThat(moved.getCars()).allMatch(car -> car.getLocation() == 1);
+ }
+
+ @Test
+ @DisplayName("성공: 라운드 진행 시 이동 거리 누적")
+ void 성공_라운드_진행시_이동_거리_누적() {
+ Cars cars = new Cars(List.of(new Car("a"), new Car("b")));
+ MovePolicy movePolicy = () -> true;
+
+ cars = cars.moveAll(movePolicy);
+ cars = cars.moveAll(movePolicy);
+
+ assertThat(cars.getCars()).allMatch(car -> car.getLocation() == 2);
+ }
+
+ @Test
+ @DisplayName("실패: 자동차 리스트 비어있으면 예외 발생")
+ void 실패_자동차_리스트_비어있으면_예외() {
+ assertThatThrownBy(() -> new Cars(List.of()))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("실패: 자동차 1대만 입력하면 예외 발생")
+ void 실패_자동차_1대만_입력하면_예외() {
+ assertThatThrownBy(() -> new Cars(List.of(new Car("solo"))))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("실패: 자동차 이름 중복이면 예외 발생")
+ void 실패_자동차_이름_중복이면_예외() {
+ assertThatThrownBy(() -> new Cars(List.of(new Car("a"), new Car("a"))))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/src/test/java/racingcar/domain/RandomMovePolicyTest.java b/src/test/java/racingcar/domain/RandomMovePolicyTest.java
new file mode 100644
index 0000000000..3a1a877d06
--- /dev/null
+++ b/src/test/java/racingcar/domain/RandomMovePolicyTest.java
@@ -0,0 +1,20 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.RepeatedTest;
+
+@DisplayName("RandomMovePolicy 단위 테스트")
+class RandomMovePolicyTest {
+
+ @RepeatedTest(5)
+ @DisplayName("랜덤값 4 이상이면 실제 정책에서 true 반환 가능")
+ void 성공_랜덤값_4이상_정책_전진_확률검증() {
+ RandomMovePolicy policy = new RandomMovePolicy();
+ boolean result = policy.canMove();
+
+ // 4~9 값일 땐 true, 0~3 값일 땐 false — Random이므로 확률 테스트
+ assertThat(result).isIn(true, false);
+ }
+}
diff --git a/src/test/java/racingcar/domain/WinnerCalculatorTest.java b/src/test/java/racingcar/domain/WinnerCalculatorTest.java
new file mode 100644
index 0000000000..c2e886894f
--- /dev/null
+++ b/src/test/java/racingcar/domain/WinnerCalculatorTest.java
@@ -0,0 +1,31 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("WinnerCalculator 단위 테스트")
+class WinnerCalculatorTest {
+
+ @Test
+ @DisplayName("성공: 최대 이동 거리 계산 및 우승자 필터링")
+ void 성공_최대_이동_거리_계산_및_우승자_필터링() {
+ Car car1 = new Car("pobi").move(true).move(true);
+ Car car2 = new Car("jun").move(true);
+ Cars cars = new Cars(List.of(car1, car2));
+
+ List winners = WinnerCalculator.calculateWinners(cars);
+
+ assertThat(winners).containsExactly("pobi");
+ }
+
+ @Test
+ @DisplayName(" 실패: 자동차 리스트 비어있으면 예외 발생")
+ void 실패_자동차_리스트_비어있으면_예외() {
+ assertThatThrownBy(() -> WinnerCalculator.calculateWinners(new Cars(List.of())))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/src/test/java/racingcar/service/RacingServiceTest.java b/src/test/java/racingcar/service/RacingServiceTest.java
new file mode 100644
index 0000000000..702e55a221
--- /dev/null
+++ b/src/test/java/racingcar/service/RacingServiceTest.java
@@ -0,0 +1,82 @@
+package racingcar.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import racingcar.domain.Car;
+import racingcar.domain.Cars;
+
+@DisplayName("RacingService 단위 테스트")
+class RacingServiceTest {
+
+ @Test
+ @DisplayName("성공: 자동차 이름 입력 시 trim 적용")
+ void 성공_자동차_이름_입력후_trim_적용() {
+ RacingService service = new RacingService(() -> true);
+ service.initCars(" pobi , jun ");
+
+ List carNames = service.playRound().getCars()
+ .stream()
+ .map(Car::getName)
+ .toList();
+
+ assertThat(carNames).containsExactly("pobi", "jun");
+
+ }
+
+ @Test
+ @DisplayName("성공: 양의 정수 경주 차수 설정 가능")
+ void 성공_양의_정수_경주_차수_설정_가능() {
+ assertThatCode(() -> RacingService.validateInputRoundCountIsPlus(3))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("성공: 여러 라운드 후 위치 누적")
+ void 성공_여러_라운드_후_위치_누적() {
+ RacingService service = new RacingService(() -> true);
+ service.initCars("pobi,jun");
+
+ service.playRound();
+ Cars result = service.playRound();
+
+ assertThat(result.getCars()).allMatch(car -> car.getLocation() == 2);
+ }
+
+ @Test
+ @DisplayName("성공: 우승자 리스트 반환 및 입력 순서 유지")
+ void 성공_우승자_리스트_반환_및_입력_순서_유지() {
+ RacingService service = new RacingService(() -> true);
+ service.initCars("pobi,jun");
+ service.playRound();
+ List winners = service.getWinners();
+
+ assertThat(winners).containsExactly("pobi", "jun");
+ }
+
+ @ParameterizedTest(name = "입력값 \"{0}\"이면 IllegalArgumentException 발생")
+ @NullAndEmptySource
+ @DisplayName("실패: 자동차 이름 입력 null 또는 공백이면 예외 발생")
+ void 실패_자동차_이름_입력_null_또는_공백(String input) {
+ assertThatThrownBy(() -> RacingService.validateInputNamesIsNotNullAndEmpty(input))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+
+ @ParameterizedTest(name = "입력값 \"{0}\"이면 IllegalArgumentException 발생")
+ @ValueSource(strings = {"0", "-1", "abc"})
+ @DisplayName("실패: 경주 차수가 0, 음수, 숫자 아님이면 예외 발생")
+ void 실패_경주_차수_잘못된_입력(String input) {
+ assertThatThrownBy(() -> {
+ int roundCount = Integer.parseInt(input); // 숫자 변환
+ RacingService.validateInputRoundCountIsPlus(roundCount);
+ }).isInstanceOf(IllegalArgumentException.class);
+ }
+}