Resource library

QA How-To

Java for Testers: JUnit 5 extensions (2026)

Learn java testers JUnit 5 extensions with lifecycle callbacks, parameter injection, scoped stores, resource cleanup, registration, and runnable examples.

26 min read | 2,534 words

TL;DR

JUnit 5 extensions add reusable lifecycle, dependency, condition, and reporting behavior without base-class inheritance. Keep each extension focused, store state at the correct ExtensionContext scope, own AutoCloseable resources, and test discovery and cleanup through the platform launcher.

Key Takeaways

  • Choose the narrowest JUnit extension interface that matches the required lifecycle boundary.
  • Keep invocation state in ExtensionContext.Store under a private namespace instead of extension fields.
  • Align method, class, or root store scope with resource isolation and parallel-safety requirements.
  • Use narrow ParameterResolver matching to avoid conflicts with built-in and third-party resolvers.
  • Register configured instances with @RegisterExtension and declarative defaults with @ExtendWith.
  • Preserve original failures when evidence capture or reporting also encounters an error.
  • Verify extensions through the JUnit Platform Launcher across lifecycle, template, and parallel cases.

The phrase "java testers JUnit 5 extensions" describes the supported way to add reusable behavior around Jupiter tests. Extensions can manage resources, inject parameters, time execution, evaluate conditions, intercept failures, and publish results without forcing every test class to inherit from a framework base class.

In 2026, JUnit 6 is also available, but many Java automation stacks deliberately remain on the maintained JUnit 5 line. This guide targets JUnit Jupiter 5.14.4 and uses stable extension APIs from that line. The central design lesson is lifecycle scope: an extension must create, store, expose, and close each resource at the same level of the test tree.

TL;DR

Extension need Interface or mechanism Use it for
Before and after each test BeforeEachCallback, AfterEachCallback Fixture boundary around user setup and teardown
Around only the test method BeforeTestExecutionCallback, AfterTestExecutionCallback Timing and tracing test code
Inject a method argument ParameterResolver Clock, client, temporary domain fixture
Enable or disable dynamically ExecutionCondition Capability or environment gating
Observe outcomes TestWatcher Reporting successful, failed, aborted, disabled tests
Handle test exceptions TestExecutionExceptionHandler Attach evidence, then usually rethrow
Keep scoped state ExtensionContext.Store Pair callbacks and own resources
Register declaratively @ExtendWith or composed annotation Reusable default behavior
Register configured instance @RegisterExtension Builder or constructor configuration

Prefer one focused responsibility, store state in a private namespace, implement AutoCloseable for owned resources, and test the extension with JUnit's launcher rather than only calling callbacks by hand.

1. Why java testers JUnit 5 extensions Matters

A test framework needs cross-cutting behavior: start a server, create an API client, capture a screenshot after failure, seed a clock, or add correlation metadata. Copying this logic into @BeforeEach and @AfterEach methods spreads lifecycle rules through the suite. A shared base class centralizes code but consumes Java's single inheritance slot and often becomes a collection of unrelated features.

JUnit Jupiter's extension model composes behavior at discovery and execution points. A test opts into the capabilities it needs. The extension receives ExtensionContext, which identifies the current engine, class, nested container, or method and exposes a scoped store. This design supports parallel execution when state is scoped correctly.

Extensions are framework infrastructure, not a way to hide the scenario. A test should still make its dependencies visible, ideally through a parameter type or an explicit annotation. If an extension silently changes system properties, global time zones, or shared browser state, failures become order-dependent.

The extension model replaced JUnit 4 rules and runners for Jupiter tests. @RunWith is not combined with Jupiter's extension engine. During migration, map each old responsibility to a small Jupiter extension and verify lifecycle ordering through integration tests.

For data-driven invocation, use the dedicated JUnit 5 parameterized tests guide. Parameterized tests generate invocations, while an extension changes how those invocations are prepared, resolved, or observed.

2. Map Requirements to the Correct Extension Point

Callback timing is precise. BeforeEachCallback runs before user-defined @BeforeEach methods, while BeforeTestExecutionCallback runs after setup and immediately before the test method. The matching after callbacks occur on the other side of test execution and teardown.

Requirement Extension point Important boundary
Start a per-test dependency before setup BeforeEachCallback Runs before @BeforeEach
Measure only the test method Before and after test execution callbacks Excludes user setup and teardown
Add evidence when test code throws TestExecutionExceptionHandler Rethrow to preserve failure
Resolve a Clock parameter ParameterResolver Must claim only supported parameters
Skip on absent capability ExecutionCondition Evaluated during conditional execution
Report final test outcome TestWatcher Observer exceptions do not fail the test
Wrap invocation behavior InvocationInterceptor Powerful, use narrow interception

