Introduction to JUnit 5 Extensions

After we have finished this webinar, we:

  • Understand what JUnit 5 extensions are.
  • Can identify the extension points of a test lifecycle.
  • Understand how we can create our own JUnit 5 extensions.

A Quick Introduction to JUnit 5 Extension Model

A JUnit 5 extension must fulfill these three rules:

  • JUnit 5 extensions allow us to extend the behavior of a test class or a test method.
  • A JUnit 5 extension is always invoked during the specific phase of a test execution lifecycle. This phase is often referred as an extension point.
  • We can specify the phase in which our extension is invoked by implementing the correct extension interfaces.

When we want to register custom extensions, we have to follow these rules:

  • We can register custom extensions by annotating our test class with the @ExtendWith annotation.
  • If we want to register multiple custom extensions, we can either pass the registered extensions as the value of one @ExtendWith annotation or annotate our test class with multiple @ExtendWith annotations.
  • The registration order of our custom extensions determines the order in which the registered extensions are invoked.

Additional Reading:

Next, we will find out how we can create custom JUnit 5 extensions.

Creating Custom JUnit 5 Extensions

When we want to create a custom extension, we have to create a class that implements one or more interfaces which specify the used extension point. Next, we will take a look at five examples that help us to identify the supported extension points and understand what kind of problems we can solve by using custom extensions.

Injecting Dependencies Into a Test Object

If we want to inject dependencies into an instantiated test object, we have to write an extension class that implements the TestInstancePostProcessor interface. The extensions that implement this interface are invoked after JUnit 5 has created a new test object.

For example, if we want to create an extension that injects a Logger object into a test object, we can create our custom extension by following these steps:

  1. Create a class that implements the TestInstancePostProcessor interface.
  2. Write a method that finds a Field object which represents the Logger field of the created test object.
  3. Implement the postProcessTestInstance() method. We have to create a new Logger object and inject this object into the test object.

The source code of our custom extension class looks as follows:

 1import org.junit.jupiter.api.extension.ExtensionContext;
 2import org.junit.jupiter.api.extension.TestInstancePostProcessor;
 3import org.slf4j.Logger;
 4import org.slf4j.LoggerFactory;
 5
 6import java.lang.reflect.Field;
 7import java.util.Arrays;
 8
 9public class LoggerInjectorExtension implements TestInstancePostProcessor {
10
11    @Override
12    public void postProcessTestInstance(Object o,
13                                        ExtensionContext extensionContext) throws Exception {
14        Logger logger = LoggerFactory.getLogger(o.getClass());
15        Field loggerField = findLoggerField(o);
16        loggerField.setAccessible(true);
17        loggerField.set(o, logger);
18    }
19
20    private Field findLoggerField(Object source) {
21        return Arrays.asList(source.getClass().getDeclaredFields())
22                .stream()
23                .filter(f -> f.getType().isAssignableFrom(Logger.class))
24                .findAny()
25                .orElseThrow(() ->
26                        new RuntimeException("No Logger field found from the test class")
27                );
28    }
29}

Next, we have to write a test class that uses this extension and implement a test method that writes one line to the log. After we have written this test class, its source code looks as follows:

 1import org.junit.jupiter.api.DisplayName;
 2import org.junit.jupiter.api.Test;
 3import org.junit.jupiter.api.extension.ExtendWith;
 4import org.slf4j.Logger;
 5
 6@ExtendWith(LoggerInjectorExtension.class)
 7@DisplayName("Inject a Logger into our test object")
 8class LoggerTest {
 9
10    private Logger logger;
11
12    @Test
13    @DisplayName("Should write line to log")
14    void shouldWriteLineToLog() {
15        logger.info("Hello logger");
16    }
17}

When we run our test, we see that the correct line is written to the log:

120:06:29.215
2[main] INFO  com.testwithspring.intermediate.testinstance.LoggerTest - Hello logger

Let’s move on and find out how we can decide if a test method is run.

Deciding If a Test Method Is Run

