JUnit5 확장 모델

JUnit5의 여러 확장 모델에 대한 설명

확장 모델

JUnit Jupiter에는 확장 모델 이라는 구조가 준비되어있어 모든 확장 기능을 쉽게 도입 할 수 있다.

기본 확장 모델

확장 기능을 만들려면 먼저 Extension 인터페이스를 구현하는 클래스를 만든다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MyExtension implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("MyExtension.beforeEach()");
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("MyExtension.afterEach()");
    }
}

여기서 Extension 자체는 마커 인터페이스이어서 메서드가 정의되어 있지 않다.

Extension 상속한 BeforeEachCallback 이나 AfterEachCallback와 같은 확장 포인트 마다 정의된 서브 인터페이스를 구현한다.

  • BeforeEachCallback는 각 테스트 전에 콜백되는 beforeEach() 메소드가 정의된다.
  • AfterEachCallback는 각 테스트 후에 콜백되는 afterEach() 메소드가 정의된다.

그럼, 앞에서 생성한 MyExtension를 사용해 보겠다.

확장 기능을 구현한 클래스를 실제로 테스트에서 사용하는 방법 중 하나로 @ExtendWith 어노테이션을 사용하는 방법이 있다.
확장 기능을 적용할 위치에 @ExtendWith 어노테이션을 설정하고, value에 적용할 확장 프로그램의 Class 객체를 지정한다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(MyExtension.class)
public class MyExtensionTest {

    @Test
    void test1() {
        System.out.println("  test1()");
    }

    @Test
    void test2() {
        System.out.println("  test2()");
    }
}

실행 결과:

MyExtension.beforeEach()
MyExtension.beforeEach()
  test1()
  test2()
MyExtension.afterEach()
MyExtension.afterEach()

이것만으로 각 테스트 전후에 확장 기능으로 정의한 처리가 실행되었다.

그리고 앞에 예에서는 클래스에 대해서 @ExtendWith를 지정하였지만, 메소드에 지정하는 것으로 부분적으로 확장 기능을 적용할 수도 있다.

아래 예는 test1() 메소드에만 확장 기능을 적용한 경우이다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

//@ExtendWith(MyExtension.class)
public class MyExtensionTest {

    @Test
    @ExtendWith(MyExtension.class)
    void test1() {
        System.out.println("  test1()");
    }

    @Test
    void test2() {
        System.out.println("  test2()");
    }
}

실행 결과:

MyExtension.beforeEach()
  test1()
MyExtension.afterEach()
  test2()

확장 포인트

Extension인터페이스를 상속한 확장 포인트가 되는 인터페이스에는 다음과 같은 것이 있다.

인터페이스 설명
ExecutionCondition 테스트를 실행할지 여부를 제어한다.
TestInstanceFactory 테스트 인스턴스를 생성한다.
TestInstancePostProcessor 테스트 인스턴스 생성 후의 초기화 처리 등을 수행한다.
ParameterResolver 테스트 메소드나 라이프사이클 메소드 등의 인수를 받을 수 있게 해준다.
BeforeAllCallback
BeforeEachCallback
BeforeTestExecutionAfter
AfterTestExecutionCallback
AfterEachCallback
AfterAllCallback
테스트의 실행 전후 등 라이프 사이클에 따른 처리를 실행한다.
TestWatcher 테스트 메소드의 실행 결과에 맞춘 후처리를 실행한다.
TestExecutionExceptionHandler 테스트 실행 시에 발생한 예외를 처리한다.
LifecycleMethodExecutionExceptionHandler @BeforeEach 등의 라이프 사이클 메소드로 발생한 예외를 처리한다.
TestTemplateInvocationContextProvider 동일한 테스트를 다른 컨텍스트에서 실행하기 위한 준비 처리 등을 수행한다.

지원 클래스

확장 클래스를 구현할 때, 범용적으로 이용할 수 있는 유틸리티 클래스(지원 클래스)가 제공되고 있다.

  • AnnotationSupport
    • 어노테이션에 대한 유틸리티 클래스
      • 어노테이션이 지정된 요소 찾기
      • 어노테이션이 지정되어 있는지 여부 판단
      • 그밖에 등등
  • ClassSupport
    • Class문자열 표현에 대한 처리를 제공하는 유틸리티
  • ReflectionSupport
    • 리플렉션 조작에 관한 유틸리티
      • 클래스 메소드 필드 생성자 찾기
      • 메소드 생성자 실행
      • 인스턴스 생성
      • 그밖에 등등
  • ModifierSupport
    • 한정자의 판정 메소드를 정의한 유틸리티

예외 처리나 번거로운 기술을 생략하고, 간결한 방법으로 조작을 할 수 있게 되어 있다.

우선 “이런 것이 존재한다"라고 하는 것을 기억해 두고, 막상 확장 클래스를 만들기 시작하면 “아, 이것 서포트 클래스 사용할 수 있지?“라고 기억해 내서 원하는 메소드가 있는지 찾는 방식으로 하는 것이 좋다고 생각한다.

이러한 클래스는 내부 유틸리티가 아니고, 제3자가 독자적인 TestEngine이나 확장 기능을 만들 때의 보조로서 준비되어 있는 것이므로 안심하고 사용하여도 문제 없다.

테스트 실행 조건 제어

ExecutionCondition을 사용하면 테스트를 실행할지 여부를 제어할 수 있다.

먼저 실행 조건의 확장 클래스를 생성한다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MyExecutionCondition implements ExecutionCondition {

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        if (context.getTestMethod().isPresent()) {
            System.out.println("# Test method = " + context.getRequiredTestMethod().getName());
            return context.getDisplayName().contains("o")
                    ? ConditionEvaluationResult.enabled("Test name has 'o'.")
                    : ConditionEvaluationResult.disabled("Test name does not have 'o'.");
        } else {
            System.out.println("# Test class = " + context.getRequiredTestClass().getSimpleName());
            return ConditionEvaluationResult.enabled("This is test class.");
        }
    }
}

이 실행 조건의 확장 클래스는 테스트 메소드의 표시 이름에 “o” 포함된 항목만 사용하도록 설정되었다.

그럼 앞에서 생성한 확장 클래스를 지정한 테스트 코드는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.List;

@ExtendWith(MyExecutionCondition.class)
class MyExecutionConditionTest {

    @BeforeEach
    void beforeEach() {
        System.out.println("beforeEach()");
    }

    @Test
    void dog() {
        System.out.println("  dog()");
    }

    @Test
    void cat() {
        System.out.println("  cat()");
    }

    @Nested
    class NestedClass {

        @Test
        void cow() {
            System.out.println("  cow()");
        }
    }

    @TestFactory
    List<DynamicNode> testFactory() {
        return List.of(
                DynamicTest.dynamicTest("DynamicTest1", () -> System.out.println("  dynamicTest1")),
                DynamicTest.dynamicTest("DynamicTest2", () -> System.out.println("  dynamicTest2"))
        );
    }
}

실행 결과:

# Test class = MyExecutionConditionTest
# Test method = testFactory
beforeEach()
  dynamicTest1
  dynamicTest2
# Test method = cat
# Test method = dog
beforeEach()
  dog()