Choose the narrowest interface. An extension can implement several callback interfaces when they form one responsibility, such as starting and stopping a timer. Do not create one AutomationExtension that controls browsers, databases, API clients, screenshots, retries, and reporting. Its ordering and failure modes will be impossible to reason about.

Outcome observation and exception handling are different. A TestWatcher receives completed outcomes and is suited to reporting. It is not allowed to change the result by throwing. A TestExecutionExceptionHandler can handle a test exception and must rethrow it if the test should remain failed.

3. Configure a Runnable JUnit 5 Extension Project

The extension API is in junit-jupiter-api, and the engine executes it. Using the junit-jupiter aggregate dependency is convenient for a small Maven example. Save this as pom.xml and run mvn test.

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example.qa</groupId>
  <artifactId>junit-extension-examples</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <junit.version>5.14.4</junit.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build><plugins><plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.5.4</version>
  </plugin></plugins></build>
</project>

Put reusable extension code in a test-support module if several services share it. Consumers need the extension classes on the test runtime classpath. Avoid leaking test framework dependencies into production artifacts unless the module is explicitly a testing library.

Pin the JUnit version centrally and keep Jupiter API, engine, params, and platform components aligned. A BOM is helpful in a multi-module build. Run representative extension integration tests before updating JUnit because lifecycle and store behavior can gain capabilities even within a maintained major line.

4. Implement a Timing Extension With Scoped State

A timer needs to store a start value before the test method and retrieve it afterward. The method-level ExtensionContext.Store is designed for that pairing. A private namespace prevents collisions with other extensions.

import java.lang.reflect.Method;
import java.util.logging.Logger;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;

public final class TimingExtension
        implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
    private static final Logger LOG = Logger.getLogger(TimingExtension.class.getName());
    private static final String START_NANOS = "startNanos";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        store(context).put(START_NANOS, System.nanoTime());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        Method method = context.getRequiredTestMethod();
        long started = store(context).remove(START_NANOS, long.class);
        long elapsed = System.nanoTime() - started;
        LOG.info(() -> method.getName() + " took " + elapsed + " ns");
    }

    private Store store(ExtensionContext context) {
        return context.getStore(Namespace.create(getClass(), context.getUniqueId()));
    }
}

Register it declaratively:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(TimingExtension.class)
class CheckoutTest {
    @Test
    void totals_the_cart() {
        // exercise and assert checkout behavior
    }
}

System.nanoTime() is appropriate for elapsed duration because it is monotonic, unlike wall-clock time. The example logs raw nanoseconds for simplicity. A production extension may publish a structured report entry or send a metric after applying cardinality controls.

Do not use an instance field for method start time. One extension instance may participate in multiple invocations, including parallel ones. Store state under the current context instead.

5. Inject Explicit Dependencies With ParameterResolver

ParameterResolver has two methods. supportsParameter must claim only parameters the extension owns. resolveParameter then returns the value. If two resolvers claim the same parameter, JUnit reports a resolution conflict.

This resolver injects a fixed Clock only when the parameter carries a marker annotation:

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;

import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target(PARAMETER)
@Retention(RUNTIME)
@interface FixedClock {}

public final class FixedClockExtension implements ParameterResolver {
    @Override
    public boolean supportsParameter(
            ParameterContext parameterContext,
            ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == Clock.class
                && parameterContext.getParameter().isAnnotationPresent(FixedClock.class);
    }

    @Override
    public Clock resolveParameter(
            ParameterContext parameterContext,
            ExtensionContext extensionContext) {
        return Clock.fixed(
                Instant.parse("2026-07-13T00:00:00Z"), ZoneOffset.UTC);
    }
}

The test makes the dependency visible:

@ExtendWith(FixedClockExtension.class)
class ExpiryTest {
    @Test
    void evaluates_expiry(@FixedClock Clock clock) {
        assertEquals(Instant.parse("2026-07-13T00:00:00Z"), clock.instant());
    }
}

Matching both type and annotation avoids colliding with another Clock resolver. Do not claim broad types such as Object, String, or every interface. If resolution requires configuration, read a dedicated annotation or JUnit configuration parameter and fail with a ParameterResolutionException that identifies the missing input.

6. Own Resources Through ExtensionContext.Store