If we want to run a test method or ignore it when a specific condition is true, we have to create an extension that implements the ExecutionCondition interface. For example, if we want to ignore all test methods whose name starts with the string: ‘ignore’, we can write our custom extension by following these steps:

  1. Create a new class that implements the ExecutionCondition interface and implement the evaluateExecutionCondition() method.
  2. Get the invoked test method.
  3. If a test method is found, ignore it if its name starts with the string: ‘ignore’ and run it if this is not the case.
  4. If no test method is found, return a result which signals that the evaluated container should be enabled.

The source code of our custom extension class looks as follows:

 1import org.junit.jupiter.api.extension.ConditionEvaluationResult;
 2import org.junit.jupiter.api.extension.ExecutionCondition;
 3import org.junit.jupiter.api.extension.ExtensionContext;
 4
 5import java.lang.reflect.Method;
 6import java.util.Optional;
 7
 8public class ConditionalTestRunnerExtension implements ExecutionCondition {
 9
10    @Override
11    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext extensionContext) {
12        Optional<Method> testMethod = extensionContext.getTestMethod();
13        if (testMethod.isPresent()) {
14            String methodName = testMethod.get().getName();
15            if (methodName.startsWith("ignore")) {
16                return ConditionEvaluationResult
17                        .disabled("The name of the test method starts with the string: ignore");
18            }
19            else {
20                return ConditionEvaluationResult
21                        .enabled("The name of the test method doesn't start with the string: ignore");
22            }
23        }
24        return ConditionEvaluationResult.enabled("No test method found");
25    }
26}

Next, we have to write a test class which contains one test method that should be run and one test method that should be ignored. The source code of our test class looks as follows:

 1import org.junit.jupiter.api.DisplayName;
 2import org.junit.jupiter.api.Test;
 3import org.junit.jupiter.api.extension.ExtendWith;
 4
 5@ExtendWith(ConditionalTestRunnerExtension.class)
 6@DisplayName("Run and ignore test methods")
 7class ConditionalTest {
 8
 9    @Test
10    @DisplayName("This test method should be run")
11    void runThisTest() {
12        System.out.println("This test should be run");
13    }
14
15    @Test
16    @DisplayName("This test method should be ignored")
17    void ignoreThisTest() {
18        System.out.println("This test should be ignored");
19    }
20}

When we run this test class, we see that JUnit 5 runs only one test method and ignores one test method:

1This test should be run
2The name of the test method starts with the string: ignore

Next, we will find out how we can resolve the method parameters of test methods.

Resolving the Method Parameters of Test Methods

If we want to resolve the method parameters that are passed to a test method, we have to create an extension which implements the ParameterResolver interface. For example, if we want to create an extension which passes the string: ‘Hello World!’ as a method parameter when our test method takes a String object as a method parameter, we have to follow these steps:

  1. Create a new class that implements the ParameterResolver interface.
  2. Implement the supportsParameter() method by returning true if the method parameter is a String.
  3. Implement the resolveParameter() method by returning the string: ‘Hello World!’.

The source code of our custom extension class looks as follows:

 1import org.junit.jupiter.api.extension.ExtensionContext;
 2import org.junit.jupiter.api.extension.ParameterContext;
 3import org.junit.jupiter.api.extension.ParameterResolutionException;
 4import org.junit.jupiter.api.extension.ParameterResolver;
 5
 6public class MessageResolverExtension implements ParameterResolver {
 7
 8    @Override
 9    public boolean supportsParameter(ParameterContext parameterContext,
10                                     ExtensionContext extensionContext) throws ParameterResolutionException {
11        return parameterContext.getParameter().getType().equals(String.class);
12    }
13
14    @Override
15    public Object resolveParameter(ParameterContext parameterContext,
16                                   ExtensionContext extensionContext) throws ParameterResolutionException {
17        return "Hello World!";
18    }
19}

Next, we have to write a test class which has one test method that takes a String object as a method parameter. After we have created our test class, its source code looks as follows:

 1import org.junit.jupiter.api.DisplayName;
 2import org.junit.jupiter.api.Test;
 3import org.junit.jupiter.api.extension.ExtendWith;
 4
 5import static org.junit.jupiter.api.Assertions.assertEquals;
 6
 7@ExtendWith(MessageResolverExtension.class)
 8@DisplayName("Resolve the values of String parameters")
 9class ParameterTest {
10
11    @Test
12    @DisplayName("Should pass correct message as a method parameter")
13    void shouldPassCorrectMessageAsMethodParameter(String message) {
14        assertEquals("Hello World!", message);
15    }
16}

