How do I create a custom JUnit extension?

JUnit 5 provides a powerful extension model that lets you hook into the test lifecycle. Here’s a comprehensive guide to creating custom extensions.

1. Choose the Right Extension Interface

JUnit 5 offers several extension interfaces depending on what you want to do:

Interface Purpose
BeforeAllCallback / AfterAllCallback Run code before/after all tests in a class
BeforeEachCallback / AfterEachCallback Run code before/after each test
BeforeTestExecutionCallback / AfterTestExecutionCallback Wrap the actual test method execution
ParameterResolver Inject parameters into test methods
TestExecutionExceptionHandler Handle exceptions thrown by tests
TestWatcher Observe test results (passed, failed, skipped)
InvocationInterceptor Intercept method invocations

2. Basic Example: A Timing Extension

Here’s an extension that measures test execution time:

package com.example.testing;

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;

import java.lang.reflect.Method;

public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final Namespace NAMESPACE = Namespace.create(TimingExtension.class);
    private static final String START_TIME = "start_time";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        Method testMethod = context.getRequiredTestMethod();
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;

        System.out.printf("Method [%s] took %d ms.%n", testMethod.getName(), duration);
    }

    private Store getStore(ExtensionContext context) {
        return context.getStore(NAMESPACE);
    }
}

3. Parameter Resolver Example

Injecting custom parameters into test methods:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;

import java.util.UUID;

public class RandomUuidParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext)
            throws ParameterResolutionException {
        return parameterContext.getParameter().getType() == UUID.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext)
            throws ParameterResolutionException {
        return UUID.randomUUID();
    }
}

4. TestWatcher Example

Observing test outcomes:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;

import java.util.Optional;

public class TestResultLogger implements TestWatcher {

    @Override
    public void testSuccessful(ExtensionContext context) {
        System.out.println("✔ " + context.getDisplayName() + " passed");
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        System.out.println("✘ " + context.getDisplayName() + " failed: " + cause.getMessage());
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        System.out.println("⚠ " + context.getDisplayName() + " aborted");
    }

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        System.out.println("⊘ " + context.getDisplayName() + " disabled: " + reason.orElse("no reason"));
    }
}

5. Registering the Extension

There are three ways to register your extension:

a) Declarative with @ExtendWith

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.UUID;

@ExtendWith({TimingExtension.class, RandomUuidParameterResolver.class})
class MyServiceTest {

    @Test
    void shouldDoSomething(UUID uniqueId) {
        System.out.println("Test running with ID: " + uniqueId);
    }
}

b) Programmatic with @RegisterExtension

Useful when the extension needs configuration:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class MyServiceTest {

    @RegisterExtension
    static TimingExtension timingExtension = new TimingExtension();

    @Test
    void shouldDoSomething() {
        // ...
    }
}

c) Automatic Registration via ServiceLoader

Create the file src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension containing:

com.example.testing.TimingExtension

Then enable auto-detection in junit-platform.properties:

junit.jupiter.extensions.autodetection.enabled=true

6. Creating a Custom Annotation (Meta-Annotation)

You can bundle extensions into a custom annotation:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtendWith;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({TimingExtension.class, TestResultLogger.class})
public @interface IntegrationTest {
}

Usage:

@IntegrationTest
class MyIntegrationTest {
    @Test
    void something() { /* ... */ }
}

7. Sharing State with the Store

The ExtensionContext.Store lets you share state between callbacks safely across parallel tests. Always use a unique Namespace to avoid collisions:

Namespace ns = Namespace.create(MyExtension.class, context.getRequiredTestMethod());
Store store = context.getStore(ns);
store.put("key", value);

Key Tips

  • Prefer composition — implement multiple interfaces on the same class if the concerns are related.
  • Use Store for state rather than instance fields, because JUnit may create new instances or use the extension across parallel tests.
  • Respect lifecycle order — class-level callbacks fire before method-level ones.
  • Make extensions stateless when possible to be safe in parallel execution.

Would you like me to help you build a specific extension for your project (e.g., one that integrates with Spring, Jakarta EE, or manages test data)?

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.