1. What is Junit 5?

JUnit5 은 3개의 서브 모듈로 구성되어 있다.

Junit5 = JUnit platform + JUnit jupiter + JUnit Vintage

 

 

이들을 간략하게 정리하자면 JUni5 는 테스트를 작성하고 실행하기 위한 테스팅 프레임워크를 제공한다.

 

JUnit Vintage 는 JUnit 의 이전 버전 (= 3, 4) 과 호환시키기 위한 모듈이고, JUnit Jupiter 는 새로운 기능을 지원하기 위한 모듈이고,

 

JUnit Platform 은 이들을 통합하기 위한 기반을 제공하는 모듈이다.

 

2. Writing Tests

가장 간략하게 테스트 케이스를 작성하는 코드는 다음과 같다.

import static org.junit.jupiter.api.Assertions.assertEquals;

import example.util.Calculator;

import org.junit.jupiter.api.Test;

class MyFirstJUnitJupiterTests {

    private final Calculator calculator = new Calculator();

    @Test
    void addition() {
        assertEquals(2, calculator.add(1, 1));
    }

}

 

2.1 Annotations

JUnit jupiter 는 다음과 같은 애노테이션들을 지원한다.

Annotation Description
@Test 이 애노테이션이 붙은 메소드는 "테스트 케이스" 임을 가리킨다.
@ParameterizedTest 이 애노테이션이 붙은 메소드는 "Parameterized Test" 임을 가리킨다. 테스트를 생성할 때 @Test 를 붙혀서 생성할 수 있지만 @ParameterizedTest 를 붙혀서 생성할 수 있다. (테스트 케이스 생성 방법은 다양하다) 자세한 설명은 이후의 글을 참고
@RepeatedTest 이 애노테이션은 붙은 메소드는 반복해서 실행되는 테스트 케이스임을 가리킨다.
@TestFactory 이 애노테이션이 붙은 메소드는 "dynamic tests" 를 위한 "Test Factory" 임을 가리킨다. 자세한 설명은 이후의 글을 참고
@TestTemplate 이 애노테이션이 붙은 메소드는 하나의 테스트 케이스를 여러 환경 (= 매개변수, 라이프사이클 메소드들, DisplayName 등) 에서 실행되는 테스트임을 가리킨다.
@TestClassOrder 이 애노테이션이 붙은 클래스는 테스트 케이스를 실행하는 순서를 클래스 레벨에서 지정할 수 있다.
@TestMethodOrder 이 애노테이션이 붙은 클래스는 테스트 케이스를 실행하는 순서를 메소드 레벨에서 지정할 수 있다.
@TestInstance 이 애노테이션이 붙은 클래스는 Test Instance Lifecycle 을 지정할 수 있다. 자세한 설명은 이후의 글을 참고
@DisplayName 이 애노테이션은 테스트 클래스나 메소드에 붙어서 커스텀한 이름이나 설명을 나타낼 때 쓰인다.
@DisplayNameGeneration 이 애노테이션은 테스트 클래스나 메소드에 붙어서 이름을 커스텀하게 만들 수 있다.
@BeforeEach 이 애노테이션이 붙은 메소드는 테스트 케이스들 (= "@Test", "@ParameterizedTest", "@RepeatedTest", "@TestFactory") 보다 먼저 실행된다.
@AfterEach 이 애노테이션이 붙은 메소드는 테스트 케이스들 ("@Test", "@ParameterizedTest", "@RepeatedTest", "@TestFactory") 이 끝나고 실행된다.
@BeforeAll 이 애노테이션이 붙은 메소드는 테스트 클래스에 있는 모든 테스트 케이스들이 실행되기 전에 먼저 실행된다.
@AfterAll 이 애노테이션이 붙은 메소드는 테스트 클래스에 있는 모든 테스트 케이스들이 실행되고 난 후 실행된다.
@Nested 이 애노테이션이 붙은 클래스는 "Nested Test Class " 임을 가리킨다. 자바 8-15 에서는 @Nested 애노테이션이 붙은 테스트 클래스는 이 안에서 @TestInstance(LifeCycle.PER_CLASS) 이 붙어야만 @BeforeAll 과 @AfterAll 을 사용할 수 있었다. 자바 16 부터는 Test Instance Lifecycle 없이 static method 로 @BeforeAll 과 @AfterAll 을 사용할 수 있다. "Nested Test Class" 에 대한 설명은 이후의 글 참고
@Tag 이 애노테이션을 테스트 클래스나 메소드에 붙혀서 해당 태그가 붙은 테스트 케이스를 실행되지 않고 필터링되게 만들 수 있다. Tag Expression 을 참고하면 어떻게 필터링을 만드는 지 알 수 있다.
@Disabled 이 애노테이션이 붙은 테스트 클래스나 메소드는 실행이 되지 않는다. 이 애노테이션을 사용하는 유즈 케이스로는 버그로 인해서 일시적으로 문제가 있을 경우에 테스트 케이스 실행을 막기 위해서 사용된다.
@Timeout 이 애노테이션이 붙은 테스트 케이스가 실행될 때 애노테이션에 지정한 매개변수 시간을 초과해서 실행된다면 테스트 케이스는 실패한다.
@ExtendWith 테스트 클래스나 케이스에 다양한 확장 기능들을 제공하고 싶을 때 이 애노테이션을 쓰면 된다. 자세한 설명은 이후의 글을 참고
@RegisterExtension @ExtendWith 가 선언적으로 확장 기능을 제공할 수 있는 반면에 @RegisterExtension 은 동적으로 추가할 수 있다.
@TempDir Temporary Directory 를 제공하기 위해서 사용하는 애노테이션이다. 파라미터 injetion 이나 필드 injection 을 통해서 사용할 수 있다.

 