# Test class = NestedClass
# Test method = cow
beforeEach()
  cow()
  • 메소드가 실행될 때마다 evaluateExecutionCondition(ExtensionContext) 메소드가 호출되므로 대상 테스트를 실행할지 여부를 반환 값으로 제어한다.
  • evaluateExecutionCondition(ExtensionContext) 메소드가 인수로 받는 ExtensionContext를 사용하면, 대상의 테스트 메소드에 대한 정보를 참조할 수 있다.
    • getTestMethod()와 같은 일부 메소드는 조건에 따라 값이 존재하지 않을 수 있으므로 반환 값 유형이 Optional으로 되어 있다.
    • 반드시 null 되지 않는 것을 알고 있다면, getRequiredTestMethod()와 같이 Required 붙은 메소드를 사용하는 방법도 있다.

테스트 인스턴스 생성

TestInstanceFactory를 사용하면 테스트 인스턴스 생성을 수동화할 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstanceFactory;
import org.junit.jupiter.api.extension.TestInstanceFactoryContext;
import org.junit.jupiter.api.extension.TestInstantiationException;
import org.junit.platform.commons.support.ReflectionSupport;

public class MyTestInstanceFactory implements TestInstanceFactory {
    @Override
    public Object createTestInstance(TestInstanceFactoryContext factoryContext, ExtensionContext extensionContext) throws TestInstantiationException {
        Class<?> testClass = factoryContext.getTestClass();
        System.out.println("===========================================");
        System.out.println("* testClass=" + testClass);
        System.out.println("* outerInstance=" + factoryContext.getOuterInstance().orElse("<empty>"));
        
        return factoryContext
                .getOuterInstance()
                .map(outerInstance -> {
                    Object instance = ReflectionSupport.newInstance(testClass, outerInstance);
                    System.out.println("* outerInstance [" + outerInstance.hashCode() + "]");
                    System.out.println("* testInstance [" + instance.hashCode() + "]");
                    return instance;
                })
                .orElseGet(() -> {
                    Object instance = ReflectionSupport.newInstance(testClass);
                    System.out.println("* testInstance [" + instance.hashCode() + "]");
                    return instance;
                });
    }
}

이어서, MyTestInstanceFactory 확장 클래스를 적용한 테스트 클래스는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.List;

@ExtendWith(MyTestInstanceFactory.class)
class MyTestInstanceFactoryTest {

    @Test
    void test() {
        System.out.println("MyTestInstanceFactoryTest.test() [" + this.hashCode() + "]");
    }

    @Nested
    class NestedClass {

        @Test
        void test() {
            System.out.println("MyTestInstanceFactoryTest.NestedClass.test() [" + this.hashCode() + "]");
        }
    }

    @TestFactory
    List<DynamicNode> testFactory() {
        return List.of(
                DynamicTest.dynamicTest("DynamicTest1", () -> System.out.println("MyTestInstanceFactoryTest.dynamicTest1() [" + this.hashCode() + "]")),
                DynamicTest.dynamicTest("DynamicTest2", () -> System.out.println("MyTestInstanceFactoryTest.dynamicTest2() [" + this.hashCode() + "]"))
        );
    }
}

실행 결과:

===========================================
* testClass=class com.devkuma.junit5.extention.MyTestInstanceFactoryTest
* outerInstance=<empty>
* testInstance [322561962]
MyTestInstanceFactoryTest.dynamicTest1() [322561962]
MyTestInstanceFactoryTest.dynamicTest2() [322561962]
===========================================
* testClass=class com.devkuma.junit5.extention.MyTestInstanceFactoryTest
* outerInstance=<empty>
* testInstance [1558763625]
MyTestInstanceFactoryTest.test() [1558763625]
===========================================
* testClass=class com.devkuma.junit5.extention.MyTestInstanceFactoryTest
* outerInstance=<empty>
* testInstance [1408695561]
===========================================
* testClass=class com.devkuma.junit5.extention.MyTestInstanceFactoryTest$NestedClass
* outerInstance=com.devkuma.junit5.extention.MyTestInstanceFactoryTest@53f6fd09
* outerInstance [1408695561]
* testInstance [1811922029]
MyTestInstanceFactoryTest.NestedClass.test() [1811922029]
  • 각 테스트 메소드가 실행되기 전에 createTestInstance() 메소드가 호출된다.
    • 라이프사이클에 PER_CLASS으로 설정 되어 있는 경우는 클래스 마다 1회만 호출된다.
  • createTestInstance() 반환된 인스턴스가 테스트에서 사용된다. @Nested에서 정의한 내부 테스트 클래스가 있는 경우는 일단 외부 클래스의 생성을 해야 하기 때문에, 호출된 후에는 내부 테스트 클래스 생성을 위해서 한번 더 메소드가 호출된다.
    • 내부 클래스일 경우에는 TestInstanceFactoryContextgetOuterInstance()가 비어 있지 않기 때문에 그것으로 판단할 수 있다.

테스트 인스턴스 생성 후 초기화 처리

TestInstancePostProcessor를 사용하면 테스트 메소드가 실행되기 전에 테스트 인스턴스를 수신하여 임의의 처리를 수행할 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;

public class MyTestInstancePostProcessor implements TestInstancePostProcessor {

    @Override
    public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
        System.out.println("testInstance.hash = " + testInstance.hashCode());
    }
}

이어서, MyTestInstancePostProcessor 확장 클래스를 적용한 테스트 클래스는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(MyTestInstancePostProcessor.class)
class MyTestInstancePostProcessorTest {

    @Test
    void test1() {
        System.out.println("test1() [" + this.hashCode() + "]");
    }

    @Test
    void test2() {
        System.out.println("test2() [" + this.hashCode() + "]");
    }
}

실행 결과:

testInstance.hash = 1530295320
test1() [1530295320]
testInstance.hash = 279566689
test2() [279566689]
  • 테스트 인스턴스에 무언가 의존성을 주입하거나 초기화 메소드를 호출하는데 사용되는 것 같다.

매개변수 해결

ParameterResolver를 사용하면 다양한 메소드의 인수를 임의로 넣을 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;

import java.lang.reflect.Executable;
import java.lang.reflect.Parameter;
import java.util.Optional;

public class MyParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        Executable executable = parameterContext.getDeclaringExecutable();
        int index = parameterContext.getIndex();
        Parameter parameter = parameterContext.getParameter();
        Optional<Object> target = parameterContext.getTarget();

        System.out.printf(
                "target=%s, executable=%s, index=%d, parameter=%s%n",
                target.orElse("<empty>"),
                executable.getName(),
                index,
                parameter.getName()
        );

        return true;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        Class<?> type = parameterContext.getParameter().getType();
        if (type.equals(String.class)) {
            return "Hello";
        } else if (type.equals(int.class)) {
            return 999;
        } else {
            return 12.34;
        }
    }
}

이어서, MyParameterResolver 확장 클래스를 적용한 테스트 클래스는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.List;

@ExtendWith(MyParameterResolver.class)
class MyParameterResolverTest {

    @BeforeEach
    void beforeEach(int i) {
        System.out.printf("beforeEach(i=%d)%n", i);
    }

    @TestFactory
    List<DynamicNode> dynamicTest(String string) {
        return List.of(DynamicTest.dynamicTest("DynamicTest", () -> System.out.printf("dynamicTest(string=%s)%n", string)));
    }

    @Test
    void test1(String string, double d, int i) {
        System.out.printf("test1(string=%s, d=%f, i=%d)%n", string, d, i);
    }

    @Nested
    class NestedClass {

        @Test
        void test2(double d) {
            System.out.printf("test2(d=%f)%n", d);
        }
    }

    @AfterEach
    void afterEach() {
        System.out.println("-----------------------------------------");
    }
}

실행 결과:

target=com.devkuma.junit5.extention.MyParameterResolverTest@132e0cc, executable=beforeEach, index=0, parameter=arg0
beforeEach(i=999)
target=com.devkuma.junit5.extention.MyParameterResolverTest@132e0cc, executable=dynamicTest, index=0, parameter=arg0
dynamicTest(string=Hello)
-----------------------------------------
target=com.devkuma.junit5.extention.MyParameterResolverTest@42b02722, executable=beforeEach, index=0, parameter=arg0
beforeEach(i=999)
target=com.devkuma.junit5.extention.MyParameterResolverTest@42b02722, executable=test1, index=0, parameter=arg0
target=com.devkuma.junit5.extention.MyParameterResolverTest@42b02722, executable=test1, index=1, parameter=arg1
target=com.devkuma.junit5.extention.MyParameterResolverTest@42b02722, executable=test1, index=2, parameter=arg2
test1(string=Hello, d=12.340000, i=999)
-----------------------------------------
target=com.devkuma.junit5.extention.MyParameterResolverTest@6bffbc6d, executable=beforeEach, index=0, parameter=arg0
beforeEach(i=999)
target=com.devkuma.junit5.extention.MyParameterResolverTest$NestedClass@1b84f475, executable=test2, index=0, parameter=arg0
test2(d=12.340000)
-----------------------------------------
  • ParameterResolver를 사용하면 @Test뿐만이 아니라, @TestFactory@BeforeEach와 같은 메소드의 인수도 넣을 수 있다
  • 각 메소드 인수마다 supportsParameter() 호출된다.
    • ParameterContext로부터, 인수의 메타정을 참조할 수 있다.
    • 인수의 해결을 서포트하는 경우는 true를 돌려주어, 그렇지 않은 경우는 false를 돌려주도록 구현한다.
  • supportsParameter()true반환되면 다음에 resolveParameter()이 호출된다.
    • 이 메소드로 넣을 인수의 값을 돌려주도록 구현한다.
  • 또한 Parameter.getName()으로 인수 이름을 얻으려면, javac 컴파일 할 때 -parameters 옵션을 추가해야 한다.
    • 옵션이 없으면 arg0, arg1와 같은 이름이 된다.
    • Gradle로 빌드하는 경우 compileTestJava.options.complierArgs += "-parameters"와 같은 식으로 설정할 수 있다.

라이프사이클에 따른 처리

라이프사이클에 따른 확정 클래스에 대해 소개하겠다.

아래 확장 클래스는 라이프 사이클 콜팩 인터페이스들을 구현하고 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.*;

public class MyLifeCycleCallback
        implements BeforeAllCallback,
        BeforeEachCallback,
        BeforeTestExecutionCallback,
        AfterTestExecutionCallback,
        AfterEachCallback,
        AfterAllCallback {

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        System.out.println("BeforeAllCallback");
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("    BeforeEachCallback");
    }

    @Override
    public void beforeTestExecution(ExtensionContext context) throws Exception {
        System.out.println("        BeforeTestExecutionCallback");
    }

    @Override
    public void afterTestExecution(ExtensionContext context) throws Exception {
        System.out.println("        AfterTestExecutionCallback");
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("    AfterEachCallback");
    }

    @Override
    public void afterAll(ExtensionContext context) throws Exception {
        System.out.println("AfterAllCallback");
    }
}

앞에서 만든 확장 클래스를 지정하며 만든 테스트 클래스는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.*;


@ExtendWith(MyLifeCycleCallback.class)
class MyLifeCycleCallbackTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("  beforeAll()");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("      beforeEach()");
    }

    @Test
    void test() {
        System.out.println("          test()");
    }

    @AfterEach
    void afterEach() {
        System.out.println("      afterEach()");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("  afterAll()");
    }
}

실행 결과:

BeforeAllCallback
  beforeAll()
    BeforeEachCallback
      beforeEach()
        BeforeTestExecutionCallback
          test()
        AfterTestExecutionCallback
      afterEach()
    AfterEachCallback
  afterAll()
AfterAllCallback
  • BeforeAllCallback
    • @BeforeAll 이전에 수행하는 프로세스를 구현할 수 있다.
  • BeforeEachCallback
    • @BeforeEach 이전에 수행하는 프로세스를 구현할 수 있다.
  • BeforeTestExecutionCallback
    • @BeforeEach 이후에 테스트 메소드 이전에 실행되는 처리를 구현할 수 있다.
  • AfterTestExecutionCallback
    • @AfterEach 이전에 테스트 메소드 이후에 실행되는 처리를 구현할 수 있다.
  • AfterEachCallback
    • @AfterEach 이후에 수행하는 프로세스를 구현할 수 있다.
  • AfterAllCallback
    • @AfterAll 이후에 수행하는 프로세스를 구현할 수 있다.

테스트 결과에 맞는 처리 수행

TestWatcher를 사용하면 테스트 결과에 맞는 처리를 구현할 수 있다.

package com.devkuma.junit5.extention;


import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;

import java.util.Optional;

public class MyTestWatcher implements TestWatcher, AfterEachCallback {

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        System.out.println("disabled : test=" + context.getDisplayName() + ", reason=" + reason.orElse("<empty>"));
    }

    @Override
    public void testSuccessful(ExtensionContext context) {
        System.out.println("successful : test=" + context.getDisplayName());
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        System.out.println("aborted : test=" + context.getDisplayName() + ", cause=" + cause);
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        System.out.println("failed : test=" + context.getDisplayName() + ", cause=" + cause);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("AfterEachCallback");
    }
}

앞에서 만든 확장 클래스를 지정하며 만든 테스트 클래스는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

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

@ExtendWith(MyTestWatcher.class)
class MyTestWatcherTest {

    @Test
    void testSuccessful() {
        System.out.println("testSuccessful()");
        assertEquals(10, 10);
    }

    @Test
    void testFailed() {
        System.out.println("testFailed()");
        assertEquals(10, 20);
    }

    @Test
    @Disabled("REASON")
    void testDisabled() {
        System.out.println("testDisabled()");
    }

    @Test
    void testAborted() {
        System.out.println("testAborted()");
        assumeTrue(false, "test abort");
    }
}

실행 결과:

testAborted()
AfterEachCallback
aborted : test=testAborted(), cause=org.opentest4j.TestAbortedException: Assumption failed: test abort
testSuccessful()
AfterEachCallback
successful : test=testSuccessful()
disabled : test=testDisabled(), reason=REASON
testFailed()
AfterEachCallback
failed : test=testFailed(), cause=org.opentest4j.AssertionFailedError: expected: <10> but was: <20>
  • TestWatcher에는 다음의 4개의 메소드가 정의되고 있다.
    • testDisabled()
      • 테스트가 유효하지 않을 때 실행된다.
    • testSuccessful()
      • 테스트가 성공할 때 실행된다.
    • testAborted()
      • 테스트가 중단(assumeThat() 등)될 때 실행된다.
    • testFailed()
      • 테스트가 실패할 때 실행된다.
  • 각 메소드는 내용이 빈어 있는 default 메소드로 정의된다.
    • 따라서 디폴트로 아무것도 처리되지 않는다.
    • 필요에 따라 각 메소드를 오버라이드(override)하여 구체적인 구현을 기술한다.
  • 각 메소드는 AfterEachCallback 이후에 실행된다.

테스트에서 발생한 예외 처리

TestExecutionExceptionHandler를 사용하면 테스트에서 발생한 예외를 핸들링 처리할 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;

public class MyTestExecutionExceptionHandler implements TestExecutionExceptionHandler {