A resource must be scoped to its reuse promise. A database transaction may be method-scoped, a container may be class-scoped, and a lightweight local server may be root-scoped. Moving a resource to the root improves reuse but increases shared-state and parallelism risk.

Since JUnit Jupiter 5.13, values stored in an ExtensionContext.Store that implement AutoCloseable are automatically closed when that store closes. This supports clear ownership:

final class ApiSession implements AutoCloseable {
    private final String baseUrl;

    ApiSession(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    String baseUrl() {
        return baseUrl;
    }

    @Override
    public void close() {
        // close HTTP resources owned by this session
    }
}

An extension can create it lazily:

import org.junit.jupiter.api.extension.*;

public final class ApiSessionExtension implements ParameterResolver {
    private static final ExtensionContext.Namespace NAMESPACE =
            ExtensionContext.Namespace.create(ApiSessionExtension.class);

    @Override
    public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
        return pc.getParameter().getType() == ApiSession.class;
    }

    @Override
    public ApiSession resolveParameter(ParameterContext pc, ExtensionContext ec) {
        ExtensionContext.Store store = ec.getStore(NAMESPACE);
        return store.getOrComputeIfAbsent(
                "api-session",
                key -> new ApiSession("http://localhost:8080"),
                ApiSession.class);
    }
}

This creates a session in the current resolution context, which makes its ownership local to that invocation. For class-wide ownership, navigate deliberately to the class container context or create the resource in BeforeAllCallback using that context. Do not assume root storage is safe for mutable sessions.

Test resource ownership with counters or recorded events. Verify one creation and one close for method scope, or one pair for the selected class scope. Also force constructor and close failures so the extension's reporting behavior is known before it reaches a large automation suite.

7. Gate Tests and Preserve Failure Semantics

ExecutionCondition can enable or disable a test from configuration or capability. Return ConditionEvaluationResult.enabled(reason) or .disabled(reason). Prefer JUnit's built-in operating system, JRE, environment variable, and system property conditions when they already match the need. A custom condition should evaluate quickly and without destructive I/O.

Do not use a condition to hide an unhealthy environment silently. The disabled reason must identify the missing capability, and CI should report unexpected skip counts. If the environment was required for the job, failing setup may be more correct than disabling tests.

For failure evidence, TestExecutionExceptionHandler can capture a screenshot, DOM, request log, or server state when test code throws. Always protect evidence capture so its own failure does not replace the original exception, then rethrow the original:

public final class EvidenceExtension implements TestExecutionExceptionHandler {
    @Override
    public void handleTestExecutionException(
            ExtensionContext context, Throwable cause) throws Throwable {
        try {
            captureSafeEvidence(context);
        } catch (RuntimeException evidenceError) {
            cause.addSuppressed(evidenceError);
        }
        throw cause;
    }

    private void captureSafeEvidence(ExtensionContext context) {
        // publish bounded, redacted artifacts for the current test
    }
}

This handler covers exceptions from the test method, not every possible lifecycle failure. If setup fails before test execution, a TestWatcher may not receive a method outcome. Design reporting around the actual callback guarantees.

8. Register, Compose, and Order Extensions

@ExtendWith(MyExtension.class) is declarative and can appear on a class, method, interface, field, or parameter where supported. It works best for extensions with a default constructor and annotation-driven configuration. @RegisterExtension registers an instance from a field and supports builders or constructor arguments.

A static @RegisterExtension field behaves like a class-level extension and is available before instance creation. An instance field is registered after the test instance exists and has lifecycle limitations. Use the static form for a TestWatcher that must observe parameterized and other test-template invocations under the default per-method lifecycle.

Composed annotations make intent readable:

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({TYPE, METHOD})
@Retention(RUNTIME)
@ExtendWith({TimingExtension.class, EvidenceExtension.class})
public @interface ObservedTest {}

Ordering matters when extensions wrap the same lifecycle. JUnit uses deterministic registration rules and supports @Order for registered fields. Keep ordering requirements rare and document them as part of the extension contract. If extension A must reach into extension B's private store, their responsibilities are probably too coupled.

Automatic registration through Java's ServiceLoader exists but makes behavior less visible and requires configuration. Reserve it for organization-wide policy with strong documentation, not ordinary test fixtures.

9. Verify java testers JUnit 5 extensions End to End

Calling beforeEach directly is a unit test of a callback method, not proof that JUnit discovers, orders, scopes, and closes the extension correctly. Use the JUnit Platform Launcher in an extension test module to execute fixture classes and inspect the summary or recorded events.