2.2 Parameterized Tests

Paramterized Test 는 @Test 애노테이션으로 생성한 테스트와 달리 여러개의 명시한 매개변수들을 이용해서 그 만큼 실행되는 테스트이다.

 

테스트를 실행할 매개변수로 올 값을 명시해야하므로 @ValueSource 와 같은 Source 애노테이션을 추가로 선언해야만한다.

 

예시로보면 다음과 같다.

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

 

@ValueSource 는 간단한 값들만을 넣는 애노테이션이다. 그래서 다음과 같은 타입만 지원한다.

  • int, long, short, byte, float, double, char, boolean, java.lang.String, java.lang.Class

 

복잡한 객체의 값들을 Input 으로 명시하려면 어떻게 해야할까?

  • @MethodSource:
    • 해당 애노테이션으로 지정한 메소드에서 생성한 객체를 매개변수로 가지고 올 수 있다.
    • 여러개의 매개변수를 생성하기 위해서 지정한 메소드는 Stream 형식으로 값을 리턴했다는 점에 주목하자.
  • @ArgumentsSource:
    • ArgumentProvider 인터페이스를 상속한 객체를 사용자가 구현하고 애노테이션에서 이를 지정하면 커스텀 Provider 에서 생성한 객체들을 매개변수로 가지고 올 수 있다.

 

@MethodSource 의 예시:

@ParameterizedTest
@MethodSource("provideComplexObjects")
void testWithComplexObjects(MyComplexObject object) {
    // perform test logic with object
}

static Stream<MyComplexObject> provideComplexObjects() {
    return Stream.of(new MyComplexObject("data1"), new MyComplexObject("data2"));
}

 

 

@ArgumentSource 의 예시:

@ParameterizedTest
@ArgumentsSource(MyArgumentsProvider.class)
void testWithArgumentsSource(String argument) {
    assertNotNull(argument);
}

static class MyArgumentsProvider implements ArgumentsProvider {
    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Stream.of("apple", "banana").map(Arguments::of);
    }
}

 

이외에 @CsvSoucrce 또는 @CsvFileSource 를 이용해서 CSV 형식으로 값을 입력해서 매개변수로 읽어오거나, CSV 파일로부터 읽어서 매개변수로 가져올 수 있다.

 

2.3 Dynamic Tests

Dynamic Test 는 테스트 케이스와 테스트 케이스를 위한 매개변수들 그리고 테스트 이름을 런타임 때 만들 수 있다.

이는 정적 테스트와 가장 다른 점이다.

  • 정적 테스트는 @Test 로 테스트 케이스를 만들던 것들을 말한다.
  • 이 차이로 인해서 Dynamic Test 에서 할 수 있는 것은 하나의 테스트 케이스를 여러개의 매개변수로 실행되는 것을 할 수 있다. (물론 @ParameterizedTest 로도 가능하다.)
  • 아니면 외부에서 가져온 데이터를 바탕으로 테스트 케이스를 만드는 경우에는 Dynamic Test 로만 가능하다.

 

