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:
| Interface | When it fires |
|---|---|
BeforeAllCallback | Before all tests in the class (like @BeforeAll) |
AfterAllCallback | After all tests in the class |
BeforeEachCallback | Before each test method |
AfterEachCallback | After each test method |
BeforeTestExecutionCallback | Immediately before the test method body |
AfterTestExecutionCallback | Immediately after the test method body, before @AfterEach |
ParameterResolver | Inject custom objects into test or lifecycle methods |
TestExecutionExceptionHandler | Catch and handle exceptions during a test |
TestWatcher | Observe final test outcome: passed, failed, aborted, disabled |
ExecutionCondition | Decide 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
- – 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
WebDriverstored in a static field becomes a race condition. Always useExtensionContext.Storewith a namespace — it scopes state to the specific test or class, even under parallelism. - Registering a
TestWatcheron methods that don't exist inTestWatcher.TestWatcheronly hastestSuccessful,testFailed,testAborted, andtestDisabled. It does not havebeforeEachorafterEach. If you need both observation and setup, implementTestWatcherandBeforeEachCallbackon the same extension class. - Confusing
BeforeEachCallbackwithBeforeTestExecutionCallback. Both fire before every test, butBeforeEachCallbackfires before@BeforeEachin the test class;BeforeTestExecutionCallbackfires after@BeforeEach. For timing measurements, useBeforeTestExecutionCallbackto exclude fixture setup time.
🎯 Practice task
Build a logging extension from scratch. 25–30 minutes.
- Create
TestResultLoggerimplementingTestWatcher. Print a coloured (or prefixed) line for each outcome: ✅ PASSED, ❌ FAILED, ⏭ ABORTED. Includecontext.getDisplayName()and, for failures, the exception message. - Register it with
@ExtendWith(TestResultLogger.class)on a test class that has at least one passing and one failing test (use@Disabledto create an aborted case). Confirm each outcome prints the right symbol. - Add timing. Extend your logger to also implement
BeforeTestExecutionCallbackandAfterTestExecutionCallback. Store the start time in theExtensionContext.Store. Print the duration intestSuccessfulandtestFailed. - Global registration. Create
src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extensionand add your logger's fully qualified class name. Remove@ExtendWithfrom the test class. Confirm the logger still fires automatically. - Stretch —
ExecutionCondition. Write aCIOnlyConditionthat skips the test unless theCIenvironment variable is set to"true". Register it with@ExtendWith. Run locally (noCIvariable) — test is skipped. ExportCI=truein your shell and run again — test executes.
Next lesson: ParameterResolver — injecting custom objects like WebDriver directly into test method parameters.