JUnit 5 Extension Model

9 min read

JUnit 4 had @Rule and @ClassRule — you could intercept tests, but the mechanism was clunky and inheritance-based. TestNG has ITestListener, IReporter, and a handful of other listener interfaces — powerful, but separate interfaces for separate concerns. JUnit 5 replaced everything with a single, unified Extension API. One extension can intercept the start of a test, inject parameters into the test method, handle exceptions, and observe the outcome — by implementing multiple callback interfaces on the same class. This lesson maps the extension points to the lifecycle moments you already know.

What an extension can do

An extension is any class that implements one or more of JUnit 5's callback interfaces. You register it with @ExtendWith and JUnit calls it at the appropriate lifecycle moment:

InterfaceWhen it fires
BeforeAllCallbackBefore all tests in the class (like @BeforeAll)
AfterAllCallbackAfter all tests in the class
BeforeEachCallbackBefore each test method
AfterEachCallbackAfter each test method
BeforeTestExecutionCallbackImmediately before the test method body
AfterTestExecutionCallbackImmediately after the test method body, before @AfterEach
ParameterResolverInject custom objects into test or lifecycle methods
TestExecutionExceptionHandlerCatch and handle exceptions during a test
TestWatcherObserve final test outcome: passed, failed, aborted, disabled
ExecutionConditionDecide at runtime whether a test should run

A single class can implement as many of these as it needs.

A simple TestWatcher — like ITestListener

If you used TestNG's ITestListener to print results or take screenshots on failure, TestWatcher is the closest equivalent:

import org.junit.jupiter.api.extension.*;
 
public class TestResultLogger implements TestWatcher {
 
    @Override
    public void testSuccessful(ExtensionContext context) {
        System.out.println("✅ PASSED: " + context.getDisplayName());
    }
 
    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        System.out.println("❌ FAILED: " + context.getDisplayName());
        System.out.println("   Cause: " + cause.getMessage());
    }
 
    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        System.out.println("⏭  ABORTED: " + context.getDisplayName());
    }
 
    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        System.out.println("🚫 DISABLED: " + context.getDisplayName()
            + reason.map(r -> " — " + r).orElse(""));
    }
}

Register it on a class or globally:

@ExtendWith(TestResultLogger.class)
class LoginTest {
    @Test void shouldLogin() { ... }
    @Test void shouldRejectBadPassword() { ... }
}

Multiple extensions compose naturally:

@ExtendWith({TestResultLogger.class, ScreenshotExtension.class, TimingExtension.class})
class SeleniumTest { ... }

JUnit calls each extension in declaration order for "before" callbacks and in reverse order for "after" callbacks — the same wrapping model as Spring's interceptors.

A timing extension — BeforeTestExecution + AfterTestExecution

BeforeTestExecutionCallback and AfterTestExecutionCallback fire closer to the test method than BeforeEach / AfterEach — useful for measuring execution time without including @BeforeEach setup overhead:

public class TimingExtension
        implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
 
    private static final String START_TIME = "startTime";
 
    @Override
    public void beforeTestExecution(ExtensionContext context) {
        context.getStore(ExtensionContext.Namespace.GLOBAL)
               .put(START_TIME, System.currentTimeMillis());
    }
 
    @Override
    public void afterTestExecution(ExtensionContext context) {
        long start = context.getStore(ExtensionContext.Namespace.GLOBAL)
                            .get(START_TIME, long.class);
        long duration = System.currentTimeMillis() - start;
        System.out.printf("⏱  %s: %dms%n", context.getDisplayName(), duration);
    }
}

ExtensionContext.Store is the right place to share state between extension callbacks — not static fields, which cause issues in parallel execution. The store is keyed by namespace and key; each extension uses its own namespace to avoid collisions.

ExecutionCondition — dynamic test skipping

ExecutionCondition is what @EnabledOnOs, @EnabledIfSystemProperty, and every other built-in condition annotation uses internally. You can write your own:

public class StagingOnlyCondition implements ExecutionCondition {
 
    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        String env = System.getenv("ENVIRONMENT");
        if ("staging".equals(env)) {
            return ConditionEvaluationResult.enabled("Running in staging — condition met");
        }
        return ConditionEvaluationResult.disabled("Skipping — not in staging environment");
    }
}