Dynamic Test 를 생성하기 위해서 @TestFactory 가 이용된다.

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class DynamicTestsDemo {

    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
        // 테스트 입력 및 예상 결과를 준비합니다.
        int[] input = {1, 2, 3};
        int[] expected = {2, 4, 6};

        return Arrays.asList(
            dynamicTest("1st dynamic test", () -> assertTrue(expected[0] == input[0] * 2)),
            dynamicTest("2nd dynamic test", () -> assertTrue(expected[1] == input[1] * 2)),
            dynamicTest("3rd dynamic test", () -> assertTrue(expected[2] == input[2] * 2))
        );
    }

    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
        // 테스트 입력을 스트림으로 생성합니다.
        return Stream.of("A", "B", "C")
                .map(str -> dynamicTest("test" + str, () -> {
                    // 실제 테스트 로직
                    assertTrue(str.length() > 0);
                }));
    }
}

 

@TestFactory 가 생성할 수 있는 리턴 타입은 다음과 같다:

  • DynamicNode, Stream, Collection, Iterable, Iterator, array of Dynamic Node

 

@Test@TestFactory 모두 static 메소드이거나 private 메소드이면 안된다.

 

@BeforeEach@AfterEach 와 같이 테스트 케이스의 라이프 사이클에 해당하는 메소드들은 @TestFactory 의 실행 전과 후에 실행은 되지만 각각의 dynamicTest() 의 실행과는 상관없다.

 

2.4 Test Templates

@TestTemplate@Test 로 만든 테스트 케이스에다가 다양한 환경들 (= 테스트 케이스를 위한 매개변수, DisplayName, BeforeEach, AfterEach 와 같은 콜백, 테스트 실행 전 설정 주입 등) 을 가지고 여러번 실행할 수 있도록 하는 것이다.

  • 단순하게 매개변수만 다르게 여러번 테스트를 실행하고 싶다면 @ParamterizedTest 를 이용하면 된다.

 

@TestTemplateTestTemplateInvocationContextProvider 인터페이스를 구현한 프로바이더에 의해서 실행된다. (이건 개발자가 작성해야함.)

 

이 프로바이더는 TestTemplateInvocationContext 를 통해서 테스트가 실행될 수 있는 컨택스트를 제공하면서 실행한다.

 

TestTemplateInvocationContext 에 테스트 케이스마다 다를 수 있는 환경 정보들 (= 매개변수, 콜백, 테스트 실행 전 설정 주입 등) 이 포함된다.

 

@TestTemplate 을 이용한 예제 코드는 다음과 같다.

  • 이건 사용자 계정에 대해 권한 별로 테스트 케이스를 실행하는 코드다.
public class UserPermissionTest {

    @TestTemplate
    @ExtendWith(UserPermissionTestProvider.class)
    void userPermissionTest(UserPermission permission) {
        // 여기에 테스트 로직을 작성합니다.
        // 예: 특정 API가 사용자 권한에 따라 올바르게 반응하는지 확인합니다.
        String result = performApiCall(permission);
        Assertions.assertTrue(result.contains("Success") || result.contains("Access Denied"));
    }

    // 실제 환경에서는 여기에 API 호출 로직을 작성합니다.
    private String performApiCall(UserPermission permission) {
        // 여기서는 단순한 문자열 반환으로 시뮬레이션합니다.
        if (permission == UserPermission.ADMIN) {
            return "Success";
        } else {
            return "Access Denied";
        }
    }

    enum UserPermission {
        ADMIN, USER, GUEST
    }

    static class UserPermissionTestProvider implements TestTemplateInvocationContextProvider {

        @Override
        public boolean supportsTestTemplate(ExtensionContext context) {
            return true;
        }

        @Override
        public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
            return Stream.of(
                userPermissionContext(UserPermission.ADMIN),
                userPermissionContext(UserPermission.USER),
                userPermissionContext(UserPermission.GUEST)
            );
        }

        private TestTemplateInvocationContext userPermissionContext(UserPermission permission) {
            return new TestTemplateInvocationContext() {
                @Override
                public String getDisplayName(int invocationIndex) {
                    return "Test with user permission: " + permission;
                }

                @Override
                public void beforeEach(ExtensionContext context) throws Exception {
                    // 필요한 경우 여기에서 테스트 전에 실행할 커스텀 로직을 구현할 수 있습니다.
                }

                @Override
                public void afterEach(ExtensionContext context) throws Exception {
                    // 필요한 경우 여기에서 테스트 후에 실행할 커스텀 로직을 구현할 수 있습니다.
                }
            };
        }
    }
}

 

2.5 Test Execution Order

일반적으로 테스트 케이스들이 서로의 순서에 의존성을 가지지 않도록 해야하는 것이 옳다.

 