    @Override
    public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
        System.out.println(" * throwable=" + throwable);
        if (throwable instanceof NullPointerException) {
            throw throwable;
        } else if (throwable instanceof IllegalStateException) {
            throw new UnsupportedOperationException("test");
        }
    }
}
  • NullPointerException를 받으면 그대로 throw를 한다.
  • IllegalStateException를 받으면 UnsupportedOperationException를 생성해서 throw를 한다.
  • 둘 다 아니면, 아무것도하지 않고 종료한다.
package com.devkuma.junit5.extention;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;

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

@ExtendWith(MyTestExecutionExceptionHandler.class)
class MyTestExecutionExceptionHandlerTest {

    @Test
    void success() {
        System.out.println("success()");
        assertEquals(10, 10);
    }

    @Test
    void fail() {
        System.out.println("fail()");
        assertEquals(10, 20);
    }

    @Test
    void throwsIOException() throws Exception {
        System.out.println("throwsIOException()");
        throw new IOException("test");
    }

    @Test
    void throwsNullPointerException() {
        System.out.println("throwsNullPointerException()");
        throw new NullPointerException("test");
    }

    @Test
    void throwsIllegalStateException() {
        System.out.println("throwsIllegalStateException()");
        throw new IllegalStateException("test");
    }
}

실행 결과: 이번에는 ConsoleLauncher를 사용하여 테스트를 실행시켰다.

% java -jar junit-platform-console-standalone-1.9.1.jar \
        -cp build/classes/java/test \
        -c com.devkuma.junit5.extention.MyTestExecutionExceptionHandlerTest

success()
fail()
 * throwable=org.opentest4j.AssertionFailedError: expected: <10> but was: <20>
throwsNullPointerException()
 * throwable=java.lang.NullPointerException: test
throwsIllegalStateException()
 * throwable=java.lang.IllegalStateException: test
throwsIOException()
 * throwable=java.io.IOException: test

Thanks for using JUnit! Support its development at https://junit.org/sponsoring

├─ JUnit Jupiter ✔
│  └─ MyTestExecutionExceptionHandlerTest ✔
│     ├─ success()│     ├─ fail()│     ├─ throwsNullPointerException()test
│     ├─ throwsIllegalStateException()test
│     └─ throwsIOException()├─ JUnit Vintage ✔
└─ JUnit Platform Suite ✔

Failures (2):
  JUnit Jupiter:MyTestExecutionExceptionHandlerTest:throwsNullPointerException()
    MethodSource [className = 'com.devkuma.junit5.extention.MyTestExecutionExceptionHandlerTest', methodName = 'throwsNullPointerException', methodParameterTypes = '']
    => java.lang.NullPointerException: test
       ...
  JUnit Jupiter:MyTestExecutionExceptionHandlerTest:throwsIllegalStateException()
    MethodSource [className = 'com.devkuma.junit5.extention.MyTestExecutionExceptionHandlerTest', methodName = 'throwsIllegalStateException', methodParameterTypes = '']
    => java.lang.UnsupportedOperationException: test
       com.devkuma.junit5.extention.MyTestExecutionExceptionHandler.handleTestExecutionException(MyTestExecutionExceptionHandler.java:14)
       ...
  • 테스트에서 예외가 발생하면 handleTestExecutionException()가 호출된다.
    • 두 번째 인수로 throw된 예외를 받을 수 있다.
    • 이 메소드가 예외를 throw 하면 테스트는 실패를 하게 된다.
    • 아무것도 예외를 throw하지 않고 종료하게 되면, 테스트는 성공이 된다(예외가 파악된다).
    • 받은 예외와는 다른 예외를 던지는 것도 가능하다.
  • 단언문 에러(AssertionFailedError)도 대상이므로 주의가 필요하다.
    • 다시 throw하는 것을 잊으면, 단언문 에러가 발생한다.

라이프 사이클 메서드에서 throw된 예외 처리

LifecycleMethodExecutionExceptionHandler를 사용하면 라이프 사이클 메소드에서 발생한 예외를 처리 할 수 ​​있다.

먼저 확장 모델로 예외가 발생시 처리를 비교하기 위해 TestExecutionExceptionHandlerMyLifecycleMethodExecutionExceptionHandler를 2개를 준비한다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;

public class MyTestExecutionExceptionHandler2 implements TestExecutionExceptionHandler {

    @Override
    public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
        System.out.println("[TestExecutionExceptionHandler] throwable=" + throwable);
    }
}
package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.LifecycleMethodExecutionExceptionHandler;

public class MyLifecycleMethodExecutionExceptionHandler implements LifecycleMethodExecutionExceptionHandler {

    @Override
    public void handleBeforeEachMethodExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
        System.out.println("[LifecycleMethodExecutionExceptionHandler] throwable=" + throwable);
        throw throwable;
    }

    @Override
    public void handleAfterEachMethodExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
        System.out.println("[LifecycleMethodExecutionExceptionHandler] throwable=" + throwable);
    }
}

앞에서 만든 확장 모델 클래스를 지정하며 만든 테스트 클래스는 아래와 같다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;

public class MyLifecycleMethodExecutionExceptionHandlerTest {
    @Nested
    class InnerClass1 {

        @Test
        @DisplayName("일반적인 라이프사이클 메서드에서 예외가 throw 된 경우")
        void test() {
            System.out.println("InnerClass1");
        }
    }

    @Nested
    @ExtendWith(MyLifecycleTestExecutionExceptionHandler.class)
    class InnerClass2 {

        @Test
        @DisplayName("TestExecutionExceptionHandler 는 라이프사이클 메소드로 슬로우 된 예외에 대해서 어떻게 동작할까?")
        void test() {
            System.out.println("InnerClass2");
        }
    }

    @Nested
    @ExtendWith(MyLifecycleMethodExecutionExceptionHandler.class)
    class InnerClass3 {

        @Test
        @DisplayName("라이프사이클 메소드로 throw 된 예외를 LifecycleMethodExecutionExceptionHandler 로 잡았을 경우")
        void test() {
            System.out.println("InnerClass3");
        }
    }

    @Nested
    @ExtendWith(MyLifecycleMethodExecutionExceptionHandler.class)
    class InnerClass4 {

        @BeforeEach
        void beforeEach(TestInfo testInfo) {
            throw new RuntimeException("beforeEach@" + simpleClassName(testInfo));
        }

        @Test
        @DisplayName("라이프사이클 메소드로 throw 된 예외를 LifecycleMethodExecutionExceptionHandler 로 잡지 않은 경우")
        void test() {
            System.out.println("InnerClass4");
        }
    }

    @AfterEach
    void afterEach(TestInfo testInfo) {
        throw new RuntimeException("afterEach@" + simpleClassName(testInfo));
    }

    static String simpleClassName(TestInfo testInfo) {
        return testInfo.getTestClass().map(Class::getSimpleName).orElse("<empty>");
    }
}

실행 결과:

% java -jar junit-platform-console-standalone-1.9.1.jar \
        -cp build/classes/java/test \
        -c com.devkuma.junit5.extention.MyLifecycleMethodExecutionExceptionHandlerTest
[LifecycleMethodExecutionExceptionHandler] throwable=java.lang.RuntimeException: beforeEach@InnerClass4
[LifecycleMethodExecutionExceptionHandler] throwable=java.lang.RuntimeException: afterEach@InnerClass4
InnerClass3
[LifecycleMethodExecutionExceptionHandler] throwable=java.lang.RuntimeException: afterEach@InnerClass3
InnerClass2
InnerClass1

