본문 바로가기
Software Engineering

테스트 하기 어려운 코드! 어떻게 해야하지?(랜덤에 대한 테스트)

by nak_honest 2023. 11. 2.

테스트 코드 ^_^

 

프리코스 2주차 미션부터는 각 기능에 대한 테스트 코드를 작성해야 했다.
이에 따라 JUnit5AssertJ를 학습하고, TDD를 적용해보려 노력했다.

 

사실 이 라이브러리를 학습하고, 테스트 코드를 작성하는 것만해도 그리 쉬운일이 아니었다.
그래도 다른 분들의 야구게임 코드를 리뷰하면서 잘 작성하신 테스트 코드를 많이 보았기 때문에, 테스트 코드를 작성하는 방법 자체는 빠르게 학습할 수 있었다. (목객체 만들어서 테스트 코드도 작성하시던데 이건 아직 어떻게 하는건지 잘 모르겠다 ㅠㅠ)

 

 

하지만 정말정말 어려웠던 부분은 테스트 라이브러리 자체가 아니라, 랜덤에 대한 테스트였다.

자동차가 움직일지 결정하기 위해서는 랜덤으로 숫자를 선택해야 하는데, 이 랜덤 숫자에 대한 단위 테스트가 어려웠기 때문이다.

 

여러 검색을 해보니 목 객체라는 것을 이용하면 테스트가 가능하다고는 한다.

하지만 더 근본적으로 프로덕션 코드 자체가 테스트하기 어려운 코드라면, 냄새나는 코드일 확률이 높다고 한다는 글을 만날 수 있었다.

 

https://jojoldu.tistory.com/674?category=1036934

 

1. 테스트하기 좋은 코드 - 테스트하기 어려운 코드

팀 분들과 함께 NextStep - 이펙티브 코틀린 강좌를 수강하고 있다. 최근에 과제 회고를 처음 진행했는데, 이때 나온 주제가 테스트 하기 좋은 코드였다. 이 주제는 사실 이미 너무 많이 회자된 주

jojoldu.tistory.com

 

위의 글 내용을 바탕으로 일단 Randoms.pickNumberInRange()는 내가 제어할 수 없는 것임을 깨달을 수 있었다.
그리고 이 제어할 수 없는 것이 도메인 내부 깊숙히 들어가 있을 수록 테스트하기 어렵다는 것도 깨달을 수 있었다.

 

 

냄새나는 이전 코드

만약 테스트하기 좋은 코드를 고려하지 않고 코드를 짰다면 다음과 같았을 것이다.

public class Car {
    private final CarPosition carPosition;

    ...


    public void drive() {
        carPosition.move(determineDistance());
    }


    private int determineDistance() {
        int randomNumber = Randoms.pickNumberInRange(0, 9);
        if (randomNumber >= 4) {
            return 1;
        }

        return 0;
    }
}

 

이 코드에서는 랜덤 숫자를 생성하는 로직이 Car 라는 도메인 내부에 들어 있다.

경우에 따라서는 CarPosition에서 랜덤 숫자를 생성할 수도 있고, 아예 랜덤 숫자 생성 로직을 외부로 분리할 수도 있을 것이다.

 

하지만 근본적으로 위에서 어떤 방식을 택하든, 랜덤 숫자가 어떤 숫자를 반환할지는 우리가 제어할 수 없다.
즉, 자동차가 움직이는 것에 대한 테스트를 작성할때 어떤 랜덤 숫자가 생성될지 알 수 없기 때문에 자동차의 움직임을 제어할 수 없게 된다.

 

그리고 Car에 대해 테스트가 어려워지면, 이 Car를 감싸는 Cars도 테스트하기 어려워지게 된다.

 

즉 테스트하기 어려운 코드가 도메인 깊숙히 자리잡으면 그 외부에 있는 객체들도 모조리 테스트하기 어려워진다는 것이다.

 

 

제어하기 어려운 코드를 분리하자

먼저는 이동욱 개발자님의 블로그 글을 보면서, 자동차를 움직이는 로직을 외부에서 주입 받아야겠다는 생각을 했다.


또한 "헤드퍼스트 디자인 패턴" 책을 보고 있었는데, 전략패턴을 사용하면 자동차의 움직임을 쉽게 제어할 수 있겠다는 생각이 들었다.

전략패턴을 사용하면 자동차의 움직임을 이제 더 이상 자동차가 결정하지 않고, 외부에서 결정해 줄 수 있기 때문이었다.

 

 

