우아한테크코스 6기 백엔드 프리코스 - 자동차 경주 게임

테스트 목록

앞으로 구현해야 할 테스트는 6개이다.

m2t01 - 자동차 이름은 5자 이하의 문자열이며 쉼표로 구분하여 입력받는다.
m2t02 - 이동 횟수는 숫자만을 입력받는다.
m2st01 - 정규표현식 학습 테스트
m2t03 - 이외의 값을 입력할 경우 IllegalArgumentException을 발생시키며 프로그램은 종료된다.
m2t04 - 우승한 자동차의 이름을 모두 출력한다. 한 개 이상인 경우 쉼표로 구분하여 출력한다.
m2t05 - 지정된 범위의(0부터 9까지) 난수를 생성할 수 있어야 한다.
m2t06 - 1회의 이동에서 생성된 난수가 4 이상인 경우에만 전진한다.
m2t07 - 입력받은 횟수 만큼의 전진이 끝난 뒤 가장 앞에 있는 자동차를 선택한다.

구현

m2t06

모든 자동차에 대해 1회 이동을 실시한다. 이 때 생성된 난수가 4 이상인 경우에만 전진한다. 이 테스트를 작성하기 위해서는 일단 자동차, 이동, 난수가 무엇인지 정의해야 한다.
난수는 앞의 m2t05 테스트에서 구현했으니 자동차와 이동에 대해 구현하면 된다.

m2t06

테스트를 통과하기 위해 아래와 같이 Car 클래스를 구현했다.

Car

테스트를 약간 수정했다. 구현하다 보니 Car 객체가 난수를 생성 함수를 호출하기 위해 GameRule에 대해 알아야 했다.

m2t06-2

m2t07

n회 전진 후 선두의 차량을 모두 구한다. 이동거리가 가장 긴 자동차들의 이름을 반환하는 클래스가 필요하다. 이 클래스의 이름은 Referee 이다.

m2t07-1

위의 테스트를 통과시키기 위해 Referee 클래스를 아래와 같이 작성했다. 이 클래스의 역할은 우승자들을 결정하는 것이다.

Referee

1번 전진할 때 이름의 길이만큼 전진하도록 Mock 객체를 설정하여 이름이 5자인 자동차들이 우승하도록 테스트를 작성했다.

m2t07-2

모든 자동차가 동일한 거리를 전진하도록 Mock 객체를 설정하여 모든 자동차가 우승하도록 작성했다.

m2t07-3

테스트 코드의 중복이 상당하다. ☺️

테스트코드 중복 제거

앞에서 작성한 m2t07 테스트의 중복을 제거해보자. 중복이 발생하는 부분은 다음과 같다.

  • GameRule 객체 생성
  • Referee 객체 생성
  • 경주에 참가할 Car 객체 생성
  • RandomNumberGenerator mock 객체 생성 & 해제

중복되는 작업을 @BeforeEach @AfterEach 메서드로 묶어 주었다.

before-after-each

Mock 객체는 모든 테스트를 실행하는 동안 변하지 않기 때문에 굳이 각각의 테스트마다 새로 만들고 해제하기를 반복할 필요가 없다. 하지만 Car 객체들과 Referee 객체의 상태는 각각의 테스트에서 변하기 때문에 매번 새로운 객체가 필요하다.

before-after-all

불필요한 초기화와 해제를 줄였더니 예상대로 테스트코드의 성능이 개선되었다.

before-after-all-performance

before-after-each-performance


아쉬운 점

m2t07 테스트 아쉬운점

Car 클래스의 moveForward 메서드의 구체적인 동작 방식을 숨기지 못했다. RandomNumberGenerator.pickRandomNumber 메서드의 반환값을 직접 설정해주고 있다.

m2t01

길이가 1이상, 5이하의 자동차 이름을 ,로 구분된 문자열 형태로 입력받는다.
자동차 이름을 표현하는 클래스입력을 받는 클래스가 필요하다.
아래의 테스트는 실패한다. actualNamesexpectedNames 속에는 같은 자동차 이름들이 들어가 있지만 서로 다른 List 인스턴스이기 때문이다. 즉 동등성에 대한 적절한 오버라이딩이 필요하다. List의 동등성을 오버라이딩 할 수는 없다. CarName을 감싸는 일급 컬렉션이 필요하다.
그런데 테스트를 통과하기 위해 모델을 변경하는게 맞는건지는 모르겠다… 라고 생각했는데 애초에 TDD가 테스트를 통과하기 위한 코드를 작성하면서 프로그램을 완성해가는데 안 될 것도 없다.