하지만 빌드 시간을 최적화 하기 위해서 테스트 케이스를 실행하는 순서를 정하는 것이 때로는 유용하다:

  • 이전에 실패한 테스트를 먼저 실행하거나, 빠른 테스트를 먼저 실행하는 것.
  • 테스트 실행을 병렬 모드로 해놓고 테스트 실행이 오래 걸리는 테스트를 먼저 실행하는 것

여기서는 테스트 케이스에서 순서를 지정하는 방법으로 @TestMethodOrder@TestClassOrder 를 소개한다.

 

2.5.1 @TestMethodOrder

테스트 클래스 내의 테스트 케이스 메소드들의 순서를 지정하는 방법을 말한다.

 

실행 순서를 결정하는 옵션은 다음과 같다:

  • Alphanumeric:
    • 테스트 케이스의 이름의 알파벳 순서에 따라서 실행된다.
  • OrderAnnotation:
    • @Order 애노테이션으로 지정한 순서에 따라서 실행된다.
  • Random:
    • 무작위 순서로 실행된다.

예제 코드는 다음과 같다.

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedTest {

    @Test
    @Order(1)
    void firstTest() {
        // 이 테스트가 첫 번째로 실행됩니다.
    }

    @Test
    @Order(2)
    void secondTest() {
        // 이 테스트가 두 번째로 실행됩니다.
    }

    @Test
    @Order(3)
    void thirdTest() {
        // 이 테스트가 세 번째로 실행됩니다.
    }
}

 

2.5.2 @TestClassOrder

테스트 케이스들이 포함되어 있는 Top Level 테스트 클래스에서 테스트 케이스가 내부에 중첩되어 있는 클래스인 Nested Class

있을 때 이들의 순서를 정할 수 있다.

 

@TestClassOrderClassOrderer 구현체를 통해서 순서를 정할 수 있다:

  • Alphanumeric:
    • 클래스 이름의 알파벳순으로 순서가 결정된다.
  • OrderAnnotation:
    • @Order 애노테이션을 사용하여 명시적인 순서가 결정된다.
  • Random:
    • 무작위 순서로 실행합니다.

예시 코드는 다음과 같다.

@TestClassOrder(ClassOrderer.OrderAnnotation.class)
public class OrderedClassesTest {

    @Nested
    @Order(1)
    class FirstNestedTestClass {
        // 이 중첩 클래스의 테스트들이 첫 번째로 실행됩니다.
    }

    @Nested
    @Order(2)
    class SecondNestedTestClass {
        // 이 중첩 클래스의 테스트들이 두 번째로 실행됩니다.
    }

    @Nested
    @Order(3)
    class ThirdNestedTestClass {
        // 이 중첩 클래스의 테스트들이 세 번째로 실행됩니다.
    }
}

2.6 Test Instance Lifecycle

Test Instance Lifecycle 을 이해하기 위해서는 @Test 애노테이션이 붙은 테스트 케이스가 어떻게 실행되는지 과정을 알아야 한다.

 

@Test 애노테이션이 붙은 메소드는 기본적으로 메소드 실행 전마다 새로운 테스트 클래스 객체 (= Test Instance) 를 생성해서 해당 메소드를 실행한다.

 

즉 Test Instance 의 Lifecycle 은 @Test 메소드마다 Test Instance 를 생성하고 폐기하는 과정을 가진다.

 

근데 이 Lifecycle 을 @TestInstance(Lifecycle.PER_CLASS) 로 변경하면 하나의 Test Instance 를 생성하고 @Test 가 붙은 테스트 케이스들을 모두 실행한뒤 폐기하는 과정으로 된다.

 

(기본 동작이 per-method 마다 Test Instance 를 생성하는 이유는 테스트 클래스끼리 서로의 공유하는 변수의 상태를 변경시켜서 사이드 이펙트를 내지 않도록 하기 위함이다.)

 

@TestInstance(Lifecycle.PER_CLASS) 로 변경하면 얻을 수 있는 이점으로 @BeforeAll@AfterAll 그리고 @MethodSource 와 같은 메소드들을 static 메소드로 만들지 않고 그냥 메소드로 만들어도 된다.

 

2.7 Display Name Generators

테스트 케이스 메소드나 클래스에 이 애노테이션을 붙어서 커스텀하게 이름을 생성할 수 있다.

 

