Using JUnit 5 Extensions for Custom Test Lifecycle Hooks
JUnit 5 extensions provide a clean way to add behaviour around test execution without forcing every test class to inherit from a shared base class. They can prepare data, configure external systems, record timing, manage temporary resources, and enforce project-wide testing rules. For Java and Groovy teams, this makes lifecycle customisation explicit while keeping individual test methods focused on business behaviour.
A lifecycle hook is useful when setup or cleanup has technical significance across many tests. Examples include starting a WireMock server before a suite, adding correlation IDs to logs, resetting a database after each test, or collecting diagnostics when a REST test fails. The extension model is especially valuable in continuous delivery pipelines, where predictable cleanup and useful failure information matter as much as the assertion itself.
The extension model in JUnit 5
JUnit 5 divides its testing platform into the platform, Jupiter, and the Vintage engine. Extensions belong to Jupiter and can participate at several points in a test’s lifecycle. Common callback interfaces include BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, and AfterTestExecutionCallback.
The distinction between these callbacks matters. BeforeEachCallback runs before the test-specific setup method and is suitable for preparing state needed by that setup. BeforeTestExecutionCallback runs later, immediately before the test method itself. The corresponding “after” callbacks also differ in timing. A team measuring only test method duration should generally use the test execution callbacks rather than the broader per-test lifecycle.
An extension can also implement TestWatcher to observe success, failure, abortion, and disablement. ParameterResolver can provide method arguments, while TestInstancePostProcessor can modify or inject fields after a test instance has been created. These interfaces can be combined, but a small extension with one clear responsibility is easier to understand and maintain.
Registering a custom extension
The simplest registration style uses @ExtendWith on a test class or test method. The extension is then active for the annotated scope.
@ExtendWith(TimingExtension.class)
class PaymentServiceTest {
@Test
void shouldAcceptValidPayment() {
// test implementation
}
}
For a reusable extension that should be available through an annotation, create a composed annotation. This gives the test suite a meaningful vocabulary and prevents low-level implementation details from spreading through test classes.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(DatabaseResetExtension.class)
public @interface ResetDatabase {
}
A test can then use @ResetDatabase instead of referring directly to the implementation. This approach works well in a large organisation with teams in Sydney, Melbourne, or Brisbane, where a shared testing library may be consumed by several services with slightly different test conventions.
JUnit 5 also supports @RegisterExtension, which is useful when an extension needs configuration through a builder or constructor. Local registration is particularly suitable for integration tests that need a custom port, a selected environment, or a particular test resource.
Choosing the right lifecycle callback
A database cleanup extension often belongs in AfterEachCallback, because its purpose is to restore isolation after every test. A server or container that should exist for the whole test class can be created in BeforeAllCallback and stopped in AfterAllCallback.
public class TimingExtension
implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final ExtensionContext.Namespace NAMESPACE =
ExtensionContext.Namespace.create(TimingExtension.class);
@Override
public void beforeTestExecution(ExtensionContext context) {
getStore(context).put("startedAt", System.nanoTime());
}
@Override
public void afterTestExecution(ExtensionContext context) {
long startedAt = getStore(context).remove("startedAt", long.class);
long elapsed = System.nanoTime() - startedAt;
System.out.printf("%s took %d ms%n",
context.getDisplayName(), elapsed / 1_000_000);
}
private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(NAMESPACE);
}
}
The ExtensionContext supplies information about the current test, class, parent context, tags, display name, and unique ID. It also provides a store for safely retaining state between callbacks. Using the store is preferable to static fields, which can cause interference when tests run concurrently or when multiple test classes execute in the same JVM.
Before choosing a callback, describe the required timing in plain language. “Reset data after each test method” points to AfterEachCallback; “measure only the invocation of the test method” points to BeforeTestExecutionCallback and AfterTestExecutionCallback. Clear timing prevents accidental coupling with @BeforeEach or @AfterEach methods.
Managing resources with the extension store
The extension store is useful for retaining objects associated with a class or test method. Its scope follows the context used to obtain it. A store from the class-level context can hold a resource shared by all methods in that class, while a method-level store can hold data for one invocation.
JUnit 5 supports automatic cleanup for values implementing ExtensionContext.Store.CloseableResource. The resource is closed when the store’s owning context is shut down. This can make lifecycle management safer than manually coordinating a field and a separate callback.
public final class TestServer
implements ExtensionContext.Store.CloseableResource {
private final WireMockServer server = new WireMockServer(0);
public void start() {
server.start();
}
public int port() {
return server.port();
}
@Override
public void close() {
server.stop();
}
}
An extension can create this object in BeforeAllCallback, place it in the class store, and retrieve it from later callbacks or a parameter resolver. The same pattern works for temporary directories, embedded databases, message consumers, and HTTP stubs. It is useful for backend tests where a local service must be available during a suite but must never remain running after the build finishes.
Resource ownership should be explicit. If the extension starts a Docker container, it should also define what happens when startup fails, when a test is aborted, and when the build is interrupted. Leaked processes are particularly painful on shared CI agents used by Australian teams across time zones, because a later pipeline may inherit ports, files, or containers from an earlier run.
Combining callbacks with parameter resolution
A lifecycle extension can prepare an object and a ParameterResolver can inject it into a test method. This avoids exposing setup code in every test and can produce compact integration tests.
@ExtendWith(TestServerExtension.class)
class OrdersApiTest {
@Test
void shouldReturnOrders(TestServer server) {
given()
.baseUri("http://localhost:" + server.port())
.when()
.get("/orders")
.then()
.statusCode(200);
}
}
A resolver normally implements two methods: supportsParameter decides whether it owns a parameter, and resolveParameter returns the value. The implementation should check both the parameter type and, where necessary, an annotation. Resolving every parameter of a broad type can create surprising conflicts with Mockito, Spring, or another project extension.
For a test suite using Groovy, the same extension remains available because the extension is part of the JUnit Jupiter runtime rather than the Java syntax. Groovy specifications and Java tests can share infrastructure, although framework-specific lifecycle rules still need attention. A resolver should fail with a clear message when a parameter cannot be created, rather than returning null and producing a confusing assertion failure later.
Ordering, inheritance, and composition
Multiple extensions may participate in the same lifecycle phase. Registration order can affect behaviour, especially when one extension establishes a resource and another consumes it. A logging extension that captures diagnostics should generally run in a way that still allows it to observe failures from other extensions.
@Order can help control extension ordering where supported, but it should not be used to hide unclear dependencies. If extension B requires a database started by extension A, representing the shared resource through one composed extension is often clearer than relying on a fragile ordering arrangement.
Extensions may be registered at the class level, method level, through meta-annotations, or globally using the JUnit platform configuration. Inheritance can make a base test class appear convenient, yet it can also make lifecycle behaviour difficult to discover. Prefer a deliberately named annotation for cross-cutting rules such as @UseContractEnvironment or @ResetTestData.
A project may also enable automatic extension discovery through the service loader. This is powerful for organisation-wide policies, but it changes test behaviour without a visible annotation. Use it for genuinely global concerns, such as a standard reporting integration, and document the configuration in the build and developer documentation.
Handling failures and parallel execution
A callback can throw an exception to fail the test or prevent execution. That may be appropriate when a required dependency cannot start. Cleanup code needs more care: if cleanup hides the original assertion failure, developers lose the most valuable diagnostic. Extensions should preserve the primary failure and attach cleanup problems as suppressed exceptions where possible.
TestWatcher is suitable for collecting details after a failure, such as request and response bodies, database state, or application logs. Sensitive values must be filtered before they reach CI output. This is important for systems handling Medicare-related information, banking data, or customer records in Australia, where logs can create privacy and compliance risks.
Parallel execution also affects extension design. Avoid mutable static state, use thread-safe collections when shared state is unavoidable, and scope stores correctly. A test-specific temporary directory should not be stored at a class or root scope. If an external resource cannot safely serve concurrent tests, declare an appropriate resource lock or configure the relevant tests to run in isolation.
Time zones are another practical source of errors. A test running in Perth may expose assumptions that passed in Sydney or on a developer laptop configured for UTC. An extension that sets a clock, locale, or time zone should restore the original value and should avoid changing JVM-wide state unless the test suite is deliberately serialised.
Building maintainable lifecycle infrastructure
A custom hook should solve a repeated testing problem rather than become a second application framework. Keep the extension’s public API small, name failures precisely, and include diagnostic context such as the test class, method, resource identifier, and relevant configuration. Unit-test the extension itself so that lifecycle behaviour is verified independently from the tests that consume it.
A useful structure separates responsibilities into three layers: a resource or service class, an extension that controls its lifecycle, and optional annotations or resolvers that expose it to tests. This makes it possible to replace WireMock with a real service, or an in-memory repository with PostgreSQL, without rewriting every test method.
The best extensions make test intent easier to read. A test annotated with @UseOrdersApi communicates more than a class containing several hidden setup methods. At the same time, lifecycle automation should remain visible enough that a developer can understand where data comes from, when it is reset, and who owns cleanup.
For teams delivering software through Australian cloud regions or local data centres, this clarity reduces the gap between a laptop build and a CI build. Reliable hooks help tests behave consistently across operating systems, Java versions, and pipeline agents. They also make failures actionable instead of turning infrastructure problems into unexplained assertion errors.
JUnit 5 extensions are most effective when each callback has a precise lifecycle purpose, state is held in the ExtensionContext store, and resources have an unambiguous owner. Choose the narrowest registration scope, preserve useful failures, design for parallel execution, and keep cross-cutting behaviour visible through meaningful annotations. The key point to remember is that a lifecycle extension should make tests safer and clearer, never make their execution model mysterious.