The JUnit 6 extension model is not just for simple before/after hooks. At its most advanced, it lets you build entire custom testing frameworks on top of JUnit — with domain-specific annotations, automatic injection, retry logic, soft assertions, and test templates. This guide explores the most powerful extension patterns used by production-grade testing frameworks, with complete, runnable examples.
This post builds on JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step. Make sure you are comfortable with basic extension interfaces before diving in here.
Pattern 1: Composed Annotation Extensions
Combine multiple annotations and extensions into a single meta-annotation so test classes need only one annotation to get a full suite of capabilities:
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.*;
import java.lang.annotation.*;
/**
* @DatabaseTest: a single annotation that applies:
* - @ExtendWith(DatabaseSetupExtension.class) — manages DB lifecycle
* - @ExtendWith(TransactionRollbackExtension.class) — rolls back after each test
* - @Tag("integration") — marks for integration test filtering
* - @Tag("database") — marks for database test filtering
* - @TestInstance(PER_CLASS) — shares one instance across methods
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(DatabaseSetupExtension.class)
@ExtendWith(TransactionRollbackExtension.class)
@Tag("integration")
@Tag("database")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public @interface DatabaseTest {
// Custom attribute: which database profile to use
String profile() default "test";
}
// DatabaseSetupExtension: opens connection before all, closes after all
class DatabaseSetupExtension implements BeforeAllCallback, AfterAllCallback {
@Override
public void beforeAll(ExtensionContext ctx) {
System.out.println("[DB] Opening database connection");
// Store connection in context store for tests to access
ctx.getStore(ExtensionContext.Namespace.GLOBAL)
.put("db.connection", createConnection());
}
@Override
public void afterAll(ExtensionContext ctx) {
System.out.println("[DB] Closing database connection");
}
private Object createConnection() { return "connection-placeholder"; }
}
// TransactionRollbackExtension: wraps each test in a transaction that rolls back
class TransactionRollbackExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext ctx) {
System.out.println("[TX] Starting transaction");
}
@Override
public void afterEach(ExtensionContext ctx) {
System.out.println("[TX] Rolling back transaction"); // ensures test isolation
}
}
// Usage — one annotation gives you DB lifecycle + transaction rollback + tags
@DatabaseTest(profile = "integration")
class UserRepositoryTest {
@Test
void findByEmailReturnsCorrectUser() {
// DB is set up, wrapped in a transaction that will roll back
assertNotNull("result");
}
}
Pattern 2: @RetryingTest — Automatic Test Retry Extension
import org.junit.jupiter.api.extension.*;
import java.lang.annotation.*;
/**
* @RetryingTest: retries a flaky test up to N times before marking it as failed.
* Useful for tests that occasionally fail due to timing or network issues.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test // This IS a test
public @interface RetryingTest {
int maxAttempts() default 3;
}
/**
* RetryExtension: intercepts test failures and re-executes up to maxAttempts.
*/
public class RetryExtension implements TestExecutionExceptionHandler {
// Namespace for storing attempt count per test method
private static final ExtensionContext.Namespace RETRY_NS =
ExtensionContext.Namespace.create(RetryExtension.class);
@Override
public void handleTestExecutionException(ExtensionContext context,
Throwable throwable) throws Throwable {
// Only retry if @RetryingTest is present on the method
RetryingTest retryAnnotation = context.getRequiredTestMethod()
.getAnnotation(RetryingTest.class);
if (retryAnnotation == null) throw throwable;
int maxAttempts = retryAnnotation.maxAttempts();
int currentAttempt = context.getStore(RETRY_NS)
.getOrComputeIfAbsent("attempt", k -> 1, Integer.class);
System.out.printf("[Retry] Attempt %d of %d failed for: %s%n",
currentAttempt, maxAttempts, context.getDisplayName());
if (currentAttempt < maxAttempts) {
// Increment attempt counter and re-throw to trigger retry
context.getStore(RETRY_NS).put("attempt", currentAttempt + 1);
throw throwable; // JUnit retries the test when exception propagates
}
// Max attempts exhausted — fail the test permanently
throw throwable;
}
}
// Usage
@ExtendWith(RetryExtension.class)
class FlakyNetworkTest {
@RetryingTest(maxAttempts = 3)
@DisplayName("External API call succeeds within 3 attempts")
void externalApiCallSucceeds() {
// This test might fail on the first attempt due to network latency
// With @RetryingTest it will be retried up to 3 times
boolean success = externalApiClient.ping();
assertTrue(success, "External API should respond");
}
}
Pattern 3: Soft Assertions Extension
Inject a SoftAssertions object that collects all assertion failures and reports them together at the end of the test — without stopping on the first failure:
import org.junit.jupiter.api.extension.*;
import java.util.ArrayList;
import java.util.List;
/**
* SoftAssertions: collect all failures, report at end.
* Injected via ParameterResolver — tests declare it as a parameter.
*/
public class SoftAssertions {
private final List failures = new ArrayList();
// Called by test methods to add a "soft" assertion
public void assertEquals(Object expected, Object actual, String message) {
if (!expected.equals(actual)) {
failures.add(new AssertionError(
message + ": expected but was "));
}
}
public void assertTrue(boolean condition, String message) {
if (!condition) failures.add(new AssertionError(message));
}
// Called by the extension after the test method finishes
public void assertAll() {
if (!failures.isEmpty()) {
StringBuilder sb = new StringBuilder("Soft assertion failures (")
.append(failures.size()).append("):n");
failures.forEach(f -> sb.append(" ❌ ").append(f.getMessage()).append("n"));
throw new AssertionError(sb.toString());
}
}
}
/**
* SoftAssertionsExtension: resolves SoftAssertions parameter and calls assertAll after test.
*/
public class SoftAssertionsExtension
implements ParameterResolver, AfterEachCallback {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(SoftAssertionsExtension.class);
@Override
public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
return pc.getParameter().getType() == SoftAssertions.class;
}
@Override
public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
// Create a new SoftAssertions instance for this test and store it
SoftAssertions softly = new SoftAssertions();
ec.getStore(NS).put("softly", softly);
return softly;
}
@Override
public void afterEach(ExtensionContext context) {
// After each test, trigger assertAll to report any collected failures
SoftAssertions softly = context.getStore(NS).get("softly", SoftAssertions.class);
if (softly != null) softly.assertAll();
}
}
// Usage: declare SoftAssertions as a test method parameter
@ExtendWith(SoftAssertionsExtension.class)
class OrderSoftAssertionTest {
@Test
@DisplayName("Order has all required fields populated")
void orderHasAllRequiredFields(SoftAssertions softly) {
Order order = orderService.createOrder("[email protected]", 99.99);
// All assertions run even if earlier ones fail
softly.assertEquals("[email protected]", order.getEmail(), "email");
softly.assertTrue(order.getTotal() > 0, "total must be positive");
softly.assertTrue(order.getId() != null, "id must be assigned");
// Failures collected here; reported as one error by afterEach
}
}
Pattern 4: TestTemplate — Custom Test Invocation Strategies
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.*;
import java.util.stream.Stream;
/**
* @CrossBrowserTest: runs the same test in multiple browser contexts.
* Uses @TestTemplate to invoke the test once per browser type.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@TestTemplate
@ExtendWith(CrossBrowserExtension.class)
public @interface CrossBrowserTest {}
public class CrossBrowserExtension implements TestTemplateInvocationContextProvider {
@Override
public boolean supportsTestTemplate(ExtensionContext context) {
return context.getTestMethod()
.map(m -> m.isAnnotationPresent(CrossBrowserTest.class))
.orElse(false);
}
@Override
public Stream provideTestTemplateInvocationContexts(
ExtensionContext context) {
// One invocation per browser
return Stream.of("Chrome", "Firefox", "Safari")
.map(browserName -> new TestTemplateInvocationContext() {
@Override
public String getDisplayName(int invocationIndex) {
return "[" + browserName + "]"; // shown in test report
}
@Override
public List getAdditionalExtensions() {
// Inject the browser name as a parameter
return List.of(new ParameterResolver() {
public boolean supportsParameter(ParameterContext p, ExtensionContext e) {
return p.getParameter().getType() == String.class;
}
public Object resolveParameter(ParameterContext p, ExtensionContext e) {
return browserName;
}
});
}
});
}
}
// Usage: runs 3 times — once per browser
class LoginPageTest {
@CrossBrowserTest
@DisplayName("Login page renders correctly in")
void loginPageRendersCorrectly(String browser) {
System.out.println("Testing login page in: " + browser);
assertTrue(true, "Login page should render in " + browser);
}
}
Login page renders correctly in
✔ [Chrome]
✔ [Firefox]
✔ [Safari]
Tests run: 3, Failures: 0
Frequently Asked Questions (FAQs)
Q1: What is the difference between @TestTemplate and @ParameterizedTest?
@ParameterizedTest is built on top of @TestTemplate — it is a higher-level, annotation-driven convenience. @TestTemplate gives you full control over the invocation strategy via TestTemplateInvocationContextProvider, including injecting different extensions and parameters per invocation. Use @ParameterizedTest for data-driven testing; use @TestTemplate when you need a custom invocation model (e.g., per-browser, per-locale, per-feature-flag).
Q2: Can a composed meta-annotation inherit @ExtendWith from multiple extensions?
Yes. You can stack multiple @ExtendWith annotations on a single meta-annotation, and JUnit 6 will register all of them in declaration order. This is the foundation of the @DatabaseTest pattern shown above — one annotation composes DB lifecycle management, transaction rollback, and tag classification.
Q3: How do extensions share data with each other in the same test?
Use ExtensionContext.Namespace.GLOBAL or a shared custom namespace. Any extension can store data in the global namespace and any other extension can read it. For tighter coupling, use a well-known key constant in a shared class. This is how frameworks like Spring’s SpringExtension share the ApplicationContext across multiple extension implementations.
Q4: Is there a performance cost to using many extensions?
The overhead of extension callback invocation is nanoseconds per callback — completely negligible for any realistic test suite. The cost you do need to consider is what your extension does: opening a database connection in @BeforeEach, making an HTTP call, or spawning a thread all have real costs. The extension mechanism itself is not the bottleneck.
Q5: Can I publish my custom extensions as a reusable library?
Absolutely. Package your extensions as a regular JAR with the extension classes and, if using auto-registration, the META-INF/services file. Teams add it as a test-scoped dependency and immediately get the annotations and behaviour. Popular open-source examples include Mockito’s MockitoExtension, Testcontainers JUnit 6 support, and WireMock’s JUnit 6 extension.
See Also
- JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step
- Build Your Own JUnit 6 Test Engine (Advanced Guide)
- Parameterized Tests in JUnit 6: All Sources Explained
- How to Fix Flaky Tests in JUnit 6
- JUnit 6 Tutorial: Complete Series Index
Conclusion
Advanced JUnit 6 extensions transform the framework from a test runner into a testing platform you own. Composed meta-annotations eliminate boilerplate. Retry extensions make flaky tests self-healing. Soft assertion injection gives you complete failure pictures. @TestTemplate enables custom invocation strategies for browser, locale, and environment testing. Combined, these patterns let you build domain-specific testing frameworks that feel native to your team’s workflow.
Next: JUnit 6 with Spring Boot — integrate everything you’ve learned with Spring Boot’s testing layer for unit, slice, and full integration tests.