Car가 더이상 Randoms.pickNumberInRange(0, 9) 에 의존하지 않게 된다는 것이다.

그리고 테스트에 대해 생각하지 않더라도 자동차의 본질을 생각했을 때 자동차는 랜덤 숫자와 직접적인 연관이 없다고 생각하였다.

따라서 다음과 같은 인터페이스를 먼저 정의하였다.

 

public interface DriveStrategy {
    int determineDistance();
}

 

사실 처음부터 위와 같은 형태로 만든 것은 아니었고, 여러 고민과 많은 시도를 하다가 위와 같은 인터페이스를 정의하게 되었다.

(처음부터 너무 완벽한 걸 짜려하지 말자!!)

 

그리고 Car는 위의 인터페이스 타입의 객체를 생성자로 주입받는 방식을 택하였다.

public class Car {
    private final DriveStrategy driveStrategy;
    private final CarPosition carPosition;

    public Car(DriveStrategy driveStrategy, CarPosition carPosition) {
        this.driveStrategy = driveStrategy;
        this.carPosition = carPosition;
    }

    public void drive() {
        carPosition.move(driveStrategy.determineDistance());
    }
    ...
}

 

위와 같이 인터페이스로 자동차의 움직임을 추상화하니 이제 자동차의 움직임은 쉽게 제어할 수 있게 되었다.

 


그리고 다음과 같이 테스트 코드를 작성하는 것도 가능해졌다.

public class CarTest {
    @ParameterizedTest
    @ValueSource(ints = {0, 1, 10, 100})
    void 자동차를_움직일_수_있다(int distance) {
        // given
        Car car = new Car(() -> distance, new CarPosition(0));

        // when
        car.drive();

        // then
        assertThat(car.getPosition()).isEqualTo(distance);
    }
}

 

DriveStrategy는 함수형 인터페이스이기 때문에, 람다를 통해 쉽게 자동차의 움직임을 테스트할 수 있게 되었다.

 

그런데 정말 신기한 것은 테스트하기 좋은 코드를 만들기 위해 노력을 했더니, Car에 대한 확장성과 유연성이 증가했다는 것이다.

실제로 이제 자동차를 움직이는 로직이 변경된다고 해도(랜덤 숫자로 뽑은 숫자만큼 이동한다던지, 사용자가 입력한 숫자만큼 이동한다던지, ...) 외부에서 DriveStrategy를 구현한 뒤 주입 해주기만 하면 되기 때문이다.

 

 

 

랜덤도 마찬가지!!

위처럼 자동차의 움직임에 대한 부분은 해결하였지만 여전히 문제 하나가 남아있었는데, 그것은 바로 Randoms.pickNumberInRange(0, 9) 가 여전히 테스트하기 어렵다는 것이었다.


그런데 이전에 이미 많은 고민을 거쳤기 때문인지, 이번에는 생각보다 금방 구현할 수 있게되었다.

DriveStrategy 를 구현하는 RandomDriveStrategy가 랜덤 숫자 생성 로직을 마찬가지로 외부에서 주입받도록 만들면 되겠다고 금방 떠올릴 수 있었다.

 

 

하지만 이번에는 인터페이스를 따로 작성하지 않았다.

"이펙티브 자바 아이템 44 - 표준 함수형 인터페이스를 사용하라" 를 보면서 단순히 랜덤 숫자를 생생하는 로직은 IntSupplier라는 표준 함수형 인터페이스를 사용하는 것이 좋겠다는 생각이 들었기 때문이다.

 

따라서 다음과 같이 RandomDriveStrategy를 작성하였다.

public class RandomDriveStrategy implements DriveStrategy {
    private final IntSupplier randomGenerator;

    public RandomDriveStrategy(IntSupplier randomGenerator) {
        this.randomGenerator = randomGenerator;
    }

    @Override
    public int determineDistance() {
        int randomNumber = randomGenerator.getAsInt();
        if (randomNumber >= 4) {
            return 1;
        }

        return 0;
    }

    public static int generateRandomNumber() {
        return Randoms.pickNumberInRange(0, 9);
    }
}

 

이렇게 랜덤 숫자를 생성하는 로직을 외부에서 주입 받도록 변경하니, 마찬가지로 테스트 코드를 작성하는 것이 가능해졌다.

 

public class RandomDriveStrategyTest {
    @ParameterizedTest
    @ValueSource(ints = {4, 5, 6, 7, 8, 9})
    void 무작위_값이_4_이상인_경우_자동차를_전진한다(int randomNumber) {
        // given
        DriveStrategy driveStrategy = new RandomDriveStrategy(() -> randomNumber);
        CarPosition carPosition = new CarPosition(0);
        int oldPosition = carPosition.getPosition();

        // when
        carPosition.move(driveStrategy.determineDistance());

        // then
        int newPosition = carPosition.getPosition();
        assertThat(newPosition).isEqualTo(oldPosition + 1);
    }

    @ParameterizedTest
    @ValueSource(ints = {0, 1, 2, 3})
    void 무작위_값이_4_미만인_경우_자동차를_전진하지_않는다(int randomNumber) {
        // given
        DriveStrategy driveStrategy = new RandomDriveStrategy(() -> randomNumber);
        CarPosition carPosition = new CarPosition(0);
        int oldPosition = carPosition.getPosition();

        // when
        carPosition.move(driveStrategy.determineDistance());

        // then
        int newPosition = carPosition.getPosition();
        assertThat(newPosition).isEqualTo(oldPosition);
    }

    @ParameterizedTest
    @ValueSource(ints = {-1, -2, 10, 11})
    void 무작위_값이_0애서_9_사이의_숫자가_아니라면_예외를_발생시킨다(int randomNumber) {
        // given
        DriveStrategy driveStrategy = new RandomDriveStrategy(() -> randomNumber);
        CarPosition carPosition = new CarPosition(0);

        // when, then
        assertThatThrownBy(() -> carPosition.move(driveStrategy.determineDistance()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("Error: 무작위 값은 0에서 9 사이의 숫자여야 합니다.");
    }
}

 

테스트 코드를 작성하기 쉬워지니, 동시에 외부에서 랜덤 숫자를 생성하는 방식을 선택할 수 있게 되었다.


그리고 무엇보다 더이상 Randoms라는 외부 API에게도 의존하지 않을 수 있게 되었다. 만약 이후에 외부 API가 변경되더라도 RandomDriveStrategy는 변경된 API를 주입받기만 하면 되는 확장성을 얻을 수 있게 되었다.
(Randoms 뿐만 아니라 외부 API인 Console 또한 View가 직접 의존하지 않고 외부에서 주입받도록 구현하였다.)

 

 

 

실제로 작성한 코드는 살짝 다르니 참고해 주셔도 좋을 것 같습니다 :)

https://github.com/woowacourse-precourse/java-racingcar-6/pull/1538#discussion_r1379550567

 

[자동차 경주] 이낙헌 미션 제출합니다. by nak-honest · Pull Request #1538 · woowacourse-precourse/java-racingcar

PR 타입 미션 제출 반영 브랜치 nak-honest/nak-honest -> woowacourse-precourse/main 상세 내용 테스트하기 어렵고, 제어할 수 없는 부분을 외부에서 주입하도록 구현하였습니다. 자동차의 움직임을 외부에서

github.com

 

 

마무리

이번 2주차는 테스트에서 시작해 테스트로 끝났다고 해도 무방할 것 같다.


테스트 라이브러리 학습부터, 테스트하기 좋은 코드로 만들기 위해 수없이 고민하고, 코드를 갈아 엎었던 것 같다.
이를 통해 처음으로 인터페이스도 사용해보고, 많은 성장을 할 수 있게 되었다고 생각한다.

 

그리고 테스트 코드는 또 하나의 문서이기 때문에, 리팩토링을 통해 가독성을 높이려 노력했다.

 

만약 이번에 미션을 진행하면서 테스트가 어려웠다면, 인터페이스를 통해 추상화하여 외부에서 주입 받도록 구현할 수는 없는지 한번 고민해 보아도 좋을 것 같다.

 


그리고 이동욱 개발자님의 블로글과 함께 다음 영상도 참고하면 좋을 것 같아 링크를 첨부한다!

https://www.youtube.com/watch?v=DJCmvzhFVOI

 

 

 

출처

- https://jojoldu.tistory.com/674

- https://www.youtube.com/watch?v=DJCmvzhFVOI

- 헤드퍼스트 디자인 패턴(에릭 프리먼) : ch01 전략패턴

- 이펙티브 자바(조슈아 블로크) : 아이템 44 - 표준 함수형 인터페이스를 사용하라