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
Storefor 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)?