m2t01-failed

List<CarName>를 감싸는 CarRecord 클래스를 도입했다.

m2t01-2

대규모 리팩토링

CarRecord 일급 컬렉션을 도입하는 과정에서 대규모 리팩토링이 있었다…

related-problems

리팩토링을 하면서 이런 일(대규모 리팩토링)을 사전에 방지할 수는 없는 걸까 고민했다.

problems

문제가 된 건 대부분 위와 같은 형태였다. 위와 같은 형태란, 클라이언트 코드가 Car라는 구체적인 구현체에 의존하고 있는 것이다. 그렇기 때문에 Car 클래스가 수정되면 클라이언트 코드들이 민감하게 반응한다. 클라이언트 코드를 구체적인 클래스로부터 분리하여 클래스의 수정에 전체 코드가 둔감해지도록 만들자.

a-few-hours-later

후…🫠
많은 변화가 있었다. 하나씩 차근차근 되짚어보자.

CarFactory

Car 객체 생성에 팩토리 패턴을 적용했다. Car 객체 생성을 팩토리 메서드로 감싸 객체를 생성하는 구체적인 과정을 클라이언트 코드로부터 감췄다.
Car 객체의 생성자에는 CarName 객체를 전달해야 하지만 팩토리 메서드에는 String 인스턴스를 전달해도 객체가 정상적으로 생성된다.

CarFactory

RefereeFactory

Referee 객체 생성을 돕는다.

RefereeFactory

CarRecord

Car 컬렉션을 감싸는 일급 컬렉션이다. 내부적으로 List<Car>를 저장하고 있다. Car 객체 목록을 추상화한 것이며 Car는 경주에 참여한 자동차들의 상태를 추상화 한 것이다.
Car들에 대한 연산(상태 변경, 조회)을 제공한다.

CarRecord-1

CarRecord-2

아래는 CarRecord 객체를 생성하는 팩토리 클래스이다.

CarRecordFactory

리팩토링의 효과

m2t07 테스트 코드가 아래와 같이 간결해졌다. 그리고 모든 API는 통제 가능한(내가 작성한) 코드이다. 외부 API 변경에 대한 충격을 최소화 했다.
여러 군데에서 중복되던 mocked.when ... 부분을 setDistance 메서드로 묶고 CarRecord 객체로 모든 Car 객체에 한 번에 접근할 수 있게 되었기 때문이다.
모든 Car 객체가 CarRecord에 숨겨져 각각에 대해 움직일 거리를 지정해 줄 수는 없었지만 아래와 같은 방법으로 테스트를 구현할 수 있었다!

테스트 로직

  1. @BeforeEach에서 초기화된 Car 객체들을 3만큼 laps회 이동(4 미만이기 때문에 전진하지 않음)
  2. 5만큼 이동한 winner 객체를 carRecord에 추가
  3. carRecord에서 우승자를 선택

m2t07-after-refactoring

m2t01

그럼 이제 m2t01 테스트를 통과하기 위해 CarRecord 클래스를 구현해보자.

테스트 코드를 약간 수정했다. 중요한 점은 expectedNamesactualNames의 생성 로직이 다르다는 점이다. expectedNames는 이미 검증된 방식으로, actualNames는 테스트하고자 하는 로직으로 생성된 객체이다.

m2t01-완성

m2st01

m2t02를 진행하기 전에 정규 표현식을 학습하는 테스트를 작성했다.

regex 학습 테스트 1

regex 학습 테스트 2

m2t02

정상적인 입력에 대한 테스트이다.

m2t02

사용자의 입력을 NumberOfRepetition 클래스로 추상화했다.

NumberOfRepetitions

아래는 InputPrompt 클래스의 사용자 입력을 받는 메서드이다.

readNumberOfRepetitions

m2t03

올바르지 않은 입력값에 대해 IllegalArgumentException 예외를 던질 수 있어야 한다.

m2t03-1

m2t03-2

마무리

9시간 16분

reference

Comments