Appearance
JUnit 5: Extensions in Depth
The Operating System Device Driver Analogy
Imagine purchasing a new graphics card or high end audio interface for your computer. The operating system does not rewrite its core kernel to accommodate every new device released on the market. Instead, the operating system defines a standardized driver interface. As long as the hardware manufacturer implements that interface — providing functions for initialization, memory management, and shutdown — the operating system can load the driver seamlessly at runtime, extending its capabilities without altering a single line of core kernel code.
The JUnit 5 Extension Model works exactly like that driver architecture. In JUnit 4, extending test behavior required @RunWith(SpringRunner.class) or custom @Rule implementations. Those mechanisms suffered from a severe limitation: you could only define a single @RunWith runner per test class, making it impossible to combine tools like Spring, Mockito, and custom benchmarking in the same suite. JUnit 5 abolished runners and rules entirely, replacing them with a unified, modular Extension API.
This lecture covers the complete JUnit 5 extension architecture, the core callback interfaces, the three ways to register extensions, and how to build a production grade custom benchmark extension from scratch.
Why JUnit 5 Replaced Runners and Rules
In JUnit 4, extending test behavior relied on two competing mechanisms:
- Runners (
@RunWith): Allowed deep customization of test execution. However, a test class could declare only one runner. If you needed bothSpringJUnit4ClassRunnerandParameterized, you had to choose one or write complex adapter code. - Rules (
@Ruleand@ClassRule): Allowed intercepting test lifecycle events. However, rules had awkward type safety boundaries, could not intercept method parameter injection, and could not easily participate in class level lifecycle events without duplicating logic.
JUnit 5 unified everything into the Extension API (org.junit.jupiter.api.extension.Extension). An extension can participate in every phase of the test lifecycle, and you can apply as many extensions as you want to a single test class:
java
// JUnit 5: Combine multiple extensions freely on the same class
@ExtendWith(TimingExtension.class)
@ExtendWith(MockitoExtension.class)
@ExtendWith(DatabaseCleanupExtension.class)
class OrderProcessingTest {
// All three extensions cooperate seamlessly
}The Extension Hierarchy and Core Callback Interfaces
All JUnit 5 extensions implement marker interface org.junit.jupiter.api.extension.Extension. The Jupiter test engine invokes your extension by checking which specific sub interfaces your class implements:
Extension (Marker Interface)
|
+------------------------------+-------------------------------+
| | |
ExecutionCondition ParameterResolver TestInstancePostProcessor
| |
Lifecycle Callbacks: Exception Handling:
- BeforeAllCallback - TestExecutionExceptionHandler
- BeforeEachCallback
- BeforeTestExecutionCallback
- AfterTestExecutionCallback
- AfterEachCallback
- AfterAllCallback1. Lifecycle Callback Interfaces
These interfaces let you execute logic at specific milestones during test execution:
| Interface | Method to Implement | Execution Point |
|---|---|---|
BeforeAllCallback | beforeAll(ExtensionContext) | Runs once before any tests or @BeforeAll methods |
BeforeEachCallback | beforeEach(ExtensionContext) | Runs before each test method and before @BeforeEach |
BeforeTestExecutionCallback | beforeTestExecution(ExtensionContext) | Runs immediately before the test method itself, after all setup |
AfterTestExecutionCallback | afterTestExecution(ExtensionContext) | Runs immediately after the test method itself, before any cleanup |
AfterEachCallback | afterEach(ExtensionContext) | Runs after each test method and after @AfterEach |
AfterAllCallback | afterAll(ExtensionContext) | Runs once after all tests and after @AfterAll |
2. Conditional Test Execution: ExecutionCondition
Implement ExecutionCondition to enable or disable tests dynamically based on system state, environment variables, or database flags:
java
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;
public class DisallowOnFridayCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
boolean isFriday = java.time.LocalDate.now().getDayOfWeek() == java.time.DayOfWeek.FRIDAY;
if (isFriday) {
return ConditionEvaluationResult.disabled("Deployment tests are prohibited on Fridays");
}
return ConditionEvaluationResult.enabled("Permitted to execute");
}
}3. Parameter Injection: ParameterResolver
Implement ParameterResolver to supply custom arguments to test constructors or @Test methods (this is how @Mock parameter resolution works under the hood):
java
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
public class RandomUserParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return parameterContext.getParameter().getType().equals(TestUser.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return new TestUser("generated-user-" + System.currentTimeMillis());
}
}The Three Ways to Register Extensions
JUnit 5 provides three distinct mechanisms for registering extensions:
1. Declarative Registration (@ExtendWith)
The most common approach. Place @ExtendWith on the test class or on individual test methods:
java
@ExtendWith(TimingExtension.class)
class UserServiceTest {
// Uses TimingExtension
}2. Programmatic Registration (@RegisterExtension)
Use @RegisterExtension on a field when your extension requires constructor parameters or custom runtime configuration that cannot be passed through an annotation:
java
class DatabaseTest {
// Must be static if participating in class level lifecycle callbacks
@RegisterExtension
static EmbeddedPostgresExtension db = new EmbeddedPostgresExtension(5432, "test_db");
@Test
void testQuery() {
Connection conn = db.getConnection();
assertNotNull(conn);
}
}3. Automatic Global Registration (Java ServiceLoader SPI)
To enable an extension across all test classes in an entire project without adding annotations to every file:
- Enable auto detection in
src/test/resources/junit-platform.properties:propertiesjunit.jupiter.extensions.autodetection.enabled=true - Register your extension class in
src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension:com.example.testing.GlobalLoggingExtension
Building a Custom Performance Timing Extension
Here is a complete, production grade custom extension that measures and logs the exact execution duration of each test method using ExtensionContext.Store:
java
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 java.lang.reflect.Method;
public class ExecutionTimeLoggerExtension
implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final String START_TIME = "start_time";
private ExtensionContext.Store getStore(ExtensionContext context) {
// Isolate data per test method within a private namespace
return context.getStore(ExtensionContext.Namespace.create(
getClass(),
context.getRequiredTestMethod()
));
}
@Override
public void beforeTestExecution(ExtensionContext context) {
long startTime = System.currentTimeMillis();
getStore(context).put(START_TIME, startTime);
}
@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.println(String.format(
"[BENCHMARK] Method '%s' completed in %d ms",
testMethod.getName(),
duration
));
}
}To apply it to your test suite:
java
@ExtendWith(ExecutionTimeLoggerExtension.class)
class HighThroughputServiceTest {
@Test
void processLargeBatch() throws InterruptedException {
Thread.sleep(150);
assertTrue(true);
}
}Console output:
[BENCHMARK] Method 'processLargeBatch' completed in 152 msInterview Questions & Pitfalls
Q1: How does JUnit 5's extension model differ from JUnit 4's runners and rules?
JUnit 4 limited test classes to a single @RunWith runner, preventing the combination of tools such as Spring and Mockito on the same class. JUnit 5 eliminated runners and rules in favor of a unified Extension API. Multiple extensions can be applied to a single class via @ExtendWith, collaborating cleanly across all lifecycle events.
Q2: What is the ExtensionContext.Store and why is it necessary?
JUnit extensions must remain stateless because JUnit may reuse extension instances across multiple threads during parallel execution. ExtensionContext.Store is a hierarchical key value storage mechanism provided by JUnit that allows extensions to persist state safely across lifecycle boundaries (for example, saving a start timestamp in beforeTestExecution and retrieving it in afterTestExecution).
Q3: What is the difference between @ExtendWith and @RegisterExtension?
@ExtendWith registers extensions declaratively by class reference. It cannot configure extension instances with constructor arguments. @RegisterExtension is applied to fields, allowing you to instantiate and configure the extension programmatically before registration.
Q4: In what order do multiple extensions execute during the test lifecycle?
Extensions execute in the order they are registered. For before callbacks (beforeEach, beforeAll), extensions run from top to bottom (first to last). For after callbacks (afterEach, afterAll), extensions run in reverse order (last to first), ensuring proper nesting of resources like database transactions or cleanup logic.
Q5: Can an extension intercept exceptions thrown by test methods?
Yes. By implementing TestExecutionExceptionHandler, an extension can catch, inspect, log, or even suppress exceptions thrown during test method execution.