Migrating JUnit4 Rules to JUnit5

May 10, 2020

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?

  1. Replace the event publisher with a Mockito mock. (This is what the set method is for.)
  2. Setup the mock before a test run.
  3. Make your assertions on the event.
  4. 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:

Each extension point is represented by an interface. All extension interfaces extend a common tagging interface: Extension

JUnit5 extension points

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:

  1. Instantiate the mockPublisher before all tests.
  2. Inject an instance of EventExtension.EventAssert in our test class.
  3. 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);
        }
    }
}
About the author: Frank Bommeli

Frank is a passionate skier and loves travelling. A convinced clean code and clean architect practitioner.

Comments
Join us