In this post we will learn how JUnit 5 extensions work and how we can create custom extensions.
A look into the past: Runners and Rules
With JUnit 4 we have the option to run tests with a custom JUnit runner (indicated by the @RunWith annotation). This allows us to modify the way tests are executed with JUnit. However, JUnit runners are not that easy to implement. They also suffer on the major limitation that only one runner can be used in a test.
With JUnit 4.7 Rules were introduced. Rules use a different concept to customize tests. It is also possible to use multiple rules within a test. So from this point JUnit 4 had two different ways (with different up and downsides) to customize test behavior.
JUnit 5 introduces extensions
This whole customization mechanism has changed with JUnit 5 which introduced extensions. Extensions can be added to tests in various ways. The most common way is the @ExtendWith annotation that can be used on test classes or on single test methods. For example:
@ExtendWith(MyFirstExtension.class) public class DemoTest { @Test public void test() { // uses MyFirstExtension } @Test @ExtendWith(MySecondExtension.class) public void test2() { // uses MyFirstExtension and MySecondExtension } }
Extensions added to the test class will be used for all test methods within the class.
Multiple extensions can be registered by passing an array of extensions to the @ExtendWith annotation:
@ExtendWith({ MyFirstExtension.class, MySecondExtension.class }) public class DemoTest { ... }
@ExtendWith is also a repeatable annotation, so it can be added multiple times:
@ExtendWith(MyFirstExtension.class) @ExtendWith(MySecondExtension.class) public class DemoTest { ... }
Note that @ExtendWith can be composed to other annotations. For example, we can come up with our own annotation that is annotated with @ExtendWith:
@Retention(RetentionPolicy.RUNTIME) @ExtendWith(MockWebServerExtension.class) @ExtendWith(MockDatabaseExtension.class) @Target(ElementType.TYPE) public @interface IntegrationTest {}
We can now annotate our test with @IntegrationTest and JUnit 5 will run the tests using the two extensions defined in @IntegrationTest:
@IntegrationTest public class DemoTest { ... }
While the @ExtendWith annotation is easy to use and works fine in most situations it has a drawback. Sometimes test code needs to interact with an extension or the extension might need some sort of configuration or setup code. This cannot be done if the extension is defined with @ExtendWith.
In these situations we can create the extension manually, assign it to a field and add the @RegisterExtension annotation. For example lets look at a fictional extension that manages temporary files in a test:
public class DemoTest { @RegisterExtension static TempFileExtension tempFiles = TempFileExtension.builder() .setDirectory("/tmp") .deleteAfterTestExecution(true) .build(); @Test public void test() { File f = tempFiles.newTempFile("foobar.tmp"); ... } }
Using a @RegisterExtension on a field gives us the option to configure the extension and to interact with the extension in test methods.
Creating custom extensions
Creating a custom extension for JUnit 5 is quite easy. We just have to create a class that implements one or more of JUnits extension interfaces.
Assume we want to create a simple extension that measures how long a test runs. For this we create a new class that implements the interface InvocationInterceptor.
public class TestDurationReportExtension implements InvocationInterceptor { @Override public void interceptTestMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable { long beforeTest = System.currentTimeMillis(); try { invocation.proceed(); } finally { long afterTest = System.currentTimeMillis(); long duration = afterTest - beforeTest; String testClassName = invocationContext.getTargetClass().getSimpleName(); String testMethodName = invocationContext.getExecutable().getName(); System.out.println(String.format("%s.%s: %dms", testClassName, testMethodName, duration)); } } }
InvocationInterceptor has various methods with default implementations. We override the implementation of interceptTestMethod(..). This method lets us run code before and after a test method is executed. With the proceed() method of the Invocation method parameter we can proceed with the actual test execution.
We simply subtract the system time before the test from the system time after the test execution to get the duration. After that, we use the InvocationContext parameter to obtain the names of the test class and test method. With this information we create a formatted output message.
Now we can extend tests with our TestDurationReportExtension by using the @ExtendWith annotation:
@ExtendWith(TestDurationReportExtension.class) public class DemoTest { .. }
When running tests, we will now see our extension output for every test method.
The output for a test with two methods might look like this:
DemoTest.slowTest: 64ms DemoTest.fastTest: 6ms
Extension interfaces
InvocationInterceptor is just one various extension interfaces. In this section, we will briefly look over these different interfaces and for what they can be used.
Conditional test execution
By implementing the interface ExecutionCondition an extension can decide if a test should be executed. This lets the extension decide if certain tests should be skipped. A simple example is the standard extension DisabledCondition that skips tests annotated with @Disabled.
Test instance factories
By default JUnit 5 will instantiate test classes by invoking the available constructor (if multiple test constructors are available an exception will be thrown). Possible constructor arguments are resolved using ParameterResolver extensions (see below).
This default behavior can be customized using the TestInstanceFactory interface. An Extension that implements TestInstanceFactory is used as factory for creating test class instances. This is can be used to create Tests via static factory methods or to inject additional parameters into the test constructor.
Processing test instances
After a test instance has been created, the TestInstancePostProcessor interface can be used to post process test instances. A common extension use case for this is the injection of dependencies into fields of the test instance. Similarly the TestInstancePreDestroyCallback can be used to run custom cleanup logic, when a test has finished and the instance is no longer needed.
Test parameter resolution
Test class constructors or methods annotated with @Test, @BeforeEach, @BeforeAll etc. can contain parameters. These parameters are resolved at runtime by JUnit using ParameterResolvers. Extensions can implement ParameterResolver if they want to support additional parameters.
Test Lifecycle callbacks and interceptions
JUnit 5 provides a couple of test lifecycle callback interfaces that can be implemented by extensions:
- BeforeAllCallback, runs before @BeforeAll methods in the test class
- BeforeEachCallback, runs before @BeforeEach methods in the test class
- BeforeTestExecutionCallback, runs before the test method
- AfterTestExecutionCallback, runs after the test method
- AfterEachCallback, runs after @AfterEach methods in the test class
- AfterAllCallback, runs after @AfterAll methods in the test class
Those interfaces provide a simple callback to do something at a certain time in the test lifecycle.
Additionally there is the InvocationInterceptor interface we already used in the extension example above. InvocationInterceptor has similar methods as the callback interfaces. However, InvocationInterceptor gives us an Invocation parameter that allows us to manually continue the lifecycle by calling the proceed() method. This is useful if we want to wrap code around the invocation, like a try/catch block.
Summary
Writing extensions for JUnit 5 is quite easy. We just have to create a class that implements one or more of JUnits extension interfaces. Extensions can be added to test classes (or methods) using the @ExtendWith and @RegisterExtension annotations. You can find the source code for the example extension on GitHub. Also make sure to checkout the excellent JUnit 5 user guide.
Comments
koan - Saturday, 22 August, 2020
Great article, concise and informative!
Leave a reply