Migrating JUnit4 Rules to JUnit5
Writing tests is time consuming and can contain many repetitive tasks like setting up the specific configuration and cleaning the context after the test. To best follow the DRY (Don't Repeat Yourself) principle, I started implementing JUnit4 rules. In this blog post, I will show how I migrated a JUnit4 rule to JUnit5.
Domain Event
When an aggregate changes state, a domain event is fired. The event can then be handled in other parts of the code as needed. In this example, we will use the Spring event mechanism. The event bus can simply be called via a static method.
class Item {
String value;
void update(String newValue) {
value = newValue;
EventPublisher.publish(new DomainEvent(value));
}
}
You can think of Item
as a Hibernate entity, or a domain aggregate.
As Item
is not managed by Spring, it cannot directly access the application context.
This is why we use a static method EventPublisher.publish
.
The EventPublisher
class could be as simple as
@Component
class EventPublisher {
static ApplicationEventPublisher eventPublisher;
EventPublisher(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
static void publish(DomainEvent event) {
eventPublisher.publishEvent(event);
}
static void set(ApplicationEventPublisher publisher){
eventPublisher = publisher;
}
}
Once Spring has instantiated the bean, the static method can access the ApplicationEventPublisher
.
This solution is simple and practical.
However, it does have a big problem:
In this setup, you can't test your Item
aggregate without Spring.
So, simple light-weighted unit testing cannot be done.
So what do we need in order to properly test the Item
aggregate?
- Replace the event publisher with a Mockito mock. (This is what the set method is for.)
- Setup the mock before a test run.
- Make your assertions on the event.
- Tear down the mock after the test.
@Test
void testDomainEvent() {
ApplicationEventPublisher mock = mock(ApplicationEventPublisher.class);
EventPublisher.set(mock);
new Item().update("newValue");
verify(mock).publishEvent(any(DomainEvent.class));
reset(mock);
}
We first set the mock event publisher and verify that an event of the correct type has been fired.
The reset(mock)
prepares the mock for further use.
At this point, we can see the problem clearly. For every test we need to implement this pattern. The worst part of doing this is that the eventPublisher is static, so the value will remain for all tests to come. We will replace this code with a JUnit4 Rule.
JUnit4 Rules
JUnit4 has two distinct extension mechanisms: rules and runners. One of the motivations for JUnit5 was to unify the two in a single extension model.
Using a rule, the test becomes clearer to read:
class RuleTest {
@Rule
EventRule eventRule = new EventRule();
@Test
void name() {
new Item().update("newValue");
eventRule.verifyEventType(DomainEvent.class);
}
}
As you can see most of the boilerplate code is gone. The JUnit4 rule looks like this:
class EventRule extends ExternalResource {
ApplicationEventPublisher mockPublisher;
void before() {
mockPublisher = mock(ApplicationEventPublisher.class);
EventPublisher.set(mockPublisher);
}
void after() {
reset(mockPublisher);
}
void verifyEventType(Class<? extends DomainEvent> expectedType) {
ArgumentCaptor<DomainEvent> captor = ArgumentCaptor.forClass(DomainEvent.class);
Mockito.verify(mockPublisher).publishEvent(captor.capture());
DomainEvent value = captor.getValue();
assertThat(value.getClass())
.as("Found event: %s should have been one of: %s", value, expectedType)
.isEqualTo(expectedType);
}
}
The before
method instantiates the mock and sets it to the event publisher.
The after
method resets the mock.
The verifyEventType
asserts that the event fired has the correct type.
The same mockPublisher
is used in all three methods.
With rules, we can simply use class variables.
Now that you are familiar with the JUnit4 rules, let's look at how to implement the same with JUnit5.
JUnit5
Before diving into how to implement the rule with JUnit5, let's have a quick look at the extension mechanism.
Extensions
In JUnit5, we can hook into the execution at every step of the execution of a test. E.g., before all tests in a class, after each test in a class and so on. At each of these points in the execution life cycle, we can register extensions.
There are five types of extension points:
- test instance post-processing
- conditional test execution
- life-cycle callbacks
- parameter resolution
- exception handling
Each extension point is represented by an interface.
All extension interfaces extend a common tagging interface: Extension
In this post, we will only touch the life-cycle callbacks and the test instance post-processing.
With the ExtendWith
annotation a test method or class loads a certain extension.
JUnit5 Test
The JUnit5 test is very similar to the JUnit4 version, with a few key changes.
@ExtendWith(EventExtension.class)
class ExtensionTest {
EventExtension.EventAssert assertEvent;
@Test
void extensionTest() {
new Item().update("newValue");
assertEvent.verifyPublished(DomainEvent.class);
}
}
In order to bootstrap the event extension, we have to annotate the test class with @ExtendWith
.
The assertEvent
field allows tests to interact with an extension for asserting the event type.
Event Extension
In our test case we will have to do the following:
- Instantiate the
mockPublisher
before all tests. - Inject an instance of
EventExtension.EventAssert
in our test class. - Reset the
mockPublisher
between tests.
Instantiate the Mock Publisher
The instantiation of the mock publisher happens in the beforeAll
method which comes from the BeforeAllCallback
interface.
void beforeAll(ExtensionContext context) {
ApplicationEventPublisher mockPublisher = mock(ApplicationEventPublisher.class);
context.getStore(NAMESPACE).put(KEY, mockPublisher);
EventPublisher.set(mockPublisher);
}
The first line creates a mock application event publisher.
The second line saves the mock.
Contrary to JUnit4, the extension class instances are not kept from one invocation to the next.
States must be stored in the JUnit5 store which is accessible through the context.
The third line sets the mock to the EventPublisher.eventPublisher
variable.
This is ultimately the object the business code will call.
Inject Event Assert
In order to inject EventAssert
into our test class, we have to implement the postProcessTestInstance
from the TestInstancePostProcessor
extension interface.
void postProcessTestInstance(Object testInstance, ExtensionContext context) {
Arrays.stream(testInstance.getClass().getDeclaredFields())
.filter(field -> field.getType() == EventAssert.class)
.forEach(field -> injectAssert(testInstance, field, getPublisher(context)));
}
void injectAssert(Object testInstance, Field field, ApplicationEventPublisher mockPublisher) {
field.setAccessible(true);
field.set(testInstance, new EventAssert(mockPublisher));
}
As you can see, the test class instance is passed to the method in the postProcessTestInstance
method.
It might be a little confusing, but this method just iterates over all declared fields in the class and checks if it is of type EventAssert
.
If it is, then we inject it into the field.
Note that getPublisher(context)
retrieves the mock publisher from the store.
ApplicationEventPublisher getPublisher(ExtensionContext context) {
return context.getStore(NAMESPACE).get(KEY, ApplicationEventPublisher.class);
}
Reset the Mock Publisher
The afterTestExecution
method from the AfterTestExecutionCallback
is invoked after each test.
void afterTestExecution(ExtensionContext context) {
reset(getPublisher(context));
}
The Mockito reset
method is called so the mock publisher can be reused in the next test.
Conclusion
This was a short introduction to JUnit5 extension mechanism.
A nice feature related to this is that we can pass our assertion field (EventExtension.EventAssert assertEvent
) as a parameter to our test instead of keeping it on the test class.
class MethodExtensionTest {
@Test
@ExtendWith(EventExtension.class)
void extensionTest(EventExtension.EventAssert assertEvent) {
new Item().update("newValue");
assertEvent.verifyPublished(DomainEvent.class);
}
}
Another extension point from the ParameterResolver
interface is implemented.
boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
return parameterContext.getParameter().getType() == EventAssert.class;
}
Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) throws ParameterResolutionException {
return new EventAssert(getPublisher(context));
}
The first method allows to introspect the parameters and find one of the correct type. The second method sets the value of the parameter.
There are, of course, more extension points, like those for conditional testing and exception test execution. It's important to realize that it is worth investing some time to make our tests more readable, and JUnit5 provides us so many more options to do this.
The source code corresponding to this blog can be found at GitHub.
In that, the complete EventExtension
code has been added to make it easier to see the whole story:
public class EventExtension implements BeforeAllCallback, AfterTestExecutionCallback, TestInstancePostProcessor, ParameterResolver {
private static final Logger LOGGER = LoggerFactory.getLogger(EventExtension.class);
private static final Class<EventPublisher> KEY = EventPublisher.class;
private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(EventExtension.class);
@Override
public void beforeAll(ExtensionContext context) {
LOGGER.debug("BeforeAllCallback -> beforeAll");
}
@Override
public void afterTestExecution(ExtensionContext context) {
LOGGER.debug("AfterTestExecutionCallback -> afterTestExecution");
reset(getPublisher(context));
}
@Override
public void postProcessTestInstance(Object testInstance, ExtensionContext context) {
LOGGER.debug("TestInstancePostProcessor -> postProcessTestInstance");
Arrays.stream(testInstance.getClass().getDeclaredFields())
.filter(field -> field.getType() == EventAssert.class)
.forEach(field -> injectAssert(testInstance, field, getPublisher(context)));
}
private ApplicationEventPublisher getPublisher(ExtensionContext context) {
ApplicationEventPublisher publisher = context.getStore(NAMESPACE).get(KEY, ApplicationEventPublisher.class);
if(publisher == null){
ApplicationEventPublisher mockPublisher = mock(ApplicationEventPublisher.class);
context.getStore(NAMESPACE).put(KEY, mockPublisher);
EventPublisher.set(mockPublisher);
}
return context.getStore(NAMESPACE).get(KEY, ApplicationEventPublisher.class);
}
private void injectAssert(Object testInstance, Field field, ApplicationEventPublisher mockPublisher) {
try {
field.setAccessible(true);
field.set(testInstance, new EventAssert(mockPublisher));
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
LOGGER.debug("ParameterResolver -> supportsParameter");
return parameterContext.getParameter().getType() == EventAssert.class;
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) throws ParameterResolutionException {
LOGGER.debug("ParameterResolver -> resolveParameter");
return new EventAssert(getPublisher(context));
}
public static class EventAssert {
private final ApplicationEventPublisher mockPublisher;
public EventAssert(ApplicationEventPublisher mockPublisher) {
this.mockPublisher = mockPublisher;
}
public void verifyPublished(Class<? extends DomainEvent> expectedType) {
ArgumentCaptor<DomainEvent> captor = ArgumentCaptor.forClass(DomainEvent.class);
verify(mockPublisher).publishEvent(captor.capture());
DomainEvent value = captor.getValue();
assertThat(value.getClass())
.as("Found event: %s should have been one of: %s", value, expectedType)
.isEqualTo(expectedType);
}
}
}