본문 바로가기
우아한테크코스 4기/레벨1

[Java] 인터페이스로 분리하여 객체 테스트하기

by 나는후니 2022. 2. 13.

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 ~ ), 최범균 저