Thanks for using JUnit! Support its development at https://junit.org/sponsoring

├─ JUnit Jupiter ✔
│  └─ MyLifecycleMethodExecutionExceptionHandlerTest ✔
│     ├─ InnerClass4 ✔
│     │  └─ 라이프사이클 메소드로 throw 된 예외를 LifecycleMethodExecutionExceptionHandler 로 잡지 않았던 경우 ✘ beforeEach@InnerClass4
│     ├─ InnerClass3 ✔
│     │  └─ 라이프사이클 메소드로 throw 된 예외를 LifecycleMethodExecutionExceptionHandler 로 잡지 않은 경우 ✔
│     ├─ InnerClass2 ✔
│     │  └─ TestExecutionExceptionHandler 는 라이프사이클 메소드로 슬로우 된 예외에 대해서 어떻게 동작할까? ✘ afterEach@InnerClass2
│     └─ InnerClass1 ✔
│        └─ 일반적인 라이프사이클 메서드에서 예외가 throw된 경우 ✘ afterEach@InnerClass1
├─ JUnit Vintage ✔
└─ JUnit Platform Suite ✔

...
  • LifecycleMethodExecutionExceptionHandler를 사용하면 라이프 사이클 메소드에서도 발생한 예외를 처리를 할 수 있다.
    • 앞전에서 설명한 TestExecutionExceptionHandler는 어디까지나 테스트 메소드 본체에서 예외가 발생했을 경우만 핸들링할 수 있다.
    • TestExecutionExceptionHandler는 라이프 사이클 메소드에서 예외가 발생하면 콜백되지 않는다.
  • LifecycleMethodExecutionExceptionHandler에는 네 가지 방법이 정의되어 있다.
    • 각 메소드는 각각 @BeforeAll, @BeforeEach, @AfterEach, @AfterAll에 대응된다.
    • 라이크 사이클 메서드에서 예외가 발생하면 해당 메서드가 호출된다.
    • 각 메소드는 디폴트 메소드로 정의되고 있어 디폴트에서는 받은 예외를 그대로 슬로우 해 재하게 되어 있다.
  • TestExecutionExceptionHandler의 경우와 마찬가지로 인수로 받은 예외를 다시 throw를 하지 않으면 예외를 잡을 수 있다.

동일한 테스트를 다른 컨텍스트에서 실행

TestTemplateInvocationContextProvider를 사용하면 동일한 테스트 메서드를 다른 컨텍스트에서 여러 번 실행할 수 있다.

MyTestTemplateInvocationContextProvider
package sample.junit5.extension;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;

import java.util.stream.Stream;

public class MyTestTemplateInvocationContextProvider implements TestTemplateInvocationContextProvider {
    @Override
    public boolean supportsTestTemplate(ExtensionContext context) {
        System.out.println("[supportsTestTemplate] displayName=" + context.getDisplayName());
        return context.getDisplayName().equals("test1()");
    }

    @Override
    public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
        System.out.println("[provideTestTemplateInvocationContexts] displayName=" + context.getDisplayName());
        return Stream.of(
            new MyTestTemplateInvocationContext(),
            new MyTestTemplateInvocationContext(),
            new MyTestTemplateInvocationContext()
        );
    }

    public static class MyTestTemplateInvocationContext implements TestTemplateInvocationContext {
    }
}

JUnit5Test

package sample.junit5;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import sample.junit5.extension.MyTestTemplateInvocationContextProvider;

@ExtendWith(MyTestTemplateInvocationContextProvider.class)
class JUnit5Test {

    @TestTemplate
    void test1() {
        System.out.println("test1()");
    }

    @TestTemplate
    void test2() {
        System.out.println("test2()");
    }

    @Test
    void test3() {
        System.out.println("test3()");
    }
}

실행 결과:

% java -jar junit-platform-console-standalone-1.9.1.jar \
        -cp build/classes/java/test \
        -c com.devkuma.junit5.extention.MyTestTemplateInvocationContextProviderTest
[supportsTestTemplate] displayName=test1()
[provideTestTemplateInvocationContexts] displayName=test1()
test1()
test1()
test1()
[supportsTestTemplate] displayName=test2()
test3()

Thanks for using JUnit! Support its development at https://junit.org/sponsoring

├─ JUnit Jupiter ✔
│  └─ MyTestTemplateInvocationContextProviderTest ✔
│     ├─ test1()│     │  ├─ [1]│     │  ├─ [2]│     │  └─ [3]│     ├─ test2() ✘ You must register at least one TestTemplateInvocationContextProvider that supports @TestTemplate method [void com.devkuma.junit5.extention.MyTestTemplateInvocationContextProviderTest.test2()]
│     └─ test3()├─ JUnit Vintage ✔
└─ JUnit Platform Suite ✔

Failures (1):
  JUnit Jupiter:MyTestTemplateInvocationContextProviderTest:test2()
    MethodSource [className = 'com.devkuma.junit5.extention.MyTestTemplateInvocationContextProviderTest', methodName = 'test2', methodParameterTypes = '']
    => org.junit.platform.commons.PreconditionViolationException: You must register at least one TestTemplateInvocationContextProvider that supports @TestTemplate method [void com.devkuma.junit5.extention.MyTestTemplateInvocationContextProviderTest.test2()]
...
  • 다른 컨텍스트에서 실행하려는 테스트는 @TestTemplate으로 어노테이션을 지정을 해야 한다.
  • @TestTemplate이 설정된 각 메소드에 대해 TestTemplateInvocationContextProvidersupportsTestTemplate()이 호출된다.
    • 대상의 테스트를 처리 대상으로 하는 경우 즉 지원을 하고 있는 경우라면 true를 반환하도록 구현한다.
    • @TestTemplate를 설정하는 동안 지원하는 TestTemplateInvocationContextProvider가 1개라도 존재하지 않으면 에러가 발생한다.
  • 지원한다면 provideTestTemplateInvocationContexts()이 호출된다.
    • 이 메서드는 TestTemplateInvocationContextStream을 반환하도록 구현한다.
    • TestTemplateInvocationContext이 테스트를 실행할 때 하나의 컨텍스트를 나타낸다.
    • 여러 컨텍스트에서 실행하는 경우는 여러 요소를 반환하도록 Stream을 구축한다.
    • 위의 구현 예제에서는 2개의 MyTestTemplateInvocationContext를 갖는 Stream을 반환하기에, test1() 메서드가 3번(3개의 컨텍스트)이 실행된다.

컨텍스트에서 표시 이름 지정

TestTemplateInvocationContext에는 getDisplayName() 메소드가 정의되어 있다. 이 메소드를 재정의하면 표시될 임의의 테스트 이름을 지정할 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;

import java.util.stream.Stream;

public class MyTestTemplateInvocationContextProvider implements TestTemplateInvocationContextProvider {
    @Override
    public boolean supportsTestTemplate(ExtensionContext context) {
        System.out.println("[supportsTestTemplate] displayName=" + context.getDisplayName());
        return context.getDisplayName().equals("test1()");
    }

    @Override
    public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
        System.out.println("[provideTestTemplateInvocationContexts] displayName=" + context.getDisplayName());
        return Stream.of(
                new MyTestTemplateInvocationContext("testName1"),
                new MyTestTemplateInvocationContext("testName2"),
                new MyTestTemplateInvocationContext("testName3")
        );
    }

    public static class MyTestTemplateInvocationContext implements TestTemplateInvocationContext {
        private final String name;

        public MyTestTemplateInvocationContext(String name) {
            this.name = name;
        }

        @Override
        public String getDisplayName(int invocationIndex) {
            return this.name + "[" + invocationIndex + "]";
        }
    }
}

