level 1 첫 미션으로 프리코스에서 진행한 racingGame
을 페어 프로그래밍으로 구현하는 미션을 받았다. TDD로 구현하라는 미션덕분에 많은 크루들이 Random
값을 테스트하는 방법, System.in
을 테스트하는 방법에 대해 궁금증을 품었고 그 중에서도 Random 값에 대한 테스트에 어려움을 겪었던 것 같다. 내 코드와 리뷰
나는 과거 Mockito
를 사용한 경험이 있어 Mockito
라이브러리를 가져와 임의의 랜덤값을 제공하는 가짜 객체를 만들어 테스트 했다.
MockRandomGenerator
Random 값을 가짜로 전달해줄 수 있는 임의의 추상 클래스를 생성하여 상속받아 테스트했다.
public abstract class MockRandomGenerator {
MockedStatic<RandomGenerator> mockRandom = Mockito.mockStatic(RandomGenerator.class);
// 전진 기준인 4를 return 하는 메서드
public void randomNumberOverFour() {
mockRandom.when(() -> RandomGenerator.getRandomNumberInRange(9)).thenReturn(4);
}
// 전진 하지 못하는 메서드
public void randomNumberBelowFour() {
mockRandom.when(() -> RandomGenerator.getRandomNumberInRange(9)).thenReturn(3);
}
// 사용한 가짜 객체를 닫아주는 메서드
public void closeMockRandom() {
mockRandom.close();
}
}
아래와 같이
MockRandomGenretor
를 상속받아 임의의 값을 넣어 테스트할 수 있다.
public class CarTest extends MockRandomGenerator {
@Test
void 자동차_전진_성공() {
Car car = new Car("jae");
randomNumberOverFour();
car.goForward(); // 이 메서드에 Random값을 generate하는 로직이 들어간다.
assertThat(car.getPosition()).isEqualTo(1);
}
@Test
void 자동차_포지션_출력_성공() {
Car car = new Car("jae");
randomNumberOverFour();
car.goForward();
assertThat(car.toString()).isEqualTo("jae : -");
}
@AfterEach
void stopMockRandom() {
closeMockRandom();
}
}
하지만 이 미션은 Java 미션으로 외부 프레임워크에 의존하기 보다는 순수 Java를 이용하여 미션을 수행하는 것이 그 목적이다. 우선 테스트한 내용대로 제출했지만 멘토에게 다음과 같은 리뷰를 받을 수 있었다.
문제 해결
문제 해결의 키워드는 격리
와 다형성
이다. Random
값을 발생시키는 로직을 테스트 하고자 하는 내용 과 격리시키는 것이다. 격리 전 기존 코드의 문제점을 먼저 살펴보자.
Step 1. 격리
Car 클래스의 일부이다. isCarGoForward 메서드 내에서 난수가 발생하지만, 사실 해당 메서드에서는 구현 요구 사항인 '4'를 넘는지만 확인하면 된다.
public void goForward() {
if (isCarGoForward()) {
position++;
}
}
private boolean isCarGoForward() {
return getRandomNumberInRange(MAX_RANDOM_RANGE) >= MIN_GO_FORWARD_RANGE;
}
해당 메서드에서 Random
을 격리해보자.
public void goForward(int number) {
if (isCarGoForward(number)) {
position++;
}
}
private boolean isCarGoForward(int number) {
return number >= MIN_GO_FORWARD_RANGE;
}
매개변수로 number를 받음으로써 이제 이 메서드는 number 이상이면 전진 으로 확실한 역할을 수행할 수 있게 됐다. 여기까지 리팩토링하고 테스트를 수정해보자.
CarTest 클래스의 자동차_전진_성공 메서드
@Test
void 자동차_전진_성공() {
Car car = new Car("jae");
// randomNumberOverFour(); Mocking 한 내용 제거
car.goForward(4); // 매개변수에 4를 넣음으로써 전진에 성공한 것을 확인할 수 있다.
assertThat(car.getPosition()).isEqualTo(1);
}
이제 격리를 통해 Mockito
라이브러리 없이 Test를 작성할 수 있게 됐다. 이제 Test와 구현 코드를 다형성을 이용하여 리팩토링해보자.
Step 2. 인터페이스
먼저 수를 발생시킨다는 의미의 인터페이스를 하나 생성하고 메서드를 만들어준다. 그리고 RandomNumberGenerator
클래스로 상속한다.
부모 클래스
public interface NumberGenerator {
int generate();
}
자식 클래스
public class RandomNumberGenerator implements NumberGenerator{
private static final int RANDOM_NUMBER_UPPER_BOUND = 10;
public int generate() {
Random random = new Random();
return random.nextInt(RANDOM_NUMBER_UPPER_BOUND);
}
}
그리고 이 다형성을 게임 진행시 사용할 수 있도록 RacingController
에서 의존성 주입을 시켜준다.
public class RacingController {
private NumberGenerator numberGenerator;
public RacingController(NumberGenerator numberGenerator) {
this.numberGenerator = numberGenerator;
}
public void play() {
//...
}
private String[] getRightName() {
//...
}
private int getRightNumber() {
//...
}
private void progressTurns(Cars cars, int trialCount) {
for (int i = 0; i < trialCount; i++) {
cars.moveCars(numberGenerator); // 이 지점에서 NumberGenerator 자식 클래스를 모두 매개변수로 넣어줄 수 있다.
printMessage(cars.toString());
}
}
}
참고. Cars 클래스의 moveCars 메서드
// NumberGenerator 하위 클래스를 모두 매개변수로 받는다.
public void moveCars(NumberGenerator generator) {
for (Car car : cars) {
car.goForward(generator.generate());
}
}
이제 구현 코드를 모두 리팩토링 하였으니 NumberGenerator
를 상속받는 대역 클래스를 만들어 cars.moveCars(numberGenerator)
메서드를 테스트 할 수 있다.
public class StubNumberGenerator implements NumberGenerator{
@Override
public int generate() {
return 4; // 이 가짜 랜덤 객체는 무조건 4를 리턴한다. 즉 전진할 수 있는 조건을 제공해준다.
}
}
StubNumberGenerator
클래스를 이용하여 아래와 같이 테스트 코드를 작성할 수 있다. 가짜 객체를 매개변수로 넣어 generate
메서드를 수행하면 4를 반환하기 때문에 전진한다. 고로 두 자동차 모두 전진하여 우승자는 두명으로 확인된다.
@Test
void 자동차_우승_2명_성공() {
Cars cars = new Cars(new String[]{"jae","rick"});
cars.moveCars(new StubNumberGenerator());
assertThat(cars.getWinners()).isEqualTo("jae, rick가 최종 우승 했습니다.");
}
정리
만약 많은 지점에서 RandomGenerator
가 사용중이었다면 이 코드를 리팩토링하는 것이 정말 어려웠을 것 같다. 하지만 이번 리뷰와 리팩토링을 진행하면서 라이브러리에 의존하여 테스트를 하는 것이 구조적 문제점을 야기하고 테스트의 정확도를 낮출 수 있다는 생각이 확고해졌다.
테스트를 하는 데 있어 개발자가 다룰 수 없는 부분은 없다고 생각한다. 격리와 다형성을 통해 라이브러리에 의존하지 않고 TDD를 달성하는 것이 좋은 코드를 짜는 방법 중 하나라고 생각한다.
참고
https://aroundck.tistory.com/6108
https://tecoble.techcourse.co.kr/post/2020-05-17-appropriate_method_for_test_by_interface/
테스트 주도 개발 시작하기 Chapter 7. 대역 (140p ~ ), 최범균 저
'우아한테크코스 4기 > 레벨1' 카테고리의 다른 글
[Java] Java 예외처리와 StackOverFlow (0) | 2022.02.21 |
---|---|
[Java] Enum타입 사용하기 (1) | 2022.02.19 |
[Java] Mockito를 사용하여 TDD 적용해보기 (0) | 2022.02.17 |
[Java] JUnit으로 System.in, System.out 테스트하기 deep dive (2) | 2022.02.16 |
[Java] 대역으로 Junit 테스트 코드 작성하기 (0) | 2022.02.15 |