본문 바로가기
Language/JUnit

우테코 2주차 JUnit5 Test 코드 해석

by 박서현 2022. 11. 5.

우테코 2주차 과제는 숫자 야구 게임을 만드는 것이다. 사실상 숫자 맞추기 게임인데, 게임 유저는 컴퓨터가 낸 문제(숫자 3자리)를 맞추면 끝나는 게임이다. 숫자가 얼마나 일치하느냐에 따라 "낫싱", "1볼 1스트라이크" 등으로 힌트를 줘 사용자가 정답 숫자를 유추하게끔 한다. 정답을 맞춘 경우에는 게임이 종료된다. 1을 입력하면 게임을 다시 시작할 수 있고, 2를 입력하면 프로그램을 완전히 종료한다.

/* ApplicationTest.java */
class ApplicationTest extends NsTest {
    @Test
    void 게임종료_후_재시작() {
        assertRandomNumberInRangeTest(
            () -> {
                run("246", "135", "1", "597", "589", "2");
                assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
            },
            1, 3, 5, 5, 8, 9 
        );
    }
    
    @Override
    public void runMain() {
        Application.main(new String[]{});
    }
}

위 코드는 게임 종료 후 정상적으로 재시작할 수 있는지 테스트하는 코드다. 정상적으로 프로그램이 작동한다면 이러한 프로세스를 따른다.

  1. 첫번째 게임에서 정답 숫자는 "135"다.
  2. "246", "135"를 입력해 정답을 맞춘 다음 "1"을 입력해 게임을 재시작한다.
  3. 다시 시작한 게임에서 정답 숫자는 "589"다.
  4. "597", 589"를 입력해 정답을 맞춘 다음 "2"를 입력해 프로그램을 완전히 종료한다. 

위에서 run, output 메서드는 ApplicationTest가 상속하는 NsTest 클래스의 메서드다. 구체적으로 어떤 로직인지 파헤쳐 보기 위해 NsTest.java를 살펴보자.

/* NsTest.java */
public abstract class NsTest {
    private PrintStream standardOut;
    private OutputStream captor;

    @BeforeEach
    protected final void init() {
        standardOut = System.out;	// 기존 Sytem.out의 스트림 저장
        captor = new ByteArrayOutputStream();
        System.setOut(new PrintStream(captor));	// System.out으로 출력하는 스트림을 captor로 변경
    }

    @AfterEach
    protected final void printOutput() {
        System.setOut(standardOut);	// 기존 Sytem.out스트림으로 출력 스트림 변경
        System.out.println(output());
    }
    
    protected final String output() {
        return captor.toString().trim();
    }   
    
    protected final void run(final String... args) {
        command(args);
        runMain();
    }
    
    private void command(final String... args) {
        final byte[] buf = String.join("\n", args).getBytes();
        System.setIn(new ByteArrayInputStream(buf));
    }

    protected abstract void runMain();
}

NsTest는 테스트 어플리케이션에서 콘솔에서 입력받거나 출력하는 값들을 내부적으로 처리하기 위해 System.setOut과 System.setIn을 사용한다. 먼저 콘솔 출력값(System.out.println)을 내부 변수에 어떻게 담는지 알아보자.

NsTest는 BeforeEach와 AfterEach 어노테이션을 사용해 Test 중에 System.out.println 메서드로 콘솔에 출력하는 값을 captor에 담아둔다.

  1. init 메서드에서 System.out의 스트림을 captor를 향하도록 바꾼다. 즉, 테스트 코드의 출력 값을 captor로 담아둔다.
  2. printOutput에서 System.out의 스트림을 원상복구한다.

콘솔에서 입력해야 하는 값은 미리 command 메서드에 String 값(String... args)으로 전달한다.  전달한 String 값은 byte 배열인 buf 변수에 담는다. 그리고 buf 값을 ByteArrayInputStream 형태로 System.in 입력 스트림으로 사용한다. 즉, Scanner.nextLine 등을 호출해 콘솔에서 입력값을 받을 때 실제로 콘솔에서 입력하는 게 아니라 buf 배열 안에 값들을 차례로 전달하는 것이다.

ApplicationTest에서 사용하는 run메서드는 command메서드와 runMain메서드로 구성된다. command 메서드는 게임 유저가 입력해야 하는 세자리 숫자값(246, 135)이나 다시 시작(1), 게임 종료(2) 명령자를 미리 받는다. 위에서 설명한 것처럼 command는 전달받은 문자열을 System.in의 입력 스트림으로 대기시킨다. runMain메서드는 ApplicationTest에서 오버라이딩한 메서드로 테스트 클래스 Application을 실행한다. Application을 실행하면서 Scanner.nextLine()을 호출할 때마다 command에 전달한 문자열 값을 리턴받는 것이다.

/* ApplicationTest.java */
class ApplicationTest extends NsTest {
    @Test
    void 게임종료_후_재시작() {
        assertRandomNumberInRangeTest(
            () -> {
                run("246", "135", "1", "597", "589", "2");	// Application.main을 실행한다.
                assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
            },
            1, 3, 5, 5, 8, 9 
        );
    }
    
    @Override
    public void runMain() {
        Application.main(new String[]{});
    }
}

지금까지는 runMain메서드가 실행될 때 내부적으로 어떤 동작을 하는지 살펴본 것이다. 그럼 이제 "게임종료_후_재시작" 메서드에서 assertRandomNumberInRangeTest가 어떤 동작을 수행하는지 살펴보자.

/* Assertions.java */
public class Assertions {
    private static final Duration SIMPLE_TEST_TIMEOUT = Duration.ofSeconds(1L);
    private static final Duration RANDOM_TEST_TIMEOUT = Duration.ofSeconds(10L);
    
    public static void assertRandomNumberInRangeTest(
        final Executable executable,
        final Integer value,
        final Integer... values
    ) {
        assertRandomTest(
            () -> Randoms.pickNumberInRange(anyInt(), anyInt()), // 해당 메서드를 호출할 때 -> value, Arrays.stream(values).toArray()]를 차례로 리턴 
            executable,
            value,
            values
        );
    }
    
    private static <T> void assertRandomTest(
        final Verification verification,
        final Executable executable,
        final T value,
        final T... values
    ) {
        assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> {
            try (final MockedStatic<Randoms> mock = mockStatic(Randoms.class)) {
                mock.when(verification).thenReturn(value, Arrays.stream(values).toArray()); // -> [1, 3, 5, 5, 8, 9]
                executable.execute();
            }
        });
    }
}
/* Randoms.java */
public class Randoms {
    public static int pickNumberInRange(final int startInclusive, final int endInclusive) {
        validateRange(startInclusive, endInclusive);
        return startInclusive + defaultRandom.nextInt(endInclusive - startInclusive + 1);
    }

assertRandomNumberInRangeTest 메서드는 Executable, value, values를 전달받는다. 이것을 그대로 "() -> Randoms.pickNumberInRange(anyInt(), anyInt())"와 함께 assertRandomTest 메서드에 전달한다. assertRandomTest는 한마디로 verification이 호출된다면 value, values를 묶은 어레이의 값들을 차례로 리턴해, Executable을 실행하는 것이다. 잘 이해가 안된다면 아래 ApplicationTest와 함께 보자.

/* ApplicationTest.java */
class ApplicationTest extends NsTest {
    @Test
    void 게임종료_후_재시작() {
        assertRandomNumberInRangeTest(
            () -> {
                run("246", "135", "1", "597", "589", "2");	// Application.main을 실행한다.
                assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
            },
            1, 3, 5, 5, 8, 9 
        );
    }
    
    @Override
    public void runMain() {
        Application.main(new String[]{});
    }
}
  1. 위 케이스에서는 assertRandomNumberInRangeTest에 value, values로 1,3,5,5,8,9을 전달한다. 따라서 verification인 pickNumberInRange메서드가 호출될 때마다  1, 3, 5, 5, 8, 9를 차례로 리턴하도록 세팅한다.
  2. executable로는 run("246", "135", "1", "597", "589", "2")을 전달한다. run이 실행되면 정답 숫자 세자리를 랜덤 생성하기 위해 pickNumberInRange를 3번 호출할 것이다. 따라서 1,3,5를 리턴받아 정답 숫자 "135"를 만든다.
  3. 정답으로 "246", "135"를 입력한다. → "낫싱", "3스트라이크"를 콘솔에 출력한다.
  4. 정답을 맞추고 프로그램 종료 여부를 묻는다. run 메서드의 3번째 파라미터인 "1"을 입력해 게임을 재시작한다.
  5. 프로그램은 처음으로 돌아가 pickNumberInRange를 3번 호출한다. 5, 8, 9를 리턴받아 정답 숫자 "589"를 만든다.
  6. 정답으로 "597", "589"를 차례로 입력한다. → "1볼, 1스트라이크", " 3스트라이크"를 콘솔에 출력한다.
  7. 정답을 맞추고 프로그램 종료 여부를 묻는다. run 메서드의 6번째 파라미터인 "2"를 입력한다. → "게임 종료"를 콘솔에 출력한다.
  8. 테스트 중에 captor 변수에 쌓인 시스템 출력값(System.out)에 "낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료"를 포함하는지 확인한다.