The @ExtendWith Extension Mechanism
JUnit 5 reimagined how Java tests are extended. Where earlier versions relied on the restrictive @RunWith annotation and the TestRule interface, JUnit 5 introduces a clean, interface-driven extension model. At the center of this model sits @ExtendWith, the annotation that registers custom behavior with the test engine.
Under JUnit 4, extending test behavior usually meant supplying a custom Runner. The problem was structural: a test class could only be associated with a single Runner. If you wanted Spring's test support and Mockito's mock initialization at the same time, you were often stuck. Composing multiple extensions required awkward workarounds or third-party bridges.
JUnit 5 abandons the "one runner per class" idea entirely. Instead, it defines a series of extension points—well-defined moments in the test lifecycle where custom logic can plug in. @ExtendWith acts as the registration mechanism, telling JUnit which extension classes should apply to a test class, interface, or method.
Core Extension Interfaces and the Test Lifecycle
The power of @ExtendWith comes from the interfaces it activates. JUnit 5 provides several callback interfaces, each corresponding to a specific phase of test execution:
BeforeAllCallbackandAfterAllCallback: Run once before all tests in a class begin, or after they all finish.BeforeEachCallbackandAfterEachCallback: Execute before and after every individual test method.BeforeTestExecutionCallbackandAfterTestExecutionCallback: Hook in immediately before and after the test method body runs, making them ideal for timing, profiling, or resource setup.ParameterResolver: Supplies arguments to test constructors or methods, enabling dependency injection patterns such as automatic@Mockinjection.TestExecutionExceptionHandler: Intercepts exceptions thrown during test execution for centralized handling or transformation.
By implementing one or more of these interfaces, a developer gains precise control over when and how custom logic runs.
Registering Extensions with @ExtendWith
@ExtendWith can be placed on a test class, a test interface, or an individual test method. When applied at the class level, the extension is active for every test method within that class.
Here is a typical usage:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ExtendWith(TimingExtension.class)
class CalculatorTests {
@Test
void testAddition() {
Calculator calculator = new Calculator();
assertEquals(2, calculator.add(1, 1));
}
}
Multiple extensions can be registered together by passing an array of classes:
@ExtendWith({TimingExtension.class, DatabaseExtension.class})
class MultiExtensionTests {
// ...
}
When several extensions apply to the same test, JUnit coordinates their execution order according to well-defined rules, so developers do not need to manage sequencing manually.
Building a Custom Extension: A Timing Example
To see how this works in practice, let us build a simple extension that measures how long each test method takes to execute.
Implementing the Callbacks
The extension needs to capture a timestamp before the test runs and calculate the elapsed time afterward. This maps directly to BeforeTestExecutionCallback and AfterTestExecutionCallback.
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.lang.reflect.Method;
import java.util.logging.Logger;
public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final Logger logger = Logger.getLogger(TimingExtension.class.getName());
private static final String START_TIME_KEY = "start_time";
@Override
public void beforeTestExecution(ExtensionContext context) throws Exception {
Method testMethod = context.getRequiredTestMethod();
getStore(context, testMethod).put(START_TIME_KEY, System.currentTimeMillis());
}
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
Method testMethod = context.getRequiredTestMethod();
long startTime = getStore(context, testMethod).remove(START_TIME_KEY, long.class);
long duration = System.currentTimeMillis() - startTime;
String className = context.getRequiredTestClass().getSimpleName();
logger.info(() -> String.format("Test [%s.%s] completed in %d ms", className, testMethod.getName(), duration));
}
private ExtensionContext.Store getStore(ExtensionContext context, Method testMethod) {
return context.getStore(ExtensionContext.Namespace.create(getClass(), testMethod));
}
}
A few details matter here. The ExtensionContext.Store is used to persist the start time between the before and after calls. Scoping the store by test method ensures that timing data does not leak between different tests or test classes. The logger uses a supplier so that string formatting only happens when the log level is actually enabled.
Applying the Extension
Once the class is written, using it requires only the annotation:
@ExtendWith(TimingExtension.class)
class UserServiceTests {
@Test
void testCreateUser() {
// test logic
}
@Test
void testDeleteUser() {
// test logic
}
}
Every test method in UserServiceTests now reports its execution time automatically. The test code itself remains free of timing logic, which keeps the test focused on behavior rather than instrumentation.
Why This Model Matters
The @ExtendWith mechanism solves the composition problem that plagued JUnit 4. Because extensions are small, interface-based components, they can be combined freely. A project can apply Spring's test extension, Mockito's extension, a custom logging extension, and a performance monitoring extension all to the same test class without conflict.
Beyond composition, the model encourages separation of concerns. Cross-cutting concerns such as transaction management, temporary directory creation, or mock initialization live in extension classes rather than being scattered across individual test methods. This makes tests easier to read and maintain.
Conclusion
@ExtendWith is more than a replacement for @RunWith. It is the foundation of JUnit 5's extensibility, offering a standardized way to hook into the test lifecycle. Whether you are integrating with a framework, injecting parameters, or building custom diagnostics, mastering @ExtendWith gives you the tools to write cleaner, more maintainable tests.