Your integration matrix should include successful, failed, aborted, and disabled tests; @BeforeEach and @AfterEach failure; nested classes; repeated and parameterized invocations; per-method and per-class instances; and parallel execution when supported. Resource extensions need creation count, sharing scope, and close-count assertions.

Create an event recorder that is thread-safe and injected only into test fixtures. Do not use production logs as the sole oracle. Verify ordering through recorded callback names and unique IDs. For parameter resolvers, test supported, unsupported, annotated, and conflicting parameters.

Extension tests should also cover their own failures. What happens if resource creation fails? Does an evidence capture exception suppress under the original? Does close run after an aborted test? Does the reporting extension expose secrets?

The Hamcrest matchers guide can help express recorded event sequences, and the Java generics for frameworks guide explains safe typed resource APIs. Keep these integration fixtures tiny so failures identify extension behavior rather than application behavior.

10. Design Production-Grade Test Extensions

Give an extension one clear owner and public contract. Document registration, scope, thread safety, configuration keys, supported parameter types, emitted artifacts, failure behavior, and cleanup. A test author should know what opting in changes.

Use ExtensionContext.Store rather than mutable extension fields for invocation state. Create a private Namespace with the extension class and, when needed, an additional discriminator. Select root, class, or method context deliberately. Values shared across parallel invocations must be thread-safe or immutable.

Do not implement automatic retries as a generic extension to hide flaky tests. A retry changes result semantics, can duplicate side effects, and distorts reporting. If a narrowly approved retry policy exists, record every attempt and original failure and restrict it to idempotent scenarios.

Fail with useful exception types. Configuration problems should identify the extension and missing value. Resource startup failures should retain the cause. Cleanup failures should not silently pass, but they also should not erase a more important test failure.

Observe cardinality and privacy. Test display names, dynamic IDs, URLs, and customer data can explode metric labels or leak through artifacts. Publish bounded structured data and apply redaction before output.

Finally, version a shared extension library like production code. Maintain compatibility tests against supported JUnit lines, publish migration notes, and avoid exposing JUnit internals in the library's consumer-facing API.

Interview Questions and Answers

Q: What is a JUnit 5 extension?

It is an implementation of one or more Jupiter extension interfaces registered with a test. It participates in defined discovery, lifecycle, parameter, condition, interception, or result callbacks. I use it for reusable infrastructure behavior with explicit scope.

Q: What is the difference between BeforeEachCallback and BeforeTestExecutionCallback?

BeforeEachCallback runs before the test's @BeforeEach methods. BeforeTestExecutionCallback runs after user setup and immediately before the test method. The correct choice depends on whether setup time and setup dependencies belong inside the extension boundary.

Q: How does a ParameterResolver avoid conflicts?

Its supportsParameter method should match a narrow type plus an annotation or other unambiguous qualifier. If two registered resolvers support the same parameter, JUnit throws a resolution exception. I never claim broad types without a strict qualifier.

Q: Where should extension state be stored?

Invocation state belongs in ExtensionContext.Store under a private namespace. I choose a method, class, or root context matching resource ownership. Mutable instance fields are unsafe when invocations run concurrently or reuse an extension instance.

Q: How are resources cleaned up in current JUnit 5?

Values placed in a store and implementing AutoCloseable are closed when that store closes in JUnit 5.13 and later. I still test close behavior and scope. Older compatible lines may require the legacy closeable resource interface.

Q: When do you use @RegisterExtension instead of @ExtendWith?

I use @RegisterExtension when the extension needs a configured instance, constructor argument, or builder. I use @ExtendWith for declarative class registration, often through a composed annotation. Static versus instance field registration affects lifecycle availability.

Q: How should a failure-capture extension handle exceptions?

It attempts safe evidence capture, attaches capture failures as suppressed details, and rethrows the original test exception. It must not convert a real failure into a pass or replace it with a screenshot error. Evidence is bounded and redacted.

Q: How do you test an extension?

I execute fixture tests through the JUnit Platform Launcher and inspect events or summaries. The suite covers lifecycle outcomes, nesting, test templates, resource closure, conflicts, and parallel execution. Direct callback unit tests supplement but do not replace launcher integration tests.

