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
자체는 마커 인터페이스이어서 메서드가 정의되어 있지 않다.
마커 인터페이스
마커 인터페이스(marker interface)란, 일반적인 인터페이스와 동일하지만, 아무 메서드도 선언하지 않은 인터페이스이다. 자바의 대표적인 마커 인터페이스로는Serializable
, Cloneable
, EventListener
가 있다. 대부분의 경우 마커 인터페이스를 단순한 타입 체크를 하기 위해 사용한다.
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)
메소드가 호출되므로 대상 테스트를 실행할지 여부를 반환 값으로 제어한다.- 실행하는 경우는
ConditionEvaluationResult.enabled(String)
로 생성한 값을 반환한다. - 실행하지 않는 경우는
ConditionEvaluationResult.disabled(String)
로 생성한 값을 반환한다. - 인수에는 대상을 활성화/비활성화한 이유를 넣는다.
- 실행하는 경우는
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
에서 정의한 내부 테스트 클래스가 있는 경우는 일단 외부 클래스의 생성을 해야 하기 때문에, 호출된 후에는 내부 테스트 클래스 생성을 위해서 한번 더 메소드가 호출된다.- 내부 클래스일 경우에는
TestInstanceFactoryContext
의getOuterInstance()
가 비어 있지 않기 때문에 그것으로 판단할 수 있다.
- 내부 클래스일 경우에는
테스트 인스턴스 생성 후 초기화 처리
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
를 사용하면 라이프 사이클 메소드에서 발생한 예외를 처리 할 수 있다.
먼저 확장 모델로 예외가 발생시 처리를 비교하기 위해 TestExecutionExceptionHandler
와 MyLifecycleMethodExecutionExceptionHandler
를 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
이 설정된 각 메소드에 대해TestTemplateInvocationContextProvider
의supportsTestTemplate()
이 호출된다.- 대상의 테스트를 처리 대상으로 하는 경우 즉 지원을 하고 있는 경우라면
true
를 반환하도록 구현한다. @TestTemplate
를 설정하는 동안 지원하는TestTemplateInvocationContextProvider
가 1개라도 존재하지 않으면 에러가 발생한다.
- 대상의 테스트를 처리 대상으로 하는 경우 즉 지원을 하고 있는 경우라면
- 지원한다면
provideTestTemplateInvocationContexts()
이 호출된다.- 이 메서드는
TestTemplateInvocationContext
의Stream
을 반환하도록 구현한다. 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.enabled
를 true
로 지정해야 한다.
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()
결과를 보면 아무런 확정 기능을 추가하지 않은 일반 테스트 코드인데, MyServiceLoaderExtension
의 beforeEach()
메소드가 실행되었다.
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
의 인스턴스 변수로 수행하는 것이다.
MyStopwatch
는 MyStopwatchTest
테스트가 실행되는 동안 동일한 인스턴스가 사용된다.
즉, 각 테스트 메소드가 실행될 때 사용되는 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 정도가 되어, 잘 동작하게 되었다.
(startTime
가 test1()
와 같은 이유는, 병렬수 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
의 인스턴스는 ExtensionContext
의 getStore(Namespace)
메소드로 받아올 수 있다.
인수에는 ExtensionContext.Namespace
를 지정한다.
동작으로는 ExtensionContext
안에는 복수의 Store
가 저장되고 있어, 어느 Store
을 받아올까를 Namespace
로 지정하고 있는 방식이다.
(실제로는 Namespace
가 키의 일부로서 이용되고 있을 뿐이고, Store
의 실제로 단일 Map
이므로 구현되어 있다.)
이와 같이 나누어 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
에 이르게 된다.- 부모의 컨텍스트는
ExtensionContext
의getParent()
메소드로 얻을 수 있다.
- 부모의 컨텍스트는
JupiterEngineExtensionContext
는 첫번째 부모(루트)의 컨텍스트이다.- 루트의 컨텍스트는
ExtensionContext
의getRoot()
메소드로 얻을 수 있다.
- 루트의 컨텍스트는
- 덧붙여
ExtensionContext
구현 클래스에는 다른TestTemplateExtensionContext
,DynamicExtensionContext
라는 것도 존재하지만 여기에서는 생략한다.
- 부모의 컨텍스트를 따라 가면 결국
Store
는 이러한 컨텍스트의 각 인스턴스별로 유지된다.MethodExtensionContext
는 테스트 메소드별로 생성된다.- 즉,
test1()
와test2()
의MethodExtensionContext
는 다른 인스턴스가 전달된다. - 그러므로
Store
도 각각 별도가 된다. - 그 결과적으로
test1()
에서Store
에 저장된 정보는test2()
에서 참고할 수가 없다. - 그래서
MyStopwatch
가Store
를 사용하여 병렬 실행 시에 문제를 할 수 있었습니다.BeforeEachCallback
,AfterEachCallback
는 메소드 레벨의 Extension- 실행되는 각 테스트 메소드에 대해 컨텍스트가 분리되므로
Store
도 별도가 된다. - 이로 인해, 병렬 실행되어도 데이터가 충돌하지 않고, 정상적으로 동작할 수 있게 되어 있다.
- 상위 컨텍스트의
Store
에 저장된 정보는 하위 컨텍스트의Store
에서도 볼 수 있다.- 그리고 자식 컨텍스트 내에서 덮어 쓸 수 있다.
- 그러나 덮어쓰기된 정보는 하위 컨텍스트의 범위가 종료되고 다른 컨텍스트로 이동하면 부모 컨텍스트에 설정된 원래 값으로 돌아간다.
test1()
에서 설정 한 값이test2()
의beforeEach()
와afterAll()
의 단계에서는 원래의"INITIAL VALUE"
으로 돌아간다.
- 이 동작은, 지정된 키의 정보가
Store
에 존재하지 않았던 경우에, 부모Store
를 재귀적으로 검색하는 것으로 구현되어 있다. - 즉, 거기
MethodExtensionContext
의Store
에 존재하지 않았던 경우는, 부모의ClassExtensionContext
의Store
를, 거기에도 없으면 루트Store
까지 가면서 검색하게 되어 있다. - 이렇게 하면
test1()
에서 부모 컨텍스트에서 설정된 값을 참고할 수 있게 된다. - 그러나 자식 컨텍스트에서
Store
에 값을 설정해도 부모 컨텍스트Store
는 그대로이므로 자식 컨텍스트가 끝나면 부모 컨텍스트 정보가 부 한 것처럼 동작한다.
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