The JUnit 6 Extensions Model is one of the most powerful features in the framework. Instead of subclassing a test runner or using test rules (the old JUnit 4 approach), JUnit 6 lets you inject behaviour at precise points in the test lifecycle through clean, composable extension interfaces. This guide walks you through every major extension point with real, complete examples you can drop into your project today.
If you want to understand the architecture behind extensions, see JUnit 6 Architecture Deep Dive first. This guide focuses on practical implementation.
What Is a JUnit 6 Extension?
An extension is a class that implements one or more of JUnit 6’s callback interfaces. JUnit calls these callbacks at specific points in the test lifecycle — before the test runs, after it runs, when parameters need to be resolved, when exceptions need to be handled, and more.
Extensions replace everything that required @RunWith, @Rule, and @ClassRule in JUnit 4, but in a cleaner, more composable way.
Registering an Extension
There are three ways to register an extension:
// 1. Declarative: @ExtendWith on the test class or method
@ExtendWith(TimingExtension.class)
class OrderServiceTest { ... }
// 2. Programmatic: @RegisterExtension on a field (allows configuration)
class OrderServiceTest {
@RegisterExtension
static DatabaseExtension database = new DatabaseExtension("jdbc:h2:mem:test");
}
// 3. Automatic: via ServiceLoader in META-INF/services
// File: src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
// Contents: com.example.extensions.TimingExtension
Extension 1: Lifecycle Callbacks — Test Timing Logger
import org.junit.jupiter.api.extension.*;
import java.lang.reflect.Method;
/**
* TimingExtension: logs how long each test method takes to execute.
* Implements BeforeTestExecutionCallback and AfterTestExecutionCallback
* to bracket the actual test method execution.
*/
public class TimingExtension
implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
// Key used to store start time in the extension context store
private static final String START_TIME_KEY = "startTime";
@Override
public void beforeTestExecution(ExtensionContext context) {
// Store start time in the extension's namespace store
// The store is scoped to this extension + this test method
getStore(context).put(START_TIME_KEY, System.currentTimeMillis());
}
@Override
public void afterTestExecution(ExtensionContext context) {
// Retrieve the stored start time
long startTime = getStore(context).remove(START_TIME_KEY, long.class);
long durationMs = System.currentTimeMillis() - startTime;
// Get the test method name for the log message
String testMethodName = context.getRequiredTestMethod().getName();
System.out.printf("[TimingExtension] %-60s %4d ms%n",
testMethodName, durationMs);
}
// Helper: creates a namespace-scoped store for this extension
private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(
ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())
);
}
}
// Usage
@ExtendWith(TimingExtension.class)
class PaymentServiceTest {
@Test
void processingPaymentCompletesSuccessfully() throws Exception {
Thread.sleep(42); // simulates work
assertTrue(true);
}
}
[TimingExtension] processingPaymentCompletesSuccessfully 42 ms
Tests run: 1, Failures: 0
Extension 2: ParameterResolver — Inject Custom Test Parameters
import org.junit.jupiter.api.extension.*;
/**
* RandomIntExtension: injects a random integer into any test parameter
* annotated with @RandomInt.
*/
// Custom annotation to mark parameters for injection
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RandomInt {
int min() default 0;
int max() default 100;
}
// The extension that resolves @RandomInt parameters
public class RandomIntExtension implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
// This extension handles parameters annotated with @RandomInt
return parameterContext.isAnnotated(RandomInt.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
RandomInt annotation = parameterContext.findAnnotation(RandomInt.class).get();
int min = annotation.min();
int max = annotation.max();
// Generate a random int in [min, max]
return min + (int)(Math.random() * (max - min + 1));
}
}
// Usage in a test
@ExtendWith(RandomIntExtension.class)
class RandomNumberTest {
@Test
@DisplayName("Injected random number is within expected range")
void randomNumberIsWithinRange(
@RandomInt(min = 1, max = 10) int randomValue) {
System.out.println("Injected random value: " + randomValue);
assertTrue(randomValue >= 1 && randomValue <= 10,
"Random value " + randomValue + " must be between 1 and 10");
}
}
Extension 3: TestExecutionExceptionHandler — Handle Exceptions Gracefully
import org.junit.jupiter.api.extension.*;
/**
* IgnoreFlakeyNetworkExceptionExtension: converts network-related exceptions
* into test aborts (skips) rather than failures.
* Useful for tests running in environments with unstable network access.
*/
public class IgnoreFlakeyNetworkExceptionExtension
implements TestExecutionExceptionHandler {
@Override
public void handleTestExecutionException(ExtensionContext context,
Throwable throwable) throws Throwable {
// If the exception is a network timeout, abort rather than fail
if (throwable instanceof java.net.SocketTimeoutException ||
throwable instanceof java.net.ConnectException) {
System.out.println("[NetworkExtension] Network issue detected, aborting test: "
+ throwable.getMessage());
// Throw TestAbortedException to mark as SKIPPED, not FAILED
throw new org.opentest4j.TestAbortedException(
"Test aborted due to network instability: " + throwable.getMessage()
);
}
// Re-throw all other exceptions — they are genuine test failures
throw throwable;
}
}
Extension 4: @RegisterExtension with Configuration
import org.junit.jupiter.api.extension.*;
/**
* InMemoryDatabaseExtension: sets up and tears down an H2 in-memory database.
* Registered programmatically so the JDBC URL can be configured.
*/
public class InMemoryDatabaseExtension
implements BeforeAllCallback, AfterAllCallback {
private final String jdbcUrl;
private Connection connection;
// Constructor allows configuration at registration site
public InMemoryDatabaseExtension(String jdbcUrl) {
this.jdbcUrl = jdbcUrl;
}
@Override
public void beforeAll(ExtensionContext context) throws Exception {
connection = DriverManager.getConnection(jdbcUrl);
System.out.println("[DB Extension] Connection opened: " + jdbcUrl);
runMigrations(connection); // set up schema
}
@Override
public void afterAll(ExtensionContext context) throws Exception {
if (connection != null) {
connection.close();
System.out.println("[DB Extension] Connection closed");
}
}
// Expose the connection for tests to use
public Connection getConnection() { return connection; }
private void runMigrations(Connection conn) { /* apply schema SQL */ }
}
// Usage: @RegisterExtension allows passing constructor arguments
class OrderRepositoryTest {
// 'static' → BeforeAllCallback runs once for the class
@RegisterExtension
static InMemoryDatabaseExtension database =
new InMemoryDatabaseExtension("jdbc:h2:mem:order-test;DB_CLOSE_DELAY=-1");
@Test
@DisplayName("Saving an order persists it to the database")
void savingOrderPersistsToDatabase() throws Exception {
Connection conn = database.getConnection();
assertNotNull(conn, "Database connection must be available");
// ... run SQL assertions
}
}
Extension Interface Reference
| Interface | When called | Common use case |
|---|---|---|
BeforeAllCallback | Before all tests in class | Start server, open DB connection |
AfterAllCallback | After all tests in class | Stop server, close connection |
BeforeEachCallback | Before each test method | Reset state, set up mocks |
AfterEachCallback | After each test method | Cleanup, capture screenshots |
BeforeTestExecutionCallback | Just before test method body | Start timers |
AfterTestExecutionCallback | Just after test method body | Log duration, capture metrics |
ParameterResolver | When a parameter needs injecting | Inject test doubles, fixtures |
TestExecutionExceptionHandler | When test throws an exception | Convert/rethrow exceptions |
TestInstanceFactory | When test class is instantiated | DI framework integration |
TestInstancePostProcessor | After test class is created | Inject fields via reflection |
ExecutionCondition | Before test is decided to run | Custom @Enabled conditions |
Frequently Asked Questions (FAQs)
Q1: Can one extension class implement multiple callback interfaces?
Yes, and this is the recommended approach. A single extension class can implement BeforeEachCallback, AfterEachCallback, and ParameterResolver simultaneously. JUnit 6 calls each implemented interface at the appropriate point. This keeps related setup/teardown logic together in one cohesive class rather than spread across multiple extension files.
Q2: What is the ExtensionContext.Store and when do I need it?
The Store is a key-value map scoped to a specific extension and test node (class, method, etc.). Use it whenever your extension needs to pass data between a before* callback and its corresponding after* callback — for example, storing a start timestamp in beforeTestExecution and reading it in afterTestExecution. Always use a custom namespace (via Namespace.create) to avoid key collisions with other extensions.
Q3: What is the execution order when multiple extensions are registered?
Extensions are executed in the order they are declared. For @ExtendWith({ExtA.class, ExtB.class}), ExtA’s beforeEach runs before ExtB’s, and ExtB’s afterEach runs before ExtA’s (LIFO for after-callbacks). JUnit 6 guarantees deterministic ordering, which was a pain point in earlier versions.
Q4: How is @RegisterExtension different from @ExtendWith?
@ExtendWith takes a class reference — JUnit instantiates the extension with its no-arg constructor. @RegisterExtension takes a field holding an already-constructed extension instance, allowing you to pass constructor arguments for configuration. Use @RegisterExtension whenever your extension needs runtime configuration (e.g., a specific database URL, port number, or feature flag).
Q5: Can I use Spring’s DI container to manage extension beans?
Yes. Spring’s SpringExtension (from spring-test) is itself a JUnit 6 extension that bridges Spring’s ApplicationContext with JUnit’s extension model. It uses TestInstancePostProcessor and ParameterResolver to inject Spring beans into test fields and method parameters. See JUnit 6 with Spring Boot for complete examples.
See Also
- JUnit 6 Tutorial: Complete Series Index
- JUnit 6 Architecture Deep Dive: Platform, Engines, and Launchers
- Advanced Extensions in JUnit 6: Creating Custom Testing Frameworks
- JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing
- JUnit 6 Test Lifecycle Explained
Conclusion
The JUnit 6 extension model eliminates test runners, rules, and class rules in favour of clean, composable callback interfaces. A single extension can implement multiple interfaces, can be registered declaratively or programmatically, and can share state safely through the ExtensionContext.Store. Master these patterns and you will never reach for copy-paste test setup again.
Next: Parallel Test Execution in JUnit 6 — cut your test suite execution time dramatically by running tests concurrently with proper configuration.