Advanced Extensions in JUnit 6: Creating Custom Testing Frameworks

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

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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.