Common Mistakes

  • Storing per-test state in extension instance fields instead of a scoped store.
  • Putting every automation responsibility into one large extension.
  • Claiming broad parameter types and causing resolver conflicts.
  • Storing mutable test sessions at the root without a parallel-safety design.
  • Swallowing the original exception after capturing failure evidence.
  • Assuming TestWatcher sees class-level setup failures or can change results.
  • Using instance @RegisterExtension for a watcher that must observe all test templates.
  • Disabling tests when a required environment should fail the CI job.
  • Relying only on direct callback unit tests instead of launcher execution.
  • Publishing unbounded or sensitive artifacts from extension callbacks.

Conclusion

Java testers JUnit 5 extensions are a composable framework mechanism, not hidden magic. Select the narrow extension point, register dependencies visibly, keep invocation state in ExtensionContext.Store, and align resource creation and cleanup with the correct context scope.

Build one small extension, such as the fixed clock or timer, then test it through the JUnit Platform with success, failure, nesting, parameterized invocation, and parallel cases. That discipline creates infrastructure that helps test authors without making the suite harder to explain.

Interview Questions and Answers

How do you decide which JUnit extension interface to implement?

I map the requirement to the narrowest lifecycle boundary. Timing test code uses before and after test execution callbacks, dependency injection uses ParameterResolver, and final outcome reporting uses TestWatcher. This avoids a monolithic extension and makes ordering clear.

How does ExtensionContext.Store help parallel execution?

It ties values to a method, class, root, or other supported context rather than a shared extension field. A private namespace prevents accidental key collisions. Parallel safety still requires that any value intentionally shared across contexts be immutable or thread-safe.

What happens when two ParameterResolvers support the same parameter?

JUnit reports a ParameterResolutionException because the value is ambiguous. I prevent that by matching a narrow type and a custom annotation or qualifier. Broad catch-all resolvers are a framework smell.

How would you implement screenshot capture on failure?

I use an exception-handling extension or an appropriate watcher based on the failures I need to observe. Capture is bounded and redacted, capture errors are attached without replacing the original, and the original test exception is rethrown. I test setup-failure limitations explicitly.

What scope would you choose for a database resource?

It depends on isolation and cost. A server container may be class or root scoped if it is thread-safe, while a transaction and mutable data are usually method scoped. I document the reuse promise and prove cleanup and parallel behavior in extension tests.

Why are static and instance @RegisterExtension fields different?

Static fields register before test instance creation and act at class level. Instance fields are available later and can use instance state, but some callbacks and test-template observation have lifecycle limitations. I use static registration when the extension must cover every invocation.

How do you preserve a test failure if extension cleanup also fails?

I retain the primary failure and attach cleanup or evidence errors as suppressed exceptions when possible. If no primary failure exists, the cleanup failure should be reported according to the extension contract. I never silently discard resource leaks.

What is your strategy for testing a shared extension library?

I execute small fixture classes with the JUnit Platform Launcher and record callback events, resource counts, outcomes, and parameters. The matrix includes nesting, disabled and aborted tests, lifecycle failures, parameterized tests, per-class instances, and parallel execution. I run compatibility tests for every supported JUnit line.

Frequently Asked Questions

Are JUnit 5 extensions still relevant in 2026?

Yes. Many production test stacks use the maintained JUnit 5 line even though JUnit 6 is also available. The Jupiter 5.14.4 extension APIs used here remain the supported mechanism for those suites.

What replaced JUnit 4 rules in JUnit 5?

JUnit Jupiter extensions replace runner and rule responsibilities through focused callback and resolver interfaces. A migration should map each rule behavior to the appropriate lifecycle point and verify ordering and cleanup.

What is the difference between @ExtendWith and @RegisterExtension?

@ExtendWith registers extension classes declaratively and normally relies on default construction plus annotations. @RegisterExtension registers a field instance, which supports builders, constructor configuration, and programmatic setup.

How do I inject a custom object into a JUnit 5 test?

Implement ParameterResolver, narrowly identify supported parameters in supportsParameter, and return the object in resolveParameter. Register the extension and make the dependency visible as a constructor or method parameter.

How should a JUnit extension store per-test state?

Use the current ExtensionContext.Store with a private Namespace. Avoid extension instance fields because one instance can participate in multiple or concurrent invocations.

Does JUnit close resources stored by extensions?

JUnit Jupiter 5.13 and later automatically closes stored values that implement AutoCloseable when the owning store closes. Scope selection still determines when cleanup happens, so test the resource lifecycle explicitly.

Can a TestWatcher fail a JUnit test?

No. TestWatcher is an outcome observer, and exceptions it throws are logged rather than allowed to change test execution. Use the appropriate exception-handling or lifecycle extension point when behavior must affect the result.

Related Guides