JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step

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

InterfaceWhen calledCommon use case
BeforeAllCallbackBefore all tests in classStart server, open DB connection
AfterAllCallbackAfter all tests in classStop server, close connection
BeforeEachCallbackBefore each test methodReset state, set up mocks
AfterEachCallbackAfter each test methodCleanup, capture screenshots
BeforeTestExecutionCallbackJust before test method bodyStart timers
AfterTestExecutionCallbackJust after test method bodyLog duration, capture metrics
ParameterResolverWhen a parameter needs injectingInject test doubles, fixtures
TestExecutionExceptionHandlerWhen test throws an exceptionConvert/rethrow exceptions
TestInstanceFactoryWhen test class is instantiatedDI framework integration
TestInstancePostProcessorAfter test class is createdInject fields via reflection
ExecutionConditionBefore test is decided to runCustom @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

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.

Leave a Reply

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