코드 리뷰 피드백 1. 자바 코드 컨벤션 - 연산자 양쪽에 공백 사용 - 불필요한 빈칸 띄어쓰기 없애기 (Google Java Style Guide 4.6.2 Horizontal whitespace 참고) → 반영 ⭕️ 2. validator에서 이름 관례 - check는 단지 상태를 확인하는 용도로 사용 - void 메서드의 경우 validate를 붙여서 사용 - 유효 여부만 판단할 경우 is를 붙여서 사용 → 반영 ⭕️ 3. 도메인 검증 - 도메인 관련 책임은 도메인에서 지기 → 코드 중복을 고려하여, 그대로 validator에서 책임지기로 함 (현행 유지) 4. 입출력 책임을 View로 분리 → 반영 ⭕️
1. 나에게 적용해보기
다른 사람들의 코드를 보니, 전체 테스트가 아니라, 계층별로 나눠서 테스트를 한것이 인상적이었다. 그래서 나도 이번에는 전체 테스트 대신 기능별로 테스트를 해보려고 한다.
오늘의 키워드는 "기능별 테스트"이다 ⭐️
추가로 이 과정에서 공통 피드백으로 주어진 디버깅을 활용해보겠다.
2. 개발 시작
2-0. 리드미 작성
우선 문제에서 주어진대로 리드미를 작성하였다.
기본적인 틀을 이정도로 잡고, 개발 과정에서 추가로 리드미를 업데이트 하도록 하겠다.
파일 구조
지난주와 달라진 점이 있다.
이번주는 입출력의 양이 많아 Controller 계층에서 사용자 입출력 관한 기능들을 View로 분리하였다. 또한 Service 계층에서 DTO를 도입하여, Controller로 전달할 데이터를 변환하는 기능을 분리하였다.
1) 랜덤 넘버 리스트 생성 기능 추가 2) 경주 게임 구현 기능 추가 3) 게임 결과를 담는 dto 추가
여기서 서술하는 Service는 리팩토링을 거친 최종 코드이다. [4.리팩토링] 에 가면 Service 코드가 탑다운 방식으로 리팩토링 된것을 확인할 수 있다.
랜덤 넘버 리스트 생성 기능 추가
Randoms.pickNumberInRange(0,9)는 외부 라이브러리이기 때문에 Domain이 아닌 Service에서 호출하였다.
Service에서 자동차 수만큼 랜덤 수를 뽑아서 리스트를 만든 후, Game 도메인에게 리스트를 전달하는 방식으로 사용하였다.
public List<Integer> pickRandomNumber(int number){
List<Integer> randomNumbers = new ArrayList<>();
for (int i = 1; i<=number; i++){
int randomNumber = Randoms.pickNumberInRange(0,9);
randomNumbers.add(randomNumber);
}
return randomNumbers;
}
경주 게임 구현 기능 추가
게임 도메인을 호출해서 게임을 반복 수행하도록 하였다.
// RacingService.java
public RacingDto getAllRoundGameResult(List<String> carNameList, Integer rounds){
List<Map<String, Integer>> result = new ArrayList<>();
Game game=Game.buildGame(carNameList);
for (int i = 1; i <= rounds; i++){
game.playRound(pickRandomNumber(carNameList.size()));
result.add(game.getGameResult());
}
List<String> winners = game.getWinners();
return new RacingDto(result, winners);
}
게임 결과를 담는 dto 추가
이 게임 결과를 RacingDto에 담아서 Controller에게 전달하였다.
public class RacingDto {
private List<Map<String,Integer>> gameHistory;
private List<String> winners;
public RacingDto(List<Map<String,Integer>> history, List<String> winner){
this.gameHistory = history;
this.winners = winner;
}
public List<Map<String,Integer>> getGameHistory(){
return gameHistory;
}
public List<String> getWinners(){
return winners;
}
}
2-9. Service 테스트 작성
원래는 아래와 같이 controller를 연결해서 Service 테스트를 했다.
근데 이렇게 하니까 servcie가 변경되면 controller까지 바꿔줘야 service test가 가능해져서, Controller와 분리하기로 하였다.
Service에서 직접 기능을 호출하여, 반환되는 값을 비교하였다.
package racingcar;
public class RacingServiceTest extends NsTest {
private static final int MOVING_FORWARD = 4;
private static final int STOP = 3;
@Test
void 게임_기록_기능_테스트() {
RacingService racingService=new RacingService();
assertRandomNumberInRangeTest(
() -> {
Map<String,Integer> result = racingService.getOneRoundGameResult(List.of("pobi","woni"));
Map<String,Integer> expected=new LinkedHashMap<>();
expected.put("pobi", 1);
expected.put("woni", 0);
System.out.println(result);
assertThat(result).containsExactlyEntriesOf(expected);
},
MOVING_FORWARD, STOP
);
}
@Override
public void runMain(){
RacingService racingService=new RacingService();
}
}
(나중에 DTO를 도입하면서, DTO값을 비교하는 코드로 변경되었다)
3. 테스트
각 계층별 테스트에서 아래의 결과들을 확인했다.
1) Application : 전체 테스트 + 예외 테스트(아래 예외처리들 포함) 2) Domain : 객체 정상 생성 여부 확인, 도메인 검사 예외처리 3) Controller : 로직 안돌리는 입출력 확인, 입력값 검사 예외 처리 4) Service : dto 결과값 확인 (기능이 정상작동하는지 확인)
Application 테스트
Application은 전체 테스트이기 때문에 각 계층에서 수행한 테스트도 @ParameterizedTest를 이용해서 다시 한번 점검했다.
Domain 테스트
Domain은 runMain()없이 바로 객체 생성하여 테스트를 진행한다.
public class CarTest{
@Test
void 자동차_객체생성_테스트() {
assertThatCode(() -> Car.buildCar("pobi"))
.doesNotThrowAnyException();
}
}
Controller 테스트
Controller를 생성할 때, View와 Service 의존성을 주입하여 테스트한다.
public class RacingControllerTest extends NsTest {
@Test
void doRacingGame_자동차_빈문자열_예외_테스트() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("", "1"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Override
public void runMain(){
View view=new View();
RacingService racingService=new RacingService();
RacingController racingController = new RacingController(view,racingService);
racingController.doRacingGame();
}
}
지난주 과제 테스트에서는 전체 애플리케이션을 실행시켜, 입출력 값으로 테스트 결과를 냈었다. 그 결과, controller를 수정해야만 테스트가 가능했고, "도메인, 서비스 등의 계층만 따로 테스트할 수 없을까?"라는 궁금증이 생겼다. 그러던 중 1 주차 과제 코드 리뷰를 하면서 많은 사람들이 계층을 따로 분리하여 테스트 코드를 작성한 것을 보았다. 따라서 이번 주에는 "기능별 테스트"라는 키워드를 가지고 계층별로 나누어 테스트에 도전해 보았다.
기능별 테스트를 위해 코드를 리팩토링하는 과정에서, 클래스 간의 결합도가 자연스럽게 낮아지는 것을 체감하였다. 처음에는 Service에서 Game 도메인을 가지고, 그걸 Service 내의 다른 메서드에게 파라미터로 넘겨주며 기능을 수행하도록 설계했다. 하지만 이렇게 구성하고 나니, Service 테스트를 할 때도 Game 객체를 생성해서 넣어줘야 하고, Game 도메인이 변경되면 Service 테스트도 영향을 받았다. 그래서 Service에서 수행하는 일부 기능을 Game 도메인에게 책임을 넘겨서, Service에서는 단지 호출만 하도록 하였다. 리팩토링을 거치니 도메인과 Service의 결합도가 낮아지고, Service 단독 테스트가 가능해졌다. 그동안 Domain과 Service, Service와 Controller 간의 역할 분배를 어떻게 해야 하는지 계속 헷갈렸었는데, 테스트가 그걸 구분해 주는 하나의 척도가 될 수 있다는 걸 느꼈다.
Service에서 Controller로 데이터를 넘기는 과정에서도 많은 시간을 고민했다. Controller에서 도메인 객체를 생성하면 안 되고, service에서 도메인을 controller로 넘겨서도 안된다. 하지만 "경기 기록과 우승자라는 두 가지 결과를 controller에게 어떻게 전달할 것인가?"에 대해 고민한 끝에, Dto를 도입하였다. 과제를 시작하기 전까지만 해도 "웹 개발이 아닌데 dto가 왜 필요하지?"라는 의문이 있었는데, dto는 웹 개발을 위해 존재하는 것이 아니라 Service에서 Controller로 데이터를 한 번에 전달할 수 있는 효과적인 방법이라는 사실을 깨달았다. 경기 기록은 List<Map> 타입, 우승자는 List<String> 타입으로, 둘의 데이터 타입이 달라서 Service에서 두 데이터를 하나로 묶는 것이 어려웠는데, 서로 상이한 타입을 Dto 클래스의 필드로 두니 수월하게 데이터를 묶을 수 있었다.
또한 공통 피드백으로 나온 디버깅을 이용해서 빈 문자열 입력값 검사 테스트의 의문점을 해결하기도 하였다. 지난주 과제에서 빈 문자열을 입력했을 때, isEmpty()로 에러 처리를 시도했지만 NoSuchElementException 에러를 던지는 것을 확인했다. print 문으로도 값이 출력이 되지 않아 원인을 파악할 수 없었는데, 디버깅을 사용하여 코드 진행 상황을 파악할 수 있었다. 이번 과제에서는 두 개의 입력을 받는데, 같은 검증기 코드(빈 문자열 에러 처리)를 사용해도 빈 문자열이 앞인지 뒤인지에 따라 테스트 결과가 다르게 나오는 문제를 발견하였다. 디버깅 결과, 앞에 값이 없을 때는 변수에 ""이 할당되어 에러 처리가 되지만, 뒤에 값이 없을 때는 변수에 값이 할당되지 않고 Console.close()가 호출되는 것을 확인했다. step into 버튼을 통해 해당 메서드로 들어가니, 최종적으로 nextLine() 메서드에 도달하였고, 이 메서드에서 NoSuchElementException을 던진다는 것을 확인했다. 콘솔 출력으로도 확인하기 어려웠던 문제를 디버깅 기능을 통해 확인할 수 있었고, 이것 외에도 개발 과정에서 디버깅을 사용해 보니 매우 편리한 기능이라는 것을 깨달았다. 코드 진행 상황을 하나씩 따라갈 수 있어서, 테스트 실패 원인이 로직 코드 때문이 아니라 잘못 작성된 테스트 코드 때문이라는 사실도 발견할 수 있었다. 디버깅을 통해 빠르게 오류를 발견하고, 로직의 흐름을 파악할 수 있었다.
이번 주에는 계층별 테스트를 시도하며, 계층 간의 역할과 책임에 대해 더 명확히 구분할 수 있게 되었다. 도메인은 상태를 갖고 있는 객체로, 상태를 수정하고 조회하는 모든 기능은 도메인 내에서 수행되고 캡슐화돼야 한다. Service는 도메인에게 요청하고 값을 반환받을 뿐, 직접 도메인에 접근하면 안 된다. 또한 Service와 Controller 사이에서도 막연하게 사용하던 Dto에 대해, 왜 dto를 사용하게 되었는지 그 필요성을 직접 피부로 느껴볼 수 있었던 시간이었다.