Let’s find out how we can handle the exceptions thrown by the system under test.

Handling Exceptions Thrown by the System Under Test

If we want to handle the exceptions thrown by the system under test, we have to create an extension that implements the TestExecutionExceptionHandler interface. For example, if we want to create an extension that ignores all runtime exceptions, we have to follow these steps:

  1. Create a new class that implements the TestExecutionExceptionHandler interface.
  2. Implement the handleTestExecutionException() method by ignoring all runtime exceptions. Because we want to ensure that our extension works, we will also write a debug message to System.out.
  3. If the thrown exception is not a runtime exception, we have to throw it because this ensures that the test case fails.

The source code of our extension class looks as follows:

 1import org.junit.jupiter.api.extension.ExtensionContext;
 2import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
 3
 4public class IgnoreRuntimeExceptionExtension implements TestExecutionExceptionHandler {
 5
 6    @Override
 7    public void handleTestExecutionException(ExtensionContext context,
 8                                             Throwable throwable) throws Throwable {
 9        if (RuntimeException.class.isAssignableFrom(throwable.getClass())) {
10            System.out.println(String.format("Ignoring runtime exception: %s",
11                    throwable.getClass()
12            ));
13            return;
14        }
15
16        throw throwable;
17    }
18}

Next, we have to write a test class which has two test methods that should pass and one test method that should fail. The source code of our test class looks as follows:

 1import org.junit.jupiter.api.DisplayName;
 2import org.junit.jupiter.api.Test;
 3import org.junit.jupiter.api.extension.ExtendWith;
 4
 5@ExtendWith(IgnoreRuntimeExceptionExtension.class)
 6@DisplayName("Handle exceptions")
 7public class ExceptionTest {
 8
 9    @Test
10    @DisplayName("Should ignore the RuntimeException")
11    void shouldPassWhenRuntimeExceptionIsThrown() {
12        throw new RuntimeException();
13    }
14
15    @Test
16    @DisplayName("Should ignore the IllegalStateException")
17    void shouldPassWhenSubClassOfRunTimeExceptionIsThrown() {
18        throw new IllegalStateException();
19    }
20
21    @Test
22    @DisplayName("Should fail the test")
23    void shouldFailWhenExceptionIsThrown() throws Exception {
24        throw new Exception();
25    }
26}

When we run our test, we can see that JUnit 5 ignores the runtime exceptions and fails the test that throws an Exception:

1Ignoring runtime exception: class java.lang.RuntimeException
2
3java.lang.Exception
4	at com.testwithspring.intermediate.exception.ExceptionTest
5	.shouldFailWhenExceptionIsThrown(ExceptionTest.java:21)
6
7Ignoring runtime exception: class java.lang.IllegalStateException

Next, we will find out how we can use lifecycle callbacks.

Using Lifecycle Callbacks

If we want to do something when a specific phase of a test execution lifecycle is invoked, we have to use lifecycle callbacks. For example, if we want to reserve resources before a test method is run and free them after a test method has been run, it’s a good idea to create an extension which implements the required callback interfaces.

JUnit 5 provides the following callback interfaces:

  • The methods declared by the BeforeAllCallback and AfterAllCallback interfaces are invoked before any test method is run and after all test methods have been run.
  • The methods declared by the BeforeEachCallBack and AfterEachCallback interfaces are invoked before a test method is run and after a test method has been run.
  • The methods declared by the BeforeTestExecutionCallback and AfterTestExecutionCallback interfaces are invoked immediately before a test method is run and after a test method has been run.

Let’s demonstrate the invocation order of these callback methods by creating an extension that implements all lifecycle callback interfaces. The implementations of these methods simply write the name of the invoked method to System.out.