실행 결과:

...
│  └─ MyTestTemplateInvocationContextProviderTest ✔
│     ├─ test1() ✔
│     │  ├─ testName1[1] ✔
│     │  ├─ testName2[2] ✔
│     │  └─ testName3[3] ✔
...
  • getDisplayName() 이 메소드는 디폴트 메소드로 정의되고 있어 디폴트 구현에서는 "[" + invocationIndex + "]"를 반환하도록 되어 있다.
    • invocationIndex는 현재 컨텍스트의 인덱스(1부터 시작)가 전달된다.
  • 이 메소드를 오버라이드(override) 하는 것으로, 임의의 표시명을 반환되게 할 수 있게 된다.

컨텍스트마다 임의의 확장 추가

TestTemplateInvocationContext에는 getAdditionalExtensions() 메소드가 정의되어 있다. 이 메소드를 재정의하면 원하는 확장을 임의로 추가 지정할 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.*;

import java.util.List;
import java.util.stream.Stream;

public class MyTestTemplateInvocationContextProvider implements TestTemplateInvocationContextProvider {
    @Override
    public boolean supportsTestTemplate(ExtensionContext context) {
        System.out.println("[supportsTestTemplate] displayName=" + context.getDisplayName());
        return context.getDisplayName().equals("test1()");
    }

    @Override
    public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
        System.out.println("[provideTestTemplateInvocationContexts] displayName=" + context.getDisplayName());

        return Stream.of(
                new MyTestTemplateInvocationContext("BeforeEach", (BeforeEachCallback) ctx -> {
                    System.out.println("beforeEachCallback()");
                }),
                new MyTestTemplateInvocationContext("AfterEach", (AfterEachCallback) ctx -> {
                    System.out.println("afterEachCallback()");
                })
        );
    }

    public static class MyTestTemplateInvocationContext implements TestTemplateInvocationContext {
        private final String name;
        private final Extension extension;

        public MyTestTemplateInvocationContext(String name,  Extension extension) {
            this.name = name;
            this.extension = extension;
        }

        @Override
        public String getDisplayName(int invocationIndex) {
            return this.name;
        }

        @Override
        public List<Extension> getAdditionalExtensions() {
            return List.of(this.extension);
        }
    }
}

실행 결과:

[supportsTestTemplate] displayName=test1()
[provideTestTemplateInvocationContexts] displayName=test1()
beforeEachCallback()
test1()
test1()
afterEachCallback()
[supportsTestTemplate] displayName=test2()
...

├─ JUnit Jupiter ✔
│  └─ MyTestTemplateInvocationContextProviderTest ✔
│     ├─ test1() ✔
│     │  ├─ BeforeEach ✔
│     │  └─ AfterEach ✔
  • getAdditionalExtensions() 이 메소드는 그 문맥으로 사용하는 확장 기능을 List<Extension>로 반환한다.
    • 이 메소드도 디폴트 메소드로 디폴트 구현에서는 비어 있는 List<Extension>를 반환하도록 되어 있다.
  • 위에 구현된 예제에서는 첫번째 컨텍스트에서는 BeforeEachCallback을, 두번째 컨텍스트에서는 AfterEachCallbak을 설정하도록 구현되어 있다.
  • ParameterResolver를 적용하면, 컨텍스트마다 다른 파라미터를 전달하여 같은 테스트 메소드를 실행하도록 할 수 있다.

확장 기능을 절차적으로 등록

@ExtendWith을 사용하는 방법의 경우 확장 기능 클래스의 조정은 기본적으로 정적이다.

  • 확장 기능을 구현한 클래스의 인스턴스는 Jupiter 에 의해 안에서 생성된다.
  • 그러기 때문에, 확장 기능 클래스의 인스턴스에 대해서 세세한 조정은 기본적으로 할 수 없다.

반면에 @RegisterExtension을 사용하면 확장 클래스의 조정을 동적으로 지정할 수 있다.

package sample.junit5.extension;

import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MyRegisterExtension implements BeforeEachCallback, BeforeAllCallback {
    private final String name;

    public MyRegisterExtension(String name) {
        this.name = name;
    }

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        System.out.println("[" + this.name + "] beforeAll()");
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("[" + this.name + "] beforeEach()");
    }
}

JUnit5Test

package sample.junit5;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import sample.junit5.extension.MyRegisterExtension;

class JUnit5Test {
    @RegisterExtension
    static MyRegisterExtension classField = new MyRegisterExtension("classField");
    @RegisterExtension
    MyRegisterExtension instanceField = new MyRegisterExtension("instanceField");

    @BeforeAll
    static void beforeAll() {
        System.out.println("beforeAll()");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("beforeEach()");
    }

    @Test
    void test1() {
        System.out.println("test1()");
    }
}

실행 결과:

[classField] beforeAll()
beforeAll()
[classField] beforeEach()
[instanceField] beforeEach()
beforeEach()
test1()
  • 사용할 확장 클래스의 인스턴스를 확장을 사용하려는 테스트 클래스의 필드(static or 인스턴스)로 선언한다.
    • 이 필드를 @RegisterExtension으로 어노테이션을 지정하면 해당 필드에 설정된 인스턴스를 확장으로 등록할 수 있다.
    • 필드에 대한 인스턴스 설정은 모든 프로그램에서 작성할 수 있으므로 자유롭게 조정된 인스턴스를 사용할 수 있다.
  • static으로 선언한 필드를 사용한 경우는 모든 확장 프로그램을 사용할 수 있다.
  • 인스턴스 필드를 사용하는 경우 BeforeAllCallback와 같은 클래스 수준 확장 기능과 TestInstancePostProcessor와 같은 인스턴스 수준 확장 기능을 사용할 수 없다.
    • 구현하더라도 무시된다.
    • BeforeEachCallback와 같은 메소드 레벨 확장 기능을 사용할 수 있다.

ServiceLoader를 사용하여 자동으로 등록

확장 기능은 ServiceLoader 메커니즘을 사용하여 자동으로 등록할 수도 있다.

먼저, 클래스 경로 아래에 /META-INF/services/ 디렉터리를 만들고 그 안에 org.junit.jupiter.api.extension.Extension라는 파일을 생성한다.

└── src
    └── test
        ├── java
        │   └── com
        │       └── devkuma
        │           └── junit5
        │               └── extention
        │                   └── JUnit5Test.java
        └── resources
            ├── META-INF
            │   └── services
            │       └── org.junit.jupiter.api.extension.Extension
            └── junit-platform.properties

org.junit.jupiter.api.extension.Extension 파일 안에는 등록하려는 확장 기능 클래스의 전체 도메인 네임(FQDN(Fully Qualified Domain Name)을 넣는다.
src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension

com.devkuma.junit5.extention.MyServiceLoaderExtension

여러 개를 등록해야 하는 경우에는 줄바꿈으로 나열할 수도 있다.

ServiceLoader를 사용하여 자동 등록을 사용하려면 구성 매개 변수 junit.jupiter.extensions.autodetection.enabledtrue로 지정해야 한다. src/test/resources/junit-platform.properties

junit.jupiter.extensions.autodetection.enabled=true

설정 파일(junit-platform.properties)로 지정하는 방법외에도, 아래 옵션을 JVM 시스템 속성에서 지정하는 방법도 있다.

–Djunit.jupiter.extensions.autodetection.enabled=true

이제 등록하려는 확장 기능 클래스를 작성하여 넣는다.

MyServiceLoader확장
package sample.junit5.extension;

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

public class MyServiceLoaderExtension implements BeforeEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("MyServiceLoaderExtension.beforeEach()");
    }
}

