mscharhag, Programming and Stuff;

A blog about programming and software development topics, mostly focused on Java technologies including Java EE, Spring and Grails.

Tuesday, 11 August, 2020

Extending JUnit 5 (Jupiter)

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