The source code of our extension class looks as follows:

 1import org.junit.jupiter.api.extension.*;
 2
 3public class LifecycleLoggerExtension implements AfterAllCallback,
 4        AfterEachCallback,
 5        AfterTestExecutionCallback,
 6        BeforeAllCallback,
 7        BeforeEachCallback,
 8        BeforeTestExecutionCallback  {
 9
10    @Override
11    public void afterAll(ExtensionContext context) {
12        System.out.println("afterAll() extension");
13    }
14
15    @Override
16    public void afterEach(ExtensionContext context) {
17        System.out.println("afterEach() extension");
18    }
19
20    @Override
21    public void afterTestExecution(ExtensionContext context) {
22        System.out.println("afterTestExecution() extension");
23    }
24
25    @Override
26    public void beforeAll(ExtensionContext context) {
27        System.out.println("beforeAll() extension");
28    }
29
30    @Override
31    public void beforeEach(ExtensionContext context) {
32        System.out.println("beforeEach() extension");
33    }
34
35    @Override
36    public void beforeTestExecution(ExtensionContext context) {
37        System.out.println("beforeTestExecution() extension");
38    }
39}

Next, we have to create a nested test class which uses our custom extension and has all setup and teardown methods. This test class has two test methods which are found from the LifecycleTest and A classes. Also, each method of this test class writes a unique identifier to System.out.

The source code of our test class looks as follows:

 1import org.junit.jupiter.api.*;
 2import org.junit.jupiter.api.extension.ExtendWith;
 3
 4@ExtendWith(LifecycleLoggerExtension.class)
 5@DisplayName("Demonstrate how lifecycle callback extension works")
 6class LifecycleTest {
 7
 8    @BeforeAll
 9    static void beforeAllonRootClass() {
10        System.out.println("beforeAllOnRootClass()");
11    }
12
13    @BeforeEach
14    void beforeEachOnRootClass() {
15        System.out.println("beforeEachOnRootClass()");
16    }
17
18    @Test
19    @DisplayName("Sample test on root class")
20    void testOnRootClass() {
21        System.out.println("testOnRootClass()");
22    }
23
24    @AfterEach
25    void afterEachOnRootClass() {
26        System.out.println("afterEachOnRootClass()");
27    }
28
29    @AfterAll
30    static void afterAllOnRootClass() {
31        System.out.println("afterAllOnRootClass()");
32    }
33
34    @Nested
35    @DisplayName("Tests for the method A")
36    class A {
37
38        @BeforeEach
39        void beforeEach() {
40            System.out.println("Before each test method of the A class");
41        }
42
43        @AfterEach
44        void afterEach() {
45            System.out.println("After each test method of the A class");
46        }
47
48        @Test
49        @DisplayName("Sample test for method A")
50        void sampleTestForMethodA() {
51            System.out.println("Sample test for method A");
52        }
53    }
54}

When we run our test class, we see an output that looks as follows:

 1beforeAll() extension
 2beforeAllOnRootClass()
 3beforeEach() extension
 4beforeEachOnRootClass()
 5beforeTestExecution() extension
 6testOnRootClass()
 7afterTestExecution() extension
 8afterEachOnRootClass()
 9afterEach() extension
10beforeAll() extension
11beforeEach() extension
12beforeEachOnRootClass()
13Before each test method of the A class
14beforeTestExecution() extension
15Sample test for method A
16afterTestExecution() extension
17After each test method of the A class
18afterEachOnRootClass()
19afterEach() extension
20afterAll() extensionafterAllOnRootClass()
21afterAll() extension

This output reveals us two things:

  • The most lifecycle callback methods are invoked before and after the setup and teardown methods found from our test class.
  • The methods declared by the BeforeTestExecutionCallback and AfterTestExecutionCallback interfaces are invoked after and before the setup and teardown methods found from our test class.

We can now create custom JUnit 5 extensions. Let’s summarize what we learned from this webinar.

Summary

This webinar has taught us six things:

  • JUnit 5 extensions allow us to extend the behavior of a test class or a test method.
  • If we want to inject dependencies into an instantiated test object, we have to write an extension class that implements the TestInstancePostProcessor interface.
  • If we want to run a test method or ignore it when a specific condition is true, we have to create an extension that implements the ExecutionCondition interface.
  • If we want to resolve the method parameters that are passed to a test method, we have to create an extension which implements the ParameterResolver interface.
  • If we want to handle the exceptions thrown by the system under test, we have to create an extension that implements the TestExecutionExceptionHandler interface.
  • If we want to do something when a specific phase of a test execution lifecycle is invoked, we have to use lifecycle callbacks.

Get the source code from Github