그러고 테스트 코드를 아래와 같이 작성해사 실행해 본다.

package sample.junit5;

import org.junit.jupiter.api.Test;

class JUnit5Test {

    @Test
    void test1() {
        System.out.println("test1()");
    }
}

실행 결과:

MyServiceLoaderExtension.beforeEach()
test1()

결과를 보면 아무런 확정 기능을 추가하지 않은 일반 테스트 코드인데, MyServiceLoaderExtensionbeforeEach() 메소드가 실행되었다.

Extension간에 데이터 공유

어느 Extension이 실행될 때 기록한 정보를 다른 Extension이 실행될 때 참조하는 방법을 생각해 보자.

예를 들어, BeforeEachCallback에서 테스트 방법의 시작 시간을 기록해두고, AfterEachCallback에서 현재 시간과 시작 시간의 차이에서 실행 시간을 출력한다고 하자.

확장 기능을 단일 클래스로 만들고 있다면 손쉽게 인스턴스 변수를 사용하는 방법이 떠오른다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MyStopwatch implements BeforeEachCallback, AfterEachCallback {

    private long startTime;

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        this.startTime = System.currentTimeMillis();
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        String displayName = context.getDisplayName();
        long endTime = System.currentTimeMillis();
        long time = endTime - this.startTime;
        System.out.println("[" + displayName + "] time=" + time + " (startTime=" + this.startTime + ", endTime=" + endTime + ")");
    }
}

그럼 다음과 같이 테스트에 적용해 실행해 본다.

package sample.junit5;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import sample.junit5.extension.MyStopwatch;

import java.util.concurrent.TimeUnit;

@ExtendWith(MyStopwatch.class)
class JUnit5Test {

    @Test
    void test1() throws Exception {
        TimeUnit.MILLISECONDS.sleep(200);
    }

    @Test
    void test2() throws Exception {
        TimeUnit.MILLISECONDS.sleep(400);
    }

    @Test
    void test3() throws Exception {
        TimeUnit.MILLISECONDS.sleep(600);
    }
}

실행 결과:

[test1()] time=208 (startTime=1671963041718, endTime=1671963041926)
[test2()] time=406 (startTime=1671963041943, endTime=1671963042349)
[test3()] time=606 (startTime=1671963042353, endTime=1671963042959)

이것으로 동작은 되었다. 그러나 이 구현에는 문제가 있다.

이 테스트를 병렬로 실행해 보면 문제가 발생하게 된다.
src/test/resources/junit-platform.properties

junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=2
junit.jupiter.execution.parallel.mode.default=concurrent

확실히 문제를 재현시키기 위하여 동시 병렬수를 2에 고정하였다.

실행 결과:

[test1()] time=212 (startTime=1671963149057, endTime=1671963149269)
[test3()] time=380 (startTime=1671963149287, endTime=1671963149667)
[test2()] time=405 (startTime=1671963149287, endTime=1671963149692)

test3()의 실행 시간이 300ms 정도가 되었다. 실제는 600ms sleep(600)을 실행했으면, 이 값은 있을 수 없다.

잘 보면, test3()test2()은 같은 startTime 값으로 되어 있는 것을 알 수 있다.
이는 즉, test3()의 시간 측정에 사용한 startTime이, test2()의 시작 시간이 되어 버리고 있는 것을 의미한다.

이 문제가 발생하는 원인은 startTime의 공유를 MyStopwatch의 인스턴스 변수로 수행하는 것이다.
MyStopwatchMyStopwatchTest 테스트가 실행되는 동안 동일한 인스턴스가 사용된다.
즉, 각 테스트 메소드가 실행될 때 사용되는 MyStopwatch는 모두 동일한 인스턴스가 된다.

test3()가 실행되면, beforeEach()시작 시간이 startTime에 기록된다. 그러나, 즉시 test2()병렬로 실행되고, startTime 값이 test2()의 시작 시간으로 덮어 쓰여진다. 그 결과로 인해 위와 같은 문제가 발생한다.

이와 같이, Extension 간의 데이터 공유에 구현 클래스의 인스턴스 변수를 사용하게 되면, 테스트의 실행 방법 나름으로 예기치 않은 문제가 일어날 가능성이 있다. (그 밖에도 다른 문제가 발생할 패턴이 있을지도 모르지만, 우선 생각할 있는 것은 이와 같은 병렬 실행의 케이스 정도이다.)

Store 사용

이 문제를 해결하기 위한 것인지는 모르지만, Store을 사용하면 병렬 실행되어도 문제가 발생하지 않도록 데이터 공유를 구현할 수 있다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MyStoreStopwatch implements BeforeEachCallback, AfterEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create("stopwatch"));
        store.put("startTime", System.currentTimeMillis());
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create("stopwatch"));
        long startTime = store.get("startTime", long.class);

        long endTime = System.currentTimeMillis();
        long time = endTime - startTime;

        String displayName = context.getDisplayName();
        System.out.println("[" + displayName + "] time=" + time + " (startTime=" + startTime + ", endTime=" + endTime + ")");
    }
}

실행 결과:

[test1()] time=210 (startTime=1671963856486, endTime=1671963856696)
[test3()] time=608 (startTime=1671963856486, endTime=1671963857094)
[test2()] time=406 (startTime=1671963856705, endTime=1671963857111)

test3()의 시간이 600ms 정도가 되어, 잘 동작하게 되었다. (startTimetest1()와 같은 이유는, 병렬수 2로 test1()test3()가 동시에 시작되었기 때문이므로 문제는 없다.)

이 구현에서는 Store 라는 구조를 이용하고 있다.
Store와는 ExtensionContext별로 준비된 데이터의 넣어서 임의의 데이터를 Key-Value 형식으로 보존할 수 있게 되어 있다.

ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create("stopwatch"));
store.put("startTime", System.currentTimeMillis());

...

long startTime = store.get("startTime", long.class);

Store의 인스턴스는 ExtensionContextgetStore(Namespace) 메소드로 받아올 수 있다.
인수에는 ExtensionContext.Namespace를 지정한다.

동작으로는 ExtensionContext안에는 복수의 Store가 저장되고 있어, 어느 Store을 받아올까를 Namespace로 지정하고 있는 방식이다. (실제로는 Namespace가 키의 일부로서 이용되고 있을 뿐이고, Store의 실제로 단일 Map이므로 구현되어 있다.)

Store의 Namespace

이와 같이 나누어 Namespace으로 Store 별로 있는 것으로, 같은 키를 사용하는 확장 기능이 복수 존재했을 경우도 Store를 나누어 데이터를 공유할 수 있다.
덧붙여서, 모든 확장 기능으로 데이터를 공유하고 싶다면 Namespace.GLOBAL이라는 미리 정의된 상수를 사용하는 방법도 있다.

Namespace의 생성에는 create(Object...) 메소드를 사용한다.
인수에는 임의의 오브젝트를 지정할 수 있지만, equals() 메소드로 비교 검증을 할 수 있는 오브젝트가 아니면 안된다.

Store 라이프사이클 및 키 검색 방법

Store의 라이프 사이클은 받아오는 곳인 ExtensionContext과 같이 동작한다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.*;

