랜덤 값 테스트하기

송선권·2024년 10월 29일
3

개발 지식

목록 보기
2/3
post-thumbnail

배경

프리코스 2주차 과제에서는 랜덤 값이 얼마가 나오느냐에 따라 자동차가 전진할지 정지할지를 정해야 한다. 그리고 TDD를 적용해보고자 한 나는 이 부분에 대한 테스트를 작성하고 싶었다. 하지만 랜덤 값을 어떻게 테스트할 수 있을까?

정말 애매한 부분이고 정답은 없다고 생각하는데, 내 경우에는 이 상황을 어떻게 풀어냈는지 공유하고자 한다.

사례 조사

인터넷을 찾아보니 이런 게시글(Tecoble)을 확인할 수 있었다. 간단하게 요약하면 다음과 같다.

// before
public void move() {
    final int number = random.nextInt(RANDOM_NUMBER_UPPER_BOUND);

    if (number >= MOVABLE_LOWER_BOUND) {
        position++;
    }
}

테스트하고자 하는 위 메서드가 있다. 이 메서드는 내부에서 랜덤 값을 임의로 추출하여 그 수치를 기반으로 전진 여부를 결정한다. 이 전진 여부 판정이 올바르게 동작하고 있는지 테스트하려면 어떻게 해야 할까? 테스트 코드에서 move()를 그대로 호출하면 랜덤 값을 의도한 대로 테스트하기 힘들어 보인다.

// after
public void move(int number) {
    if (number >= MOVABLE_LOWER_BOUND) {
        position++;
    }
}

이 상황은 메서드 시그니처를 수정하여 개선할 수 있다. 기존에 메서드 내에서 랜덤 값을 추출하여 사용하던 number 변수를 메서드 외부에서 랜덤 값 추출 후 매개변수를 통해 주입하는 방향으로 변경하는 것이다.

이렇게 하면 비즈니스 로직 상에서는 랜덤 값을 추출하여 move()에 전달함으로써 로직을 수행할 수 있고, 테스트 코드에서는 테스트하고자 하는 임의의 값을 move()에 직접 전달함으로써 랜덤 값을 테스트할 수 있게 된다.

출처: 메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기 - Tecoble

음. 확실히 좋은 방법인 것 같다! 이대로 작성해보면 실제로 잘 동작한다. 문제해결! 해피엔딩!

...일까?

이게 최선인가?

사실 내 코드는 찾아보기 전부터 이미 위의 코드와 동일한 방향으로 작성되어 있었다. 그래서 테스트도 나름 쉽게 작성할 수 있었다. 아래는 내가 기존에 작성한 코드이다.

public class Position {
    // ...
    
    public void playRound() {  
        move(RandomUtil.pickCarNumber());  
    }  
      
    protected void move(int carNumber) {  
        if (carNumber >= MOVE_STANDARD) {  
            position += DEFAULT_MOVE_DISTANCE;  
        }  
    }
}

이게 최선이라고 생각하는가? 이 코드를 보고 정말로 꺼림칙한 부분이 없다고 확신할 수 있는가?

잘 보면 move()protected로 정의되어 있다. 이 메서드는 외부에서 호출되지 않기에 원래는 private이어야 하는데 테스트 코드에서 접근하기 위해 protected로 접근제어를 열어준 것이다.

결국 랜덤 값의 테스트에는 성공했지만 테스트를 위한 비즈니스 코드의 변경에는 대응하지 못했다고 볼 수 있다. 이 상황은 어떻게 개선할 수 있을까?

전략 패턴

당장 떠오른 해결책은 전략 패턴을 활용하는 것이었다.

개념

전략 패턴은 특정 기능에 대해 여러 알고리즘 중 하나가 선택되어 사용될 수 있는 경우 각 알고리즘을 클래스화하고 이들을 interface로 묶어 알고리즘 교체를 용이하게 해주는 디자인 패턴이다. 객체지향 프로그래밍을 열심히 공부했다면 코드만 봐도 바로 이해할 수 있을 것이다.

// 전략(추상화된 알고리즘)
interface IStrategy {
    void doSomething();
}

// 전략 알고리즘 A
class ConcreteStrateyA implements IStrategy {
    public void doSomething() {}
}