Register it on a test:

@ExtendWith(StagingOnlyCondition.class)
@Test void shouldOnlyRunInStaging() { ... }

Extension registration — three ways

Per-class or per-method annotation — the most common:

@ExtendWith(TimingExtension.class)
class ApiTests { ... }

Declarative global registration via ServiceLoader — add the fully qualified class name to src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension. JUnit discovers and applies it automatically to every test class in the project. Use this for project-wide concerns like result logging.

Programmatic registration — with @RegisterExtension on a field, allowing extension instances with constructor arguments:

class DatabaseTest {
 
    @RegisterExtension
    static DatabaseExtension db = new DatabaseExtension("jdbc:h2:mem:test");
 
    @Test void shouldSaveUser() { ... }
}

static fields are initialised once per class (like @BeforeAll); instance fields are initialised per test (like @BeforeEach).

Extension points in the lifecycle

Extension Model
  • – BeforeAllCallback / AfterAllCallback
  • – BeforeEachCallback / AfterEachCallback
  • – BeforeTestExecution / AfterTestExecution
  • – ParameterResolver
  • – Inject WebDriver, DB connections, etc.
  • – supportsParameter + resolveParameter
  • – TestWatcher
  • – testSuccessful / testFailed / testAborted
  • – Screenshot on failure, result logging
  • ExecutionCondition –
  • Powers @EnabledOnOs, @EnabledIf, etc. –
  • Custom environment checks –
  • TestExecutionExceptionHandler –
  • Suppress, translate, or re-throw exceptions –
  • Custom retry logic –

Comparison with TestNG listeners

TestNG has separate interfaces for separate concerns: ITestListener for test outcomes, IReporter for report generation, IRetryAnalyzer for retry, IAnnotationTransformer for modifying annotations at runtime. You register each separately in testng.xml or with @Listeners.

JUnit 5 unifies this. One Extension class can implement TestWatcher (outcomes), BeforeEachCallback (setup), and ParameterResolver (injection) simultaneously. The registration mechanism is @ExtendWith for annotations and META-INF/services for global registration. There is no XML configuration file for extensions — everything is code.

⚠️ Common mistakes

  • Using static fields in extensions to share state across parallel tests. In parallel execution, a static field in an extension is shared by all concurrent test instances. A WebDriver stored in a static field becomes a race condition. Always use ExtensionContext.Store with a namespace — it scopes state to the specific test or class, even under parallelism.
  • Registering a TestWatcher on methods that don't exist in TestWatcher. TestWatcher only has testSuccessful, testFailed, testAborted, and testDisabled. It does not have beforeEach or afterEach. If you need both observation and setup, implement TestWatcher and BeforeEachCallback on the same extension class.
  • Confusing BeforeEachCallback with BeforeTestExecutionCallback. Both fire before every test, but BeforeEachCallback fires before @BeforeEach in the test class; BeforeTestExecutionCallback fires after @BeforeEach. For timing measurements, use BeforeTestExecutionCallback to exclude fixture setup time.

🎯 Practice task

Build a logging extension from scratch. 25–30 minutes.

  1. Create TestResultLogger implementing TestWatcher. Print a coloured (or prefixed) line for each outcome: ✅ PASSED, ❌ FAILED, ⏭ ABORTED. Include context.getDisplayName() and, for failures, the exception message.
  2. Register it with @ExtendWith(TestResultLogger.class) on a test class that has at least one passing and one failing test (use @Disabled to create an aborted case). Confirm each outcome prints the right symbol.
  3. Add timing. Extend your logger to also implement BeforeTestExecutionCallback and AfterTestExecutionCallback. Store the start time in the ExtensionContext.Store. Print the duration in testSuccessful and testFailed.
  4. Global registration. Create src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension and add your logger's fully qualified class name. Remove @ExtendWith from the test class. Confirm the logger still fires automatically.
  5. Stretch — ExecutionCondition. Write a CIOnlyCondition that skips the test unless the CI environment variable is set to "true". Register it with @ExtendWith. Run locally (no CI variable) — test is skipped. Export CI=true in your shell and run again — test executes.

Next lesson: ParameterResolver — injecting custom objects like WebDriver directly into test method parameters.

// tip to track lessons you complete and pick up where you left off across devices.