public class MyStoreExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, AfterAllCallback {

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        System.out.println("[beforeAll]");
        this.printStoreValues(context);

        ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create("foo"));
        store.put("hoge", "INITIAL VALUE");
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("[beforeEach@" + context.getDisplayName() + "]");
        this.printStoreValues(context);

        ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create("foo"));
        store.put("hoge", context.getDisplayName());
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("[afterEach@" + context.getDisplayName() + "]");
        this.printStoreValues(context);
    }

    @Override
    public void afterAll(ExtensionContext context) throws Exception {
        System.out.println("[afterAll]");
        this.printStoreValues(context);
    }

    private void printStoreValues(ExtensionContext context) {
        ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create("foo"));
        System.out.println("  hoge=" + store.get("hoge"));
        System.out.println("  context.class=" + context.getClass().getCanonicalName());
    }
}
  • beforeAll()beforeEach()에는 먼저, Store의 정보를 출력한 후 hoge`값을 설정한다.
  • 그리고, afterEach()afterAll()에서는 그대로 Store의 정보를 출력하고 있다.
  • 각각 ExtensionContext의 클래스명에 대해서도 맞추어 출력하도록 하고 있다.
package com.devkuma.junit5.extention;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;


@ExtendWith(MyStoreExtension.class)
class MyStoreExtensionTest {
    @Test
    void test1() throws Exception {}

    @Test
    void test2() throws Exception {}
}

실행 결과:

[beforeAll]
  hoge=null
  context.class=org.junit.jupiter.engine.descriptor.ClassExtensionContext
[beforeEach@test1()]
  hoge=INITIAL VALUE
  context.class=org.junit.jupiter.engine.descriptor.MethodExtensionContext
[afterEach@test1()]
  hoge=test1()
  context.class=org.junit.jupiter.engine.descriptor.MethodExtensionContext
[beforeEach@test2()]
  hoge=INITIAL VALUE
  context.class=org.junit.jupiter.engine.descriptor.MethodExtensionContext
[afterEach@test2()]
  hoge=test2()
  context.class=org.junit.jupiter.engine.descriptor.MethodExtensionContext
[afterAll]
  hoge=INITIAL VALUE
  context.class=org.junit.jupiter.engine.descriptor.ClassExtensionContext
  • BeforeAllCallback, AfterAllCallback와 같은 클래스 레벨의 Extension 에서는 ExtensionContext으로 ClassExtensionContext가 전달된다.
  • 한편, BeforeEachCallback, AfterEachCallback와 같은 메소드 레벨의 Extension에서는 MethodExtensionContext가 전달된다.
  • 이와 같이 ExtensionContext는 그 확장 기능이 실행되는 레벨에 맞는 구현 인스턴스가 전달된다.
  • 이런한 ExtensionContext에는 부모와 자식 관계가 있으며, MethodExtensionContext의 부모는 ClassExtensionContext이 된다.
    • 부모의 컨텍스트를 따라 가면 결국 JupiterEngineExtensionContext에 이르게 된다.
      • 부모의 컨텍스트는 ExtensionContextgetParent() 메소드로 얻을 수 있다.
    • JupiterEngineExtensionContext는 첫번째 부모(루트)의 컨텍스트이다.
      • 루트의 컨텍스트는 ExtensionContextgetRoot() 메소드로 얻을 수 있다.
    • 덧붙여 ExtensionContext구현 클래스에는 다른 TestTemplateExtensionContext, DynamicExtensionContext라는 것도 존재하지만 여기에서는 생략한다.
  • Store는 이러한 컨텍스트의 각 인스턴스별로 유지된다.
    • MethodExtensionContext는 테스트 메소드별로 생성된다.
    • 즉, test1()test2()MethodExtensionContext는 다른 인스턴스가 전달된다.
    • 그러므로 Store도 각각 별도가 된다.
    • 그 결과적으로 test1()에서 Store에 저장된 정보는 test2()에서 참고할 수가 없다.
    • 그래서 MyStopwatchStore를 사용하여 병렬 실행 시에 문제를 할 수 있었습니다.
      • BeforeEachCallback, AfterEachCallback는 메소드 레벨의 Extension
      • 실행되는 각 테스트 메소드에 대해 컨텍스트가 분리되므로 Store도 별도가 된다.
      • 이로 인해, 병렬 실행되어도 데이터가 충돌하지 않고, 정상적으로 동작할 수 있게 되어 있다.
  • 상위 컨텍스트의 Store에 저장된 정보는 하위 컨텍스트의 Store에서도 볼 수 있다.
    • 그리고 자식 컨텍스트 내에서 덮어 쓸 수 있다.
    • 그러나 덮어쓰기된 정보는 하위 컨텍스트의 범위가 종료되고 다른 컨텍스트로 이동하면 부모 컨텍스트에 설정된 원래 값으로 돌아간다.
      • test1()에서 설정 한 값이 test2()beforeEach()afterAll()의 단계에서는 원래의 "INITIAL VALUE"으로 돌아간다.
    • 이 동작은, 지정된 키의 정보가 Store에 존재하지 않았던 경우에, 부모 Store를 재귀적으로 검색하는 것으로 구현되어 있다.
    • 즉, 거기 MethodExtensionContextStore에 존재하지 않았던 경우는, 부모의 ClassExtensionContextStore를, 거기에도 없으면 루트 Store까지 가면서 검색하게 되어 있다.
    • 이렇게 하면 test1()에서 부모 컨텍스트에서 설정된 값을 참고할 수 있게 된다.
    • 그러나 자식 컨텍스트에서 Store에 값을 설정해도 부모 컨텍스트 Store는 그대로이므로 자식 컨텍스트가 끝나면 부모 컨텍스트 정보가 부 한 것처럼 동작한다.
  • ExtensionContextStore의 관계를 그림으로 표현하면 아래와 같다.

ExtensionContext와 Store의 관계

수명 주기 종료 시 처리 수행

CloseableResource를 구현한 인스턴스가 Store에 저장되어 있는 경우, 그 Store의 라이프 사이클이 종료할 때 close() 메소드가 자동적으로 호출된다.

먼저, CloseableResource를 상속받은 객체를 만든다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.ExtensionContext;

public class MyCloseableResource implements ExtensionContext.Store.CloseableResource {
    private final String name;

    public MyCloseableResource(String name) {
        this.name = name;
    }

    @Override
    public void close() throws Throwable {
        System.out.println("  Close Resource > " + this.name);
    }
}

그러고, CloseableResource 객체를 생성하여 활용하는 확장 기능을 만든다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class MyCloseableResourceExtension  implements BeforeEachCallback, BeforeAllCallback {

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        MyCloseableResource resource = new MyCloseableResource("BeforeAll");
        context.getStore(ExtensionContext.Namespace.GLOBAL).put("foo", resource);
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        MyCloseableResource resource = new MyCloseableResource("BeforeEach(" + context.getDisplayName() + ")");
        context.getStore(ExtensionContext.Namespace.GLOBAL).put("foo", resource);
    }
}

앞에서 만든 확장 기능을 추가한 테스트 코드를 작성한다.

package com.devkuma.junit5.extention;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(MyCloseableResourceExtension.class)
public class MyCloseableResourceExtensionTest {
    @Test
    void test1() throws Exception {
        System.out.println("test1()");
    }

    @Test
    void test2() throws Exception {
        System.out.println("test2()");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("afterAll()");
    }
}

실행 결과:

test1()
  Close Resource > BeforeEach(test1())
test2()
  Close Resource > BeforeEach(test2())
afterAll()
  Close Resource > BeforeAll



최종 수정 : 2024-03-17