이름을 생성하기 위한 전략은 다음과 같이 제공한다:

  • DisplayNameGenerator.Standard:
    • 기본 설정이다. 아무런 변경하지 않는다.
  • DisplayNameGenerator.ReplaceUnderscores:
    • 테스트 케이스나 클래스의 이름에 있는 모든 밑줄(_) 을 공백으로 대체한다.
  • DisplayNameGenerator.IndicativeSentences:
    • 테스트 클래스와 메소드의 이름을 조합해서 문장으로 생성한다.
  • DisplayNameGenerator.Simple:
    • 간단하게 클래스나 메소드 이름만 출력된다.
    • standard 로 사용했을 경우에는 메소드의 매개변수와 같은 것들도 출력되겠지만 simple 을 이용하면 진짜 딱 이름만 출력된다.

2.8 Nested Test Class

@Nested 애노테이션을 이용하면 관련 있는 테스트 케이스들을 그룹핑 할 수 있고, 테스트 구조를 계층적으로 보여줄 수 있다.

 

@Nested 애노테이션을 이용한 예제:

  • (Stack 의 생성에 대한 테스트 케이스도 생성하네)
  • (Stack 을 새로 생성한 이후와 Push 하는 연산을 다른 계층으로 효과적으로 표현하고 있음)
import java.util.EmptyStackException;  
import java.util.Stack;

import org.junit.jupiter.api.BeforeEach;  
import org.junit.jupiter.api.DisplayName;  
import org.junit.jupiter.api.Nested;  
import org.junit.jupiter.api.Test;

@DisplayName("A stack")  
class TestingAStackDemo {

Stack<Object> stack;

@Test
@DisplayName("is instantiated with new Stack()")
void isInstantiatedWithNew() {
    new Stack<>();
}

@Nested
@DisplayName("when new")
class WhenNew {

    @BeforeEach
    void createNewStack() {
        stack = new Stack<>();
    }

    @Test
    @DisplayName("is empty")
    void isEmpty() {
        assertTrue(stack.isEmpty());
    }

    @Test
    @DisplayName("throws EmptyStackException when popped")
    void throwsExceptionWhenPopped() {
        assertThrows(EmptyStackException.class, stack::pop);
    }

    @Test
    @DisplayName("throws EmptyStackException when peeked")
    void throwsExceptionWhenPeeked() {
        assertThrows(EmptyStackException.class, stack::peek);
    }

    @Nested
    @DisplayName("after pushing an element")
    class AfterPushing {

        String anElement = "an element";

        @BeforeEach
        void pushAnElement() {
            stack.push(anElement);
        }

        @Test
        @DisplayName("it is no longer empty")
        void isNotEmpty() {
            assertFalse(stack.isEmpty());
        }

        @Test
        @DisplayName("returns the element when popped and is empty")
        void returnElementWhenPopped() {
            assertEquals(anElement, stack.pop());
            assertTrue(stack.isEmpty());
        }

        @Test
        @DisplayName("returns the element when peeked but remains not empty")
        void returnElementWhenPeeked() {
            assertEquals(anElement, stack.peek());
            assertFalse(stack.isEmpty());
        }
    }
}

 

 

IDE 에서 위의 예제를 실행하면 이처럼 보일 것이다.

 

이렇게 계층적으로 구성할 때 Outer 테스트 클래스에서 정의한 @BeforeEach 와 같은 setUp 코드는 Inner 테스트 클래스에서도 독립적으로 사용된다.

 

그리고 @Nested 테스트 클래스는 기본적으로 @TestInstance(LifeCycle.PER_CLASS) 없이는 @BeforeAll@AfterAll 을 사용하지 못했다. (자바 16 이전에 해당)

  • @BeforeAll 같은 콜백은 스태틱 메소드로 선언되어지는데, 자바 16 이전에는 자바가 inner class 의 static member 에 대해서 알수 없었기 때문이라고 함.

 

2.9 Disabling Tests

@Disabled 가 사용되는 예시 코드:

  • 버그로 인해서 테스트 케이스가 깨지는 상황을 나타낼 때 쓰인다.
    import org.junit.jupiter.api.Disabled;
    import org.junit.jupiter.api.Test;


@Disabled("Disabled until bug #99 has been fixed")  
class DisabledClassDemo {

    @Test
    void testWillBeSkipped() {

    }
}

 

2.10 Declarative Extension Registration

@ExtendWith 애노테이션을 통해서 다양한 확장 기능들을 테스트 케이스나 클래스에 추가할 수 있다.

 

확장 기능은 다음과 같다:

  • 테스트 케이스에 추가 파라미터 주입
  • 테스트 실행 전/후에 커스텀한 로직 실행
  • 테스트 인스턴스의 생성에 관여
  • 테스트 메소드의 실행 조건 변경
  • 등등

 

@ExtendWithSpringExtension 를 사용하는 예시:

  • 이 확장 기능은 테스트 클래스를 생성할 떄 테스트 클래스에 빈을 주입하게 해주는 기능이다.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
class MySpringBootTest {

    @Test
    void testWithSpringContext() {
        // Spring 컨텍스트가 로드되고, 빈이 주입된 상태에서 테스트 실행
    }
}

 

 

@ExtendWithWebServerExtension 을 사용하는 예시:

  • 이 확장 기능은 로컬 서버를 띄우고 로컬 서버의 URL 을 테스트 케이스의 파라미터로 전달해준다.
@Test
@ExtendWith(WebServerExtension.class)
void getProductList(@WebServerUrl String serverUrl) {
    WebClient webClient = new WebClient();
    // Use WebClient to connect to web server using serverUrl and verify response
    assertEquals(200, webClient.get(serverUrl + "/products").getResponseStatus());
}

 

 

만약 WebServerExtension 을 테스트 클래스 레벨에 등록한다면 모든 테스트 케이스에서 사용할 수 있다.

@ExtendWith(WebServerExtension.class)
class MyTests {
    // ...
}

 

 

Multiple ExtendWith 를 사용하고 싶다면 다음과 같이 코드를 구성하면 된다.

  • @Random 확장 애노테이션을 만들어서 재사용하는 것도 있다. 신기하네.
@ExtendWith({ DatabaseExtension.class, WebServerExtension.class })
class MyFirstTests {
    // ...
}

 

 

@ExtendWith 의 확장 기능들의 조합을 재사용하고 싶다면 애노테이션으로 만들면된다.

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({ DatabaseExtension.class, WebServerExtension.class })
public @interface DatabaseAndWebServerExtension {
}
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(RandomNumberExtension.class)
public @interface Random {
}
class RandomNumberDemo {

    // Use static randomNumber0 field anywhere in the test class,
    // including @BeforeAll or @AfterEach lifecycle methods.
    @Random
    private static Integer randomNumber0;

    // Use randomNumber1 field in test methods and @BeforeEach
    // or @AfterEach lifecycle methods.
    @Random
    private int randomNumber1;

    RandomNumberDemo(@Random int randomNumber2) {
        // Use randomNumber2 in constructor.
    }

    @BeforeEach
    void beforeEach(@Random int randomNumber3) {
        // Use randomNumber3 in @BeforeEach method.
    }

    @Test
    void test(@Random int randomNumber4) {
        // Use randomNumber4 in test method.
    }

}

 

 

RandomNumberExtesnion 은 Extension API 를 이용해서 기능을 구현한 것이다. 실제 내부 구현은 아래와 같은 것.

  • BeforeAllCallback:
    • static field injection 을 위해서 사용했다.
  • BeforeEachCallback:
    • non-static field injection 을 위해서 사용했다.
    • TestInstancePostProcessor 를 이용해서도 non-static field injection 을 구현할 수 있다고 함. 이게 이상적이라고 하는듯.
  • ParameterResolver:
    • 생성자와 메소드 injection 을 위해서 사용했다.
class RandomNumberExtension
        implements BeforeAllCallback, BeforeEachCallback, ParameterResolver {

    private final java.util.Random random = new java.util.Random(System.nanoTime());

    /**
     * Inject a random integer into static fields that are annotated with
     * {@code @Random} and can be assigned an integer value.
     */
    @Override
    public void beforeAll(ExtensionContext context) {
        Class<?> testClass = context.getRequiredTestClass();
        injectFields(testClass, null, ModifierSupport::isStatic);
    }

    /**
     * Inject a random integer into non-static fields that are annotated with
     * {@code @Random} and can be assigned an integer value.
     */
    @Override
    public void beforeEach(ExtensionContext context) {
        Class<?> testClass = context.getRequiredTestClass();
        Object testInstance = context.getRequiredTestInstance();
        injectFields(testClass, testInstance, ModifierSupport::isNotStatic);
    }

    /**
     * Determine if the parameter is annotated with {@code @Random} and can be
     * assigned an integer value.
     */
    @Override
    public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
        return pc.isAnnotated(Random.class) && isInteger(pc.getParameter().getType());
    }

    /**
     * Resolve a random integer.
     */
    @Override
    public Integer resolveParameter(ParameterContext pc, ExtensionContext ec) {
        return this.random.nextInt();
    }

    private void injectFields(Class<?> testClass, Object testInstance,
            Predicate<Field> predicate) {

        predicate = predicate.and(field -> isInteger(field.getType()));
        findAnnotatedFields(testClass, Random.class, predicate)
            .forEach(field -> {
                try {
                    field.setAccessible(true);
                    field.set(testInstance, this.random.nextInt());
                }
                catch (Exception ex) {
                    throw new RuntimeException(ex);
                }
            });
    }

    private static boolean isInteger(Class<?> type) {
        return type == Integer.class || type == int.class;
    }

}

 

JUnit5 Jupiter 5.8 부터 @ExtendWith 는 필드, 생성자 파라미터, 메소드 그리고 lifecycle (= @BeforeEach, @AfterEach 등) 에서도 사용할 수 있다.

 

2.11 Programmatic Extension Registration

@RegisterExtension@ExtendWith 처럼 확장 기능을 등록하는 것이지만 방식에 차이가 있다.

 

@ExtendWith 는 선언적으로 등록하는 반면에 @RegisterExtension 은 프로그래밍 방식 (= 확장 객체를 직접 인스턴스화 하고 필요한 설정 기능을 등록해서 사용하는 방식) 으로 사용한다.

  • (프로그래밍 방식이란 표현은 이런걸 의미하네)

 

@RegisterExtension은 필드 레벨에서 사용되며, 해당 필드는 static (클래스 레벨 확장을 위해) 또는 인스턴스 레벨 (각 테스트 인스턴스마다 다른 확장 인스턴스를 위해)에 사용될 수 있다.

  • 이 필드는 public이거나 protected이어야 하며, final 또는 effectively final이어야 한다.

 

다음은 @RegisterExtension 을 사용하는 간단한 예제이다.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.BeforeEachCallback;

class MyExtension implements BeforeEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) {
        // 테스트 실행 전에 할 작업
    }
}

class MyTests {

    @RegisterExtension
    MyExtension myExtension = new MyExtension(); // 인스턴스 생성과 함께 확장을 등록

    @Test
    void testSomething() {
        // myExtension이 초기화되고, beforeEach 콜백을 통해 테스트 전에 로직이 실행됨
    }
}

 

@ExtendWith@RegisterExtension 은 확장을 등록하는 순서가 내부적으로 정해져있다. 이래야 테스트를 여러번 실행시켜도 일관성을 지킬 수 있으니.

 

확장을 등록하는 순서를 변경하고 싶다면 @Order 를 이용해야한다.

 

@RegisterExtension 확장을 이용하는 방법으로 Static FieldInstance Field 가 있는데 이것들에 대해서 살펴보겠다.

 

확장 기능이 등록되는 순서가 중요한 이유는 뭘까?

  • 확장을 하나만 쓰기 보다는 여러개를 쓰는 경우가 많아서 각 확장의 기능이 충돌될 수 있으니까 순서를 명확히 아는게 중요하다고 한다.

 

2.11.1 Static Fields

@RegisterExtension 으로 등록하는 확장의 순서는 클래스 레벨에서 선언한 @ExtendWith 를 통해 등록된 확장보다 나중에 등록된다.

 

static 필드에 대한 확장은 Extension API 에 제약이 없어서 클래스 레벨이나 인스턴스 레벨, 메소드 레벨의 확장 모두 이용해서 구현할 수 있다.

  • Extension API 로 확장 기능을 구현할 수 있다.
  • 확장 기능은 콜백 메소드들에 의해서 제공될 수 있는데 이게 클래스 레벨과 메소드 레벨 그리고 인스턴스 레벨로 구별된다.
    • 클래스 수준 API:
      • 이들은 테스트 클래스 전체의 생명주기에 걸쳐 호출된다.
      • 예를 들면, BeforeAllCallbackAfterAllCallback 이 이에 해당한다.
      • 이들은 각각 테스트 클래스의 모든 테스트 메소드가 실행되기 전과 후에 한 번씩 호출된다.
    • 인스턴스 수준 API:
      • 이들은 테스트 인스턴스의 생성과 소멸에 관련된 콜백이며, 예로 TestInstancePostProcessorTestInstancePreDestroyCallback 이 있다.
      • 이들은 테스트 인스턴스가 생성된 직후와 소멸되기 전에 각각 호출됩니다.
    • 메소드 수준 API:
      • 이들은 개별 테스트 메소드의 실행 전후에 관련된 콜백이다.
      • BeforeEachCallbackAfterEachCallback 이 여기에 해당하며, 각각 테스트 메소드가 실행되기 전과 후에 호출된다.
  • 정적 필드 (Static Field) 에 @RegisterExtension 을 써서 제공하는 확장 기능은 모든 콜백 메소드들을 이용해서 구현할 수 있다는 것.

 

@RegisterExtension 을 이용하는 예시 코드:

  • server 필드는 BeforeAllCallback 을 구현해서 모든 테스트 케이스 실행 전에 로컬 서버가 부팅될 것이고 AfterAllCallback 콜백에 의해서 모든 테스트 케이스 실행이 끝나면 종료될 것이다.
class WebServerDemo {

    @RegisterExtension
    static WebServerExtension server = WebServerExtension.builder()
        .enableSecurity(false)
        .build();

    @Test
    void getProductList() {
        WebClient webClient = new WebClient();
        String serverUrl = server.getServerUrl();
        // Use WebClient to connect to web server using serverUrl and verify response
        assertEquals(200, webClient.get(serverUrl + "/products").getResponseStatus());
    }

}

 

2.11.2 Instance Fields

@RegisterExtension 을 Instance Field 에서 사용한다면 기본적으로 메소드 레벨에서 확장 기능이 등록된다.

 

여기서 말하는 메소드 레벨은 테스트 케이스를 실행하는 전체 라이프 사이클에서 @Test 가 붙은 메소드를 실행할 때 확장 기능이 적용된다는 것이다.

 

즉 확장 기능을 등록하기 위해서는 Extension API 중 메소드 레벨의 수준인 것만 (= BeforeEachCallback, AfterEachCallback, ParameterResolver) 이용해서 구현해야만 한다.

  • 더 설명하자면 @RegisterExtension 을 Instance Field 에서 사용한다면 확장 기능은 Test Instance 가 생성된 이후부터 Extension 기능을 넣을 수 있다.
  • 콜백으로 치자면 TestInstancePostProcessor 처리 후에 등록을 할 수 있음. 즉 BeforeAllCallbackAfterAllCallback 그리고 TestInstancPostProcessor 는 사용할 수 없다.

 

확장 기능이 등록되는 순서는 메소드 레벨에 붙은 @ExtendWith 이후에 Instance Field 의 확장이 등록된다.

  • 근데 @TestInstance(Lifecycle.PER_CLASS) 로 테스트 인스턴스의 라이프사이클을 설정했다면 Instance Field 에 대한 확장 기능이 메소드 레벨에 붙언 @Extendwith 보다 먼저 등록된다고 함.

 

Instance Field 에서 사용하는 @RegisterExtension 예제를 보자.

  • docs 변수는 Instance Field 에서 @RegisterExtension 을 이용해 확장 기능을 등록했다.
  • docs 변수는 @Test 가 붙은 테스트 케이스를 실행할 때마다 매번 초기화 될 것이다.
class DocumentationDemo {

    static Path lookUpDocsDir() {
        // return path to docs dir
    }

    @RegisterExtension
    DocumentationExtension docs = DocumentationExtension.forPath(lookUpDocsDir());

    @Test
    void generateDocumentation() {
        // use this.docs ...
    }
}

 

The TempDirectory Extension

TempDirectory Extension 을 이용하는 @TempDir 은 테스트 케이스나 클래스마다 임시 디렉토리를 만들고 정리하는 역할을 제공하기 위해서 사용한다고 한다.

 

임시 디렉토리를 만들어야 하는 이유는 뭔데?

  • 파일이나 디렉토리와 관련된 테스트들을 할 떄 유용하다. (e.g 파일 쓰기/읽기 등)

 

@TempDir 를 사용해서 얻은 임시 디렉토리의 Path 에는 처음에 아무것도 없다. 그러나 다음과 같은 예제 코드를 보면 읽어온 파일에 내용을 추가해서 사용한다.

  • new ListWriter(soruceFile).write("a", "b", "c") 를 통해서 파일에 내용을 써주고 있음.
@Test
void copyFileFromSourceToTarget(@TempDir Path source, @TempDir Path target) throws IOException {
    Path sourceFile = source.resolve("test.txt");
    new ListWriter(sourceFile).write("a", "b", "c");

    Path targetFile = Files.copy(sourceFile, target.resolve("test.txt"));

    assertNotEquals(sourceFile, targetFile);
    assertEquals(singletonList("a,b,c"), Files.readAllLines(targetFile));
}

+ Recent posts