How to Replace Rules in JUnit 5
The recently published JUnit 5 (aka JUnit Lambda) alpha release caught my interest and while skimming through the documentation I noticed that rules are gone – as well as runners and class rules. According to the documentation, these partially competing concepts have been replaced by a single consistent extension model.
Over the years, Frank and I wrote several rules to help with recurring tasks like testing SWT UIs, ignoring tests in certain environments, registering (test) OSGi services, running tests in separate threads, and some more.
Therefore I was particularly interested in what it would take to transform existing rules to the new concept so that they could run natively on JUnit 5. To explore the capabilities of extensions I picked two rules with quite different characteristics and tried to migrate them to JUnit 5.
The focus of these experiments is to see what concepts have changed between rules and extenions. Therefore I chose to rewrite the JUnit 4 means with no backwards compatibility in mind.
If you are interested in migrating from JUnit 4 to 5 or explore possibilities to run existing rules in JUnit 5 you may want to join the respective discussions.
The first candidate is the ConditionalIgnoreRule that works in tandem with the @ConditionalIgnore annotation. The rule evaluates a condition that needs to be specified with the annotation and based thereon decides whether the test is executed or not.
The other candidate is the built-in TemporaryFolder rule. Like the name suggests, it allows to create files and folders that are deleted when the test finishes.
Therefore it hooks in before and after the test execution to create a root directory to store files and folders in and to clean up this directory. In addition it provides utility methods to create files and folders within the root directory.
Extensions Explained
Before going into the details of migration rules to extensions, let’s have a brief look at the new concept.
The test execution follows a certain life cycle. And each phase of that life cycle that can be extended is represented by an interface. Extensions can express interest in certain phases in that they implement the corresponding interface(s).
With the ExtendWith
annotation a test method or class can express that it requires a certain extension at runtime. All extensions have a common super interface: ExtensionPoint
. The type hierarchy of ExtensionPoint
lists all places that extension currently can hook in.
The code below for example , applies a fictional MockitoExtension
that injects mock objects:
@ExtendWith(MockitoExtension.class) class MockTest { @Mock Foo fooMock; // initialized by extension with mock( Foo.class ) }
The MockitoExtension
would provide a default constructor so that it can be instantiated by the runtime and implement the necessary extension interface(s) to be able to inject mocks into all @Mock
annotated fields.
Conditional Ignore Rule Extension
A recurring pattern for rules is to provide a service in tandem with an annotation that is used to mark and/or configure test methods that wish to use the service. Here the ConditionalIgnoreRule examines all test methods that it runs with and looks for a ConditinalIgnore annotation. If such an annotation is found, its condition is evaluated and if satisfied, the test is ignored.
Here is how the ConditionalIgnoreRule may look like in action:
@Rule public ConditionalIgnoreRule rule = new ConditionalIgnoreRule(); @Test @ConditionalIgnore( condition = IsWindowsPlatform.class ) public void testSomethingPlatformSpecific() { // ... }
And now, let’s see how the code should look like in JUnit 5:
@Test @DisabledWhen( IsWindowsPlatform.class ) void testSomethingPlatformSpecific() { // ... }
First you’ll note that the annotation changed its name. To match the JUnit 5 conventions that use the term disabled instead of ignored, the extension also changed its name to DisabledWhen
.
Though the DisabledWhen annotation is driven by the DisabledWhenExtension, there is no sight of nothing that declares that the extension is necessary. The reason therefor is called meta annotations and they are best illustrated when looking at how DisabledWhen is declared:
@Retention(RetentionPolicy.RUNTIME) @ExtendWith(DisabledWhenExtension.class) public @interface DisabledWhen { Class<? extends DisabledWhenCondition> value(); }
The annotation is (meta) annotated with the extension that handles it. And at runtime, the JUnit 5 test executor takes care of the rest. If an annotated test method is encountered and this annotation is in turn meta-annotated by ExtendWith
, the respective extension is instantiated and included into the life cycle.
Really neat, innit? This trick also avoids an oversight when annotating a test method without specifying the corresponding rule.
Behind the scenes, the DisabledWhenExtension
implements the TestExexutionCondition
interface. For each test method, its sole evaluate()
method is called and must return a ConditionEvaluationResult
that determines if or if not a test should be executed.
The rest of the code is basically the same as before. The DisabledWhen
annotation is looked up and when found, an instance of the specified condition class is created and asked whether the test should execute or not. If execution is declined a disabled ConditionEvaluationResult
is returned and the framework acts accordingly.
TemporaryFolder Rule Extension
Before turning the TemporaryFolder rule into an exception, let’s have a look at what the rule consists of. First the rule provisions and cleans up a temporary folder during test setup and teardown. But it also provides the test with access to methods to create (temporary) files and folders within that root folder.
After migrating to an extension the different responsibilities become even more apparent. The following example shows how it might be used:
@ExtendWith(TemporaryFolderExtension.class) class InputOutputTest private TemporaryFolder tempFolder; @Test void testThatUsesTemporaryFolder() { File file = tempFolder.newFile(); // ... } }
The TemporaryFolderExtension
hooks into the test execution life cycle in order to provision and clean up the temporary folder and also to supply all TemporaryFolder
fields with an instance of this type. Whereas the TemporaryFolder
gives access to methods to create files and folders within a root folder.
In order to inject TemporaryFolder
s, the extension implements the InstancePostProcessor
interface. Its postProcessTestInstance
method is called right after a test instance is created. Within that method it has access to the test instance via the TestExtensionContext
parameter and can inject a TemporaryFolder
into all matching fields.
For the unlikely event that a class declares multiple TemporaryFolder
fields, each field is assigned a new instance and each of them has its own root folder.
All injected TemporaryFolder
instances created in this process are held in a collection so that they an be accessed later when it’s time to clean up.
To clean up after the test was executed, another extension interface needs to be implemented: AfterEachExtensionPoint
. Its sole afterEach
method is called after each test is done. And the TemporaryFolderExtension
implementation hereof cleans up all known TemporaryFolder
instances.
Now that we are on par with the features of the TemporaryFolder
rule, there is also new feature to support: method level dependency injection.
In JUnit 5, methods are now permitted to have parameters.
This means that our extension should not only be able to inject fields but also method parameters of type TemporaryFolder
.
A test that wishes to create temporary files could request to have a TemporaryFolder
injected like in the following example:
class InputOutputTest { @Test @ExtendWith(TemporaryFolderExtension.class) void testThatUsesTemporaryFolder( TemporaryFolder tempFolder ) { File file = tempFolder.newFile(); // ... } }
By implementing the MethodParameterResolver
interface, an extension can participate in resolving method parameters. For each parameter of a test method the extenion’s supports()
method is called to decide if it can provide a value for the given parameter. In case of the TemporaryFolderExtension
the implementation checks whether the parameter type is a TemporaryFolder
and returns true
in this case. If a broader context is necessary, the supports()
method is also provided with the current method invocation context and extension context.
Now that the extension decided to support a certain parameter, its resolve()
method must provide a matching instance. Again, the surrounding contexts are provided. The TemporaryFolderExtension
simply returns a unique TemporaryFolder
instance that knows the (temporary) root folder and provides methods to create files and sub-folders therein.
Note however, that it is considered an error to declare a parameter that cannot be resolved. Consequently, if a parameter without a matching resolver is encountered an exception is raised.
Storing State in Extensions
As you may have noticed, the TemporaryFolderExtension
maintains its state (i.e. the list of temporary folders it has created) currently a simple field. While the tests have shown that this works in practice, the documentations nowhere states that the same instance is used throughout invoking the different extensions. Hence, if JUnit 5 changes its behavior at this point, state may well be lost during these invocations.
The good news is that JUnit 5 provides a means to maintain state of extensions called Store
s. As the documentation puts it, they provide methods for extensions to save and retrieve data.
The API is similar to that of a simplified Map
and allows to store key-value pairs, get the value associated with a given key, and remove a given key. Keys and values both can be arbitrary objects. The store can be reached through the TestExtensionContext
that is passed as a parameter to each extension method (e.g. beforeEach
, afterEach
).Each TestExtensionContext
instance encapsulates the context in which the current test is being executed.
In beforeEach
, for example, a value would be stored within the extension context like this:
@Override public void beforeEach( TestExtensionContext context ) { context.getStore().put( KEY, ... ); }
And could later be retrieved like this:
@Override public void afterEach( TestExtensionContext context ) { Store store = context.getStore(); Object value = store.get( KEY ); // use value... }
To avoid possible name clashes, stores can be created for a certain namespaces. The context.getStore()
method used above obtains a store for the default namespace. To get a store for a specific namespace, use
context.getStore( Namespace.of( MY, NAME, SPACE );
A namespace is defined through an array of objects, { MY, NAME, SPACE }
in this example.
The exercise to rework the TemporaryFolderExtension
to use a Store
is left to the reader.
Running the Code
- A spike implementation of the two extensions discussed here can be found in this GitHub repository: https://github.com/rherrmann/junit5-experiments
The project is set up to be used in Eclipse with Maven support installed. But it shouldn’t be difficult to compile and run the code in other IDEs with Maven support.
Quite naturally at this early state, there is no support to run JUnit 5 tests directly in Eclipse yet. Therefore, to run all tests, you may want use the Run all tests with ConsoleRunner launch configuration. If you run into trouble please consult the Running Tests with JUnit 5 section of my previous post about JUnit 5 for a few more hints or leave a comment.
Concluding How to Replace Rules in JUnit 5
Throughout this little experiment I got the impression that extensions are a decent and complete replacement for rules and friends in JUnit 4. And finally, using the new methods is fun and feels much more concise than the existing facilities.
If you find a use case that can’t be accomplished with extensions yet, I’m sure the JUnit 5 team will be grateful if you let them know.
But note however, that as of this writing extensions are work in progress. The API is marked as experimental and may change without prior notice. Thus it might be a bit early to actually migrate your JUnit 4 helpers right now – unless you don’t mind to adjust your code to the potentially changing APIs.
If JUnit 5 extensions have caught your interest you may also want to continue reading the respective chapter of the documentation.
Reference: | How to Replace Rules in JUnit 5 from our JCG partner Rudiger Herrmann at the Code Affine blog. |