// 전략 알고리즘 B
class ConcreteStrateyB implements IStrategy {
    public void doSomething() {}
}

출처: 전략(Strategy) 패턴 - 완벽 마스터하기 - Inpa Dev

활용

처음에는 메서드 시그니처를 수정하여 해결하기 위해 move()를 수정했다면 이번에는 RandomUtil.pickCarNumber()를 조작해보려고 한다. RandomUtil 클래스는 다음과 같다.

public class RandomUtil {  
    public static int pickCarNumber() {  
        return Randoms.pickNumberInRange(startInclusive, endInclusive);   
    }  
}

위와 같이 단일 클래스로 작성되어 있던 기존 비즈니스 로직을 아래와 같이 interface와 class로 분리했다.

public interface RandomUtil {  
    int pickCarNumber();  
}

public class RacingcarRandomUtil implements RandomUtil {  
    @Override  
    public int pickCarNumber() {  
        return Randoms.pickNumberInRange(CAR_NUMBER_START_INCLUSIVE, CAR_NUMBER_END_INCLUSIVE);  
    }  
}

그리고 마지막으로 테스트 패키지의 동일한 경로에 interface를 상속하는 테스트용 클래스를 작성했다.

public class TestRandomUtil implements RandomUtil {  
  
    private final int fixedResult;  
  
    public TestRandomUtil(int fixedResult) {  
        this.fixedResult = fixedResult;  
    }  
  
    @Override  
    public int pickCarNumber() {  
        return fixedResult;  
    }  
}

테스트용 클래스는 기존 클래스와 달리 객체 생성 시 정수를 입력받고 상태로 저장해둔다. 그리고 이후에 들어오는 모든 랜덤 값 추출 요청에 대해 항상 해당 정수 값을 반환한다.

이렇게 전략 패턴을 활용하여 비즈니스용 클래스와 테스트용 클래스를 분리하고, 테스트에서는 랜덤 값 추출 결과를 조작할 수 있게 되었다.

// Before    
public void playRound() {  
    move(RandomUtil.pickCarNumber());  
}  
  
protected void move(int carNumber) {  
    if (carNumber >= MOVE_STANDARD) {  
        position += DEFAULT_MOVE_DISTANCE;  
    }  
}

// After
public void playRound() {  
    if (randomUtil.pickCarNumber() >= MOVE_STANDARD) {  
        position += DEFAULT_MOVE_DISTANCE;  
    }  
}

randomUtil.pickCarNumber()를 조작할 수 있게 되니 애써 메서드를 분리하지 않아도 되고, 불필요한 접근제어 개방도 사라졌다.

이번에는 이렇게 변경된 구조를 활용하는 테스트 코드를 확인해보자.

@ParameterizedTest  
@ValueSource(ints = {4, 5, 6, 7, 8, 9})  
void 자동차가_정상적으로_전진하는가(int carNumber) {  
    Position position = new Position(new TestRandomUtil(carNumber));  
    position.playRound();  
    assertThat(position.get()).isEqualTo(1);  
}  
  
@ParameterizedTest  
@ValueSource(ints = {0, 1, 2, 3})  
void 자동차가_정상적으로_멈춰있는가(int carNumber) {  
    Position position = new Position(new TestRandomUtil(carNumber));  
    position.playRound();  
    assertThat(position.get()).isEqualTo(0);  
}

위 코드에서 핵심적으로 봐야할 부분은 여기다.

Position position = new Position(new TestRandomUtil(carNumber)); 

Position 객체 생성 시 테스트용 랜덤 클래스를 주입하는데, 여기에 테스트 인자로 주어진 정수를 전달한다. 이를 통해 랜덤 값 추출 결과로 특정 값이 주어질 경우 의도하는 결과를 반환하는지 테스트할 수 있다.

static 메서드

사실 위의 개선 과정에서 은근슬쩍 넘어간 부분이 있는데 눈치챘는가? 사실 소제목에서 이미 스포하고 있지만..

// Before
public class RandomUtil {  
    public static int pickCarNumber() {  
        return Randoms.pickNumberInRange(startInclusive, endInclusive);   
    }  
}

// After
public class RacingcarRandomUtil implements RandomUtil {  
    @Override  
    public int pickCarNumber() {  
        return Randoms.pickNumberInRange(CAR_NUMBER_START_INCLUSIVE, CAR_NUMBER_END_INCLUSIVE);  
    }  
}

잘 보면 기존의 RandomUtil과 변경된 후의 RacingcarRandomUtil은 사용 시 객체 생성 여부가 다르다. 기존에는 객체를 생성하지 않고 static 메서드만 참조했다면, 변경된 후에는 객체를 생성한 후에만 메서드를 사용할 수 있게 바뀌었다. 왜 이렇게 바뀌었을까?

static 메서드를 interface에 선언할 수 있지만 재정의가 불가능하다. 즉 interface에 직접 함수를 정의해야 하고, 구현체별로 이 메서드를 커스텀할 수 없다. 전략패턴을 사용하려면 static 메서드를 포기하는 방향으로 가야할 것 같다.

interface에서의 static 메서드에 대해 궁금하다면 아래 게시글을 읽어보자.

인터페이스에서의 static 메소드와 default 메소드

왜 사용했을까?

왜 처음에는 일반 메서드가 아니라 static 메서드를 사용하고자 했을까? static은 객체지향 프로그래밍에서 멀리해야하지 않나?

맞는 말이다. 하지만 static 메서드를 활용하면 객체를 생성하지 않고도 메서드에 접근할 수 있다는 장점이 있다. 그리고 RandomUtil은 별도의 상태를 가지지 않기 때문에 객체를 생성할 필요가 없다. 그래서 클래스 네이밍도 ~~Util로 지었고 필드도 없이 static 메서드만 유지하도록 작성했다.

싱글톤 패턴

전략 패턴을 사용하려면 static 메서드는 구조 상 포기해야 한다고 했다. 그리고 static 메서드를 사용한 이유는 객체를 생성하지 않고 사용할 수 있도록 함으로써 객체 생성 비용을 절약하기 위해서였다. 그럼 객체 생성 비용을 아끼면서 전략 패턴을 사용할 방법이 없을까?

이 때 갑자기 지난 주 코드리뷰에서 접한 싱글톤 패턴이 떠올랐다.

싱글톤 객체는 단일 객체만을 유지하기 때문에 객체 생성 비용을 절약하기에 효과적이다. 하지만 리뷰에도 나왔듯이 단일 객체만 사용하는 만큼 동시성 문제에 취약하다.

하지만 RandomUtil 클래스는 원래부터 상태를 유지하지 않았다. 그래서 동시에 접근하는 환경에서도 safe하다는 결론을 내렸고, RacingcarRandomUtil 클래스에 싱글톤 패턴을 적용했다.

public class RacingcarRandomUtil implements RandomUtil{  
    private static RacingcarRandomUtil instance;  
  
    private RacingcarRandomUtil() {  
    }  
    
    public static RacingcarRandomUtil getInstance() {  
        if (instance == null) {  
            instance = new RacingcarRandomUtil();  
        }  
        return instance;  
    }  
}

이렇게 전략 패턴과 싱글톤 패턴을 함께 적용함으로써 객체 생성 비용을 절약하면서 랜덤 값 테스트에 용이한 구조를 만들 수 있었다.

public class TestRandomUtil implements RandomUtil {  
  
    private static TestRandomUtil instance;  
  
    private final int fixedResult;  
  
    public TestRandomUtil(int fixedResult) {  
        this.fixedResult = fixedResult;  
    }  
  
    @Override  
    public int pickCarNumber() {  
        return fixedResult;  
    }  
}

하지만 테스트 클래스에서는 상태를 유지하고 있다. 그래서 싱글톤 패턴을 적용하지 않고 매번 객체를 생성하여 사용하도록 그대로 두었다.

참고 자료

메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기 - Tecoble
전략(Strategy) 패턴 - 완벽 마스터하기 - Inpa Dev
인터페이스에서의 static 메소드와 default 메소드

post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 10월 31일

랜덤값 테스트를 위해 다형성을 활용하신 점이 인상깊어요!! 메서드 시그니처 수정부터 싱글톤 패턴 적용까지 여러 가지 시도를 해보신 점에서 많이 배웠습니다! 😆

1개의 답글