[JUnit5] Dynamic Test - @TestFactory
JAVA/JUnit

[JUnit5] Dynamic Test - @TestFactory

동적 테스트

Jupiter에는 기존에 사용하던 @Test를 통한 표준 테스트 외에도 새로운 종류의 테스트 프로그래밍 모델이 생겼습니다.

이것은 바로 @TestFactory 어노테이션이 달린 팩토리 메서드에 의해 런타임 시 생성되는 동적 테스트입니다.

 

동적 테스트의 실행 파일은 함수형 인터페이스 @FunctionalInterface입니다. 즉, 람다 및 메서드 참조를 사용할 수 있습니다!

 

@TestFactory?

기존의 @Test와 달리 @TestFactory 메서드는 테스트 케이스 자체가 아니라, 테스트 케이스를 위한 팩토리 메서드입니다.

그렇기 때문에 이 메서드의 결과는 단일 DynamicNode나 DynamicNode 인스턴스의 [배열, Stream, Collection, Iterable, Iterator]를 반환해야 합니다.

 

@TestFactory에 의해 반환되는 스트림들은 stream.close( )를 호출해 적절하게 종료되기 때문에, Files.lines( )와 같은 리소스를 안전하게 사용할 수 있습니다.

 

@Test 메서드와 마찬가지로 @TestFactory 메서드 역시 private, static 해서는 안됩니다. 선택적으로 ParameterResolvers에서 확인할 파라미터를 선언할 수 있습니다.

 

DynamicNode

DynamicNode 인스턴스 화가 가능한 하위 클래스는 DynamicContainer와 DynamicTest입니다.

 

DynamicContainer 인스턴스는 [DisplayName, Dynamic Node의 리스트]로 구성되어 동적 노드의 임의 중첩 계층을 형성할 수 있습니다.

 

DynamicTest 인스턴스는 지연 실행이 되어, 테스트 케이스의 동적 및 비결정적 생성을 가능하게 합니다.

 

 

 

동적 테스트의 생명 주기

동적 테스트의 실행 생명 주기는 기존에 사용하던 @Test와 다르기 때문에 주의하셔야 합니다.

가장 큰 차이점은 동적 테스트에는 생명 주기 콜백이 없다는 것입니다! (=@BeforeEach, @AfterEach가 없다!!!)

그렇기 때문에 @TestFactory 메서드에서 한번 실행이 될 뿐 생성된 동적 테스트에서는 실행되지 않습니다.

 

 

예시

그럼 이번에는 직접 코드를 작성해봅시다.

 

잘못된 유형을 반환하는 경우

이러한 경우에는 컴파일 시 감지할 수 없고, 런타임 때 감지되어 JUnitException이 발생합니다.

@TestFactory
List<String> dynamicTestWithInvalidReturnType() {
    return Arrays.asList("Hello");		// List는 적절한 반환타입이 아니다!
}

 

기본적인 생성

적절한 리턴 타입에 대한 기본적인 생성 코드를 봅시다.

실제로는 스트림을 제외하고 이 코드들이 동적 동작을 하지는 않습니다.

그냥 기본적인 반환 타입을 알려주기 위한 코드입니다.

    // 리턴 타입 : 단일 노드
    @TestFactory
    DynamicTest dynamicTestFromSingleDynamicNode() {
        return dynamicTest("single dynamic test", () -> assertSame(2, 2));
    }


    // 리턴 타입 : Collection
    @TestFactory
    Collection<DynamicTest> dynamicTestFromCollection() {
        return Arrays.asList(
                dynamicTest("1st dynamic test", () -> assertSame(4, 4)),
                dynamicTest("2st dynamic test", () -> assertTrue(isPalindrome("madam"))));
    }


    // 리턴 타입 : Iterable
    @TestFactory
    Iterable<DynamicTest> dynamicTestFromIterable() {
        return Arrays.asList(
                dynamicTest("1st dynamic test", () -> assertSame(4, 4)),
                dynamicTest("2st dynamic test", () -> assertTrue(isPalindrome("madam"))));
    }


    // 리턴 타입 : Iterator
    @TestFactory
    Iterator<DynamicTest> dynamicTestFromIterator() {
        return Arrays.asList(
                dynamicTest("1st dynamic test", () -> assertSame(4, 4)),
                dynamicTest("2st dynamic test", () -> assertTrue(isPalindrome("madam")))
               ).iterator();
    }


    // 리턴 타입 : 배열
    @TestFactory
    DynamicTest[] dynamicTestsFromArray() {
        return new DynamicTest[]{
                dynamicTest("1st dynamic test", () -> assertSame(4, 4)),
                dynamicTest("2st dynamic test", () -> assertTrue(isPalindrome("madam")))
        };
    }


    // 리턴 타입 : 스트림
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
        return Stream.of("tomato", "mom", "dad")
                .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
    }

    // 물론 기본형 특화 스트림도 가능하다!
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromIntStream() {
        return IntStream.iterate(0, (int n) -> n + 2).limit(10)
                .mapToObj((int n) -> dynamicTest("test" + n, () -> assertTrue(n % 2 == 0))
                );
    }

 

 

본격적인 동적 테스트 생성

이번에는 정말 본격적으로 동적 테스트를 만들어 봅시다.

0~100 사이면서 7로 나누어 떨어지지 않는 난수를 가져오는 테스트입니다.

    @TestFactory
    Stream<DynamicTest> generateRandomNumberOfTestFromIterator() {

        Iterator<Integer> inputGenerator = new Iterator<Integer>() {

            Random random = new Random();
            int current;

			// 다음 수가 7로 나누어 떨어지면 테스트 종료
            @Override
            public boolean hasNext() {
                current = random.nextInt(100);
                return current % 7 != 0;
            }

            @Override
            public Integer next() {
                return current;
            }
        };

        Function<Integer, String> displayNameGenerator = (input) -> "input : " + input;

        ThrowingConsumer<Integer> testExecutor = (input) -> assertTrue(input % 7 != 0);

        return DynamicTest.stream(inputGenerator, displayNameGenerator, testExecutor);
    }

 

결과값은 항상 다르다!

결괏값은 항상 다를 것입니다. 이게 바로 동적 테스트를 하는 이유입니다!

이러한 테스트의 경우는 비결정적 동작이기 때문에 주의해서 사용해야 합니다.

 

 

 

다음은 DynamicTest 팩토리 메서드를 사용해 생성해 봅시다!

    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStreamFactoryMethod() {
        Stream<String> inputStream = Stream.of("mom", "dad", "tomato");

        Function<String, String> displayNameGenerator = text -> "Is " + text + " a palindrome?";

        ThrowingConsumer<String> textExecutor = text -> assertTrue(isPalindrome(text));

        return DynamicTest.stream(inputStream, displayNameGenerator, textExecutor);
    }

결과

 

다음은 컨테이너를 통한 생성입니다. 앞에서 말했지만, Dynamic Container는 [DisplayName, DynamicNode 리스트]로 이루어져 있습니다!

또한 Container를 이용하면 중첩 계층을 생성할 수 있습니다.

    @TestFactory
    Stream<DynamicNode> dynamicTestsWithContainers() {
        return Stream.of("A", "B", "C")
                .map(input -> dynamicContainer("Container " + input, Stream.of(
                        dynamicTest("not null", () -> assertNotNull(input)),
                        dynamicContainer("properties", Stream.of(
                                dynamicTest("length > 0", () -> assertTrue(input.length() > 0)),
                                dynamicTest("not empty", () -> assertFalse(input.isEmpty()))
                        ))
                )));
    }
    
    @TestFactory
    DynamicNode dynamicNodeSingleContainer() {
        return dynamicContainer("palindromes",
                Stream.of("racecar", "radar", "mom", "dad")
                        .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text)))
                        ));
    }