How do I use test templates in JUnit?

Test templates in JUnit 5 provide a powerful way to run the same test multiple times with different contexts or invocation strategies. Unlike @ParameterizedTest (which is a specialized form of test template), @TestTemplate gives you full control over how tests are invoked by requiring you to register a custom TestTemplateInvocationContextProvider.

When to Use @TestTemplate

Use test templates when you need to:

  • Run a test with different environments (e.g., different databases, browsers, or configurations).
  • Provide custom test invocation logic beyond what @ParameterizedTest or @RepeatedTest offers.
  • Inject different parameter sets and extensions per invocation.

Basic Structure

A @TestTemplate method requires at least one TestTemplateInvocationContextProvider registered via @ExtendWith.

Step 1: Define the Test Template Method

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

@ExtendWith(MyTestTemplateProvider.class)
class UserServiceTest {

    @TestTemplate
    void testUserCreation(String environment) {
        System.out.println("Running test in environment: " + environment);
        // Your test logic here
    }
}

Step 2: Create the Invocation Context Provider

import org.junit.jupiter.api.extension.*;
import java.util.stream.Stream;
import java.util.List;

public class MyTestTemplateProvider implements TestTemplateInvocationContextProvider {

    @Override
    public boolean supportsTestTemplate(ExtensionContext context) {
        return true;
    }

    @Override
    public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(
            ExtensionContext context) {
        return Stream.of(
                invocationContext("DEV"),
                invocationContext("STAGING"),
                invocationContext("PRODUCTION")
        );
    }

    private TestTemplateInvocationContext invocationContext(String environment) {
        return new TestTemplateInvocationContext() {
            @Override
            public String getDisplayName(int invocationIndex) {
                return "Environment: " + environment;
            }

            @Override
            public List<Extension> getAdditionalExtensions() {
                return List.of(new EnvironmentParameterResolver(environment));
            }
        };
    }
}

Step 3: Provide a ParameterResolver

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

public class EnvironmentParameterResolver implements ParameterResolver {

    private final String environment;

    public EnvironmentParameterResolver(String environment) {
        this.environment = environment;
    }

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == String.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext) {
        return environment;
    }
}

Real-World Example: Testing Against Multiple Configurations

Imagine testing a service against different database configurations:

@ExtendWith(DatabaseTestTemplateProvider.class)
class RepositoryTest {

    @TestTemplate
    void shouldSaveEntity(DatabaseConfig config) {
        // Test runs once for each configuration provided
        Repository repo = new Repository(config);
        assertTrue(repo.save(new Entity("test")));
    }
}

The provider can supply DatabaseConfig objects for H2, PostgreSQL, MySQL, etc.

Key Points to Remember

Feature Description
Annotation @TestTemplate
Required Provider TestTemplateInvocationContextProvider
Registration Via @ExtendWith or ServiceLoader
Invocation Count Determined by the number of contexts returned
Parameter Injection Through ParameterResolver in each context

@TestTemplate vs Other Test Annotations

  • @Test → Runs once.
  • @RepeatedTest → Runs a fixed number of times with the same context.
  • @ParameterizedTest → Runs with different arguments (built-in template).
  • @TestTemplate → Full custom control over invocation contexts and extensions.

Best Practices

  1. Use @TestTemplate only when built-in options are insufficient@ParameterizedTest covers most cases.
  2. Give meaningful display names via getDisplayName(int invocationIndex) for clear test reports.
  3. Keep providers reusable — a good provider can be shared across many test classes.
  4. Combine with other extensions to inject mocks, configurations, or lifecycle hooks per invocation.

Test templates unlock advanced testing scenarios where you need dynamic, context-aware test invocation — perfect for integration testing across multiple environments or configurations. 🚀

How do I write dynamic tests with @TestFactory?

@TestFactory is a JUnit Jupiter feature that lets you generate tests at runtime rather than declaring them statically with @Test. This is useful when the number or nature of tests depends on data that’s only known at execution time.

Key Rules

  • A @TestFactory method must return one of:
    • DynamicNode (or a subtype like DynamicTest / DynamicContainer)
    • Stream, Collection, Iterable, Iterator, or an array of DynamicNode
  • It must not be private or static.
  • Each generated DynamicTest consists of a display name and an Executable (lambda with the assertion logic).

1. Basic Example — Collection of Tests

import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class CalculatorDynamicTests {

    @TestFactory
    List<DynamicTest> additionTests() {
        return List.of(
            dynamicTest("1 + 1 = 2", () -> assertEquals(2, 1 + 1)),
            dynamicTest("2 + 3 = 5", () -> assertEquals(5, 2 + 3)),
            dynamicTest("10 + 5 = 15", () -> assertEquals(15, 10 + 5))
        );
    }
}

2. Generating Tests from a Stream

Great for data-driven scenarios:

import java.util.stream.Stream;

@TestFactory
Stream<DynamicTest> squareTests() {
    return Stream.of(1, 2, 3, 4, 5)
        .map(n -> dynamicTest(
            "square of " + n + " is " + (n * n),
            () -> assertEquals(n * n, n * n)
        ));
}

3. Using an Input Generator, Display-Name Generator, and Test Executor

The DynamicTest.stream(...) helper simplifies iterator-based generation:

import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.util.Iterator;
import java.util.List;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertTrue;

class PalindromeDynamicTests {

    @TestFactory
    Stream<DynamicTest> palindromeTests() {
        Iterator<String> inputs = List.of("racecar", "level", "madam").iterator();

        return DynamicTest.stream(
            inputs,
            input -> "isPalindrome('" + input + "')",
            input -> assertTrue(isPalindrome(input))
        );
    }

    private boolean isPalindrome(String s) {
        return new StringBuilder(s).reverse().toString().equals(s);
    }
}

4. Grouping Tests with DynamicContainer

You can nest dynamic tests into containers to build a hierarchy:

import org.junit.jupiter.api.DynamicContainer;
import org.junit.jupiter.api.DynamicNode;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.util.List;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class MathDynamicTests {

    @TestFactory
    Stream<DynamicNode> mathOperations() {
        return Stream.of(
            dynamicContainer("Addition", List.of(
                dynamicTest("1+1", () -> assertEquals(2, 1 + 1)),
                dynamicTest("2+2", () -> assertEquals(4, 2 + 2))
            )),
            dynamicContainer("Multiplication", List.of(
                dynamicTest("2*3", () -> assertEquals(6, 2 * 3)),
                dynamicTest("4*5", () -> assertEquals(20, 4 * 5))
            ))
        );
    }
}

5. Reading Test Data from a File

Perfect for parameterized-style tests where inputs live outside code:

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

@TestFactory
Stream<DynamicTest> testsFromFile() throws Exception {
    return Files.lines(Path.of("src/test/resources/cases.csv"))
        .map(line -> line.split(","))
        .map(parts -> dynamicTest(
            "Case: " + parts[0],
            () -> assertEquals(Integer.parseInt(parts[2]),
                               Integer.parseInt(parts[0]) + Integer.parseInt(parts[1]))
        ));
}

When to Use @TestFactory vs @ParameterizedTest

Use @ParameterizedTest Use @TestFactory
Fixed set of arguments, single test body Fully dynamic generation (count, names, logic can all vary)
Simple data variations Hierarchical / conditional / streamed test generation
Compile-time known inputs Runtime-computed inputs

Important Lifecycle Note

⚠️ Standard lifecycle callbacks like @BeforeEach and @AfterEach do not run around each generated dynamic test — only around the factory method itself. If you need per-test setup, do it inside each Executable.

How do I use conditional test execution in JUnit?

JUnit 5 provides several ways to conditionally execute tests based on various runtime conditions. This is useful when tests should only run under specific circumstances — like a particular operating system, JRE version, environment variable, or system property.

1. Operating System Conditions

Use @EnabledOnOs and @DisabledOnOs to run tests only on specific operating systems.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;

class OsConditionalTest {

    @Test
    @EnabledOnOs(OS.WINDOWS)
    void onlyOnWindows() {
        // Runs only on Windows
    }

    @Test
    @EnabledOnOs({OS.LINUX, OS.MAC})
    void onlyOnLinuxOrMac() {
        // Runs only on Linux or macOS
    }

    @Test
    @DisabledOnOs(OS.WINDOWS)
    void notOnWindows() {
        // Skipped on Windows
    }
}

2. JRE Version Conditions

Use @EnabledOnJre, @DisabledOnJre, or @EnabledForJreRange to control tests based on the Java version.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledOnJre;
import org.junit.jupiter.api.condition.EnabledForJreRange;
import org.junit.jupiter.api.condition.JRE;

class JreConditionalTest {

    @Test
    @EnabledOnJre(JRE.JAVA_21)
    void onlyOnJava21() {
        // Runs only on Java 21
    }

    @Test
    @EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_25)
    void betweenJava17AndJava25() {
        // Runs on Java 17 through 25
    }
}

3. System Property Conditions

Enable or disable tests based on JVM system properties.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.condition.DisabledIfSystemProperty;

class SystemPropertyTest {

    @Test
    @EnabledIfSystemProperty(named = "env", matches = "ci")
    void onlyInCiEnvironment() {
        // Runs only when -Denv=ci
    }

    @Test
    @DisabledIfSystemProperty(named = "os.arch", matches = ".*32.*")
    void notOn32BitArch() {
        // Skipped on 32-bit architectures
    }
}

4. Environment Variable Conditions

Similar to system properties, but for OS-level environment variables.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable;

class EnvVarTest {

    @Test
    @EnabledIfEnvironmentVariable(named = "CI", matches = "true")
    void onlyInCi() {
        // Runs only when CI=true in environment
    }

    @Test
    @DisabledIfEnvironmentVariable(named = "ENV", matches = "prod")
    void skipInProduction() {
        // Skipped when ENV=prod
    }
}

5. Custom Conditions with @EnabledIf and @DisabledIf

For more complex logic, delegate to a static method that returns a boolean.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;

class CustomConditionTest {

    @Test
    @EnabledIf("isDatabaseAvailable")
    void runOnlyIfDatabaseIsUp() {
        // Runs only if the referenced method returns true
    }

    static boolean isDatabaseAvailable() {
        // Perform a real check (ping DB, check port, etc.)
        return "true".equalsIgnoreCase(System.getenv("DB_UP"));
    }
}

The method must:

  • Be static (unless the test class is @TestInstance(PER_CLASS))
  • Return boolean
  • Take no arguments (or accept ExtensionContext)

6. Programmatic Conditions with Assumptions

Sometimes you don’t want a test skipped by annotation but rather aborted mid-execution based on runtime state. Use Assumptions:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assumptions.assumingThat;

class AssumptionTest {

    @Test
    void runOnlyIfOnDeveloperMachine() {
        assumeTrue("DEV".equals(System.getenv("PROFILE")),
                   "Skipping: not on developer profile");
        // rest of test logic
    }

    @Test
    void partiallyConditional() {
        assumingThat("CI".equals(System.getenv("PROFILE")), () -> {
            // Only executed in CI, but the outer test still runs
        });
        // Always executed
    }
}

Difference:

  • @Disabled* / @Enabled* → skipped at discovery time (test shows as skipped).
  • Assumptions → aborted at execution time (test shows as aborted).

Which One Should You Use?

Scenario Recommended Approach
Skip based on OS / JRE @EnabledOnOs, @EnabledOnJre
Skip based on env variable or system property @EnabledIfEnvironmentVariable, @EnabledIfSystemProperty
Complex, dynamic condition @EnabledIf("methodName")
Runtime state check inside the test Assumptions.assumeTrue(...)

Key Takeaways

  • Use annotation-based conditions for static, predictable rules (OS, JRE, env).
  • Use @EnabledIf / @DisabledIf for custom logic that can’t be expressed with the built-in annotations.
  • Use Assumptions when you need to abort a test at runtime based on data available only during execution.
  • Always provide a reason/message (e.g., disabledReason = "...") — future you (and your teammates) will thank you when reading test reports.

How do I create a custom JUnit extension?

JUnit 5 provides a powerful extension model that lets you hook into the test lifecycle. Here’s a comprehensive guide to creating custom extensions.

1. Choose the Right Extension Interface

JUnit 5 offers several extension interfaces depending on what you want to do:

Interface Purpose
BeforeAllCallback / AfterAllCallback Run code before/after all tests in a class
BeforeEachCallback / AfterEachCallback Run code before/after each test
BeforeTestExecutionCallback / AfterTestExecutionCallback Wrap the actual test method execution
ParameterResolver Inject parameters into test methods
TestExecutionExceptionHandler Handle exceptions thrown by tests
TestWatcher Observe test results (passed, failed, skipped)
InvocationInterceptor Intercept method invocations

2. Basic Example: A Timing Extension

Here’s an extension that measures test execution time:

package com.example.testing;

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;

import java.lang.reflect.Method;

public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final Namespace NAMESPACE = Namespace.create(TimingExtension.class);
    private static final String START_TIME = "start_time";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        Method testMethod = context.getRequiredTestMethod();
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;

        System.out.printf("Method [%s] took %d ms.%n", testMethod.getName(), duration);
    }

    private Store getStore(ExtensionContext context) {
        return context.getStore(NAMESPACE);
    }
}

3. Parameter Resolver Example

Injecting custom parameters into test methods:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;

import java.util.UUID;

public class RandomUuidParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext)
            throws ParameterResolutionException {
        return parameterContext.getParameter().getType() == UUID.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext)
            throws ParameterResolutionException {
        return UUID.randomUUID();
    }
}

4. TestWatcher Example

Observing test outcomes:

package com.example.testing;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;

import java.util.Optional;

public class TestResultLogger implements TestWatcher {

    @Override
    public void testSuccessful(ExtensionContext context) {
        System.out.println("✔ " + context.getDisplayName() + " passed");
    }

    @Override
    public void testFailed(ExtensionContext context, Throwable cause) {
        System.out.println("✘ " + context.getDisplayName() + " failed: " + cause.getMessage());
    }

    @Override
    public void testAborted(ExtensionContext context, Throwable cause) {
        System.out.println("⚠ " + context.getDisplayName() + " aborted");
    }

    @Override
    public void testDisabled(ExtensionContext context, Optional<String> reason) {
        System.out.println("⊘ " + context.getDisplayName() + " disabled: " + reason.orElse("no reason"));
    }
}

5. Registering the Extension

There are three ways to register your extension:

a) Declarative with @ExtendWith

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

import java.util.UUID;

@ExtendWith({TimingExtension.class, RandomUuidParameterResolver.class})
class MyServiceTest {

    @Test
    void shouldDoSomething(UUID uniqueId) {
        System.out.println("Test running with ID: " + uniqueId);
    }
}

b) Programmatic with @RegisterExtension

Useful when the extension needs configuration:

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

class MyServiceTest {

    @RegisterExtension
    static TimingExtension timingExtension = new TimingExtension();

    @Test
    void shouldDoSomething() {
        // ...
    }
}

c) Automatic Registration via ServiceLoader

Create the file src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension containing:

com.example.testing.TimingExtension

Then enable auto-detection in junit-platform.properties:

junit.jupiter.extensions.autodetection.enabled=true

6. Creating a Custom Annotation (Meta-Annotation)

You can bundle extensions into a custom annotation:

package com.example.testing;

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

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({TimingExtension.class, TestResultLogger.class})
public @interface IntegrationTest {
}

Usage:

@IntegrationTest
class MyIntegrationTest {
    @Test
    void something() { /* ... */ }
}

7. Sharing State with the Store

The ExtensionContext.Store lets you share state between callbacks safely across parallel tests. Always use a unique Namespace to avoid collisions:

Namespace ns = Namespace.create(MyExtension.class, context.getRequiredTestMethod());
Store store = context.getStore(ns);
store.put("key", value);

Key Tips

  • Prefer composition — implement multiple interfaces on the same class if the concerns are related.
  • Use Store for state rather than instance fields, because JUnit may create new instances or use the extension across parallel tests.
  • Respect lifecycle order — class-level callbacks fire before method-level ones.
  • Make extensions stateless when possible to be safe in parallel execution.

Would you like me to help you build a specific extension for your project (e.g., one that integrates with Spring, Jakarta EE, or manages test data)?

How do I use JUnit extensions with @ExtendWith?

JUnit Jupiter (JUnit 5) provides a powerful extension model that lets you plug custom behavior into the test lifecycle. Instead of the old JUnit 4 approach of using @RunWith (which allowed only one runner) or @Rule, JUnit 5 uses @ExtendWith — and you can apply as many extensions as you want.

Let’s break this down step by step.


What Is an Extension?

An extension is a class that hooks into the JUnit Jupiter test lifecycle to add behavior such as:

  • setting up resources before tests
  • cleaning up after tests
  • injecting parameters into test methods
  • intercepting test execution
  • handling exceptions
  • conditionally disabling tests

Extensions implement one or more interfaces from the org.junit.jupiter.api.extension package.


The @ExtendWith Annotation

The @ExtendWith annotation registers an extension with a test class or method.

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

@ExtendWith(MyExtension.class)
class MyTest {
    // ...
}

You can apply it at the class level, the method level, or even on custom annotations.


Example 1: Using a Built-in Extension (Mockito)

One of the most common uses of @ExtendWith is enabling Mockito’s annotation-based mocking:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void findsUserById() {
        when(userRepository.findById(1L))
            .thenReturn(new User(1L, "Alice"));

        User user = userService.findUser(1L);

        assertNotNull(user);
    }
}

The MockitoExtension handles @Mock and @InjectMocks for you automatically.


Example 2: Applying Multiple Extensions

You can register multiple extensions in a single annotation:

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith({SpringExtension.class, MockitoExtension.class})
class OrderServiceTest {
    // ...
}

Or, stack them as separate annotations (JUnit 5 supports @ExtendWith as a repeatable annotation):

@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    // ...
}

Example 3: Writing Your Own Extension

Let’s write a simple extension that logs how long each test takes.

Step 1 — Create the Extension

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 class TimingExtension
        implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final Namespace NAMESPACE = Namespace.create(TimingExtension.class);
    private static final String START_TIME = "startTime";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;

        System.out.printf("Test '%s' took %d ms%n",
                context.getRequiredTestMethod().getName(), duration);
    }

    private Store getStore(ExtensionContext context) {
        return context.getStore(NAMESPACE);
    }
}

Step 2 — Use the Extension

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

@ExtendWith(TimingExtension.class)
class SlowServiceTest {

    @Test
    void slowOperation() throws InterruptedException {
        Thread.sleep(100);
    }
}

When you run this test, you’ll see something like:

Test 'slowOperation' took 103 ms

Extension Points You Can Implement

Extensions implement one or more of these interfaces:

Interface Purpose
BeforeAllCallback Runs once before all tests in a class
AfterAllCallback Runs once after all tests in a class
BeforeEachCallback Runs before each test method
AfterEachCallback Runs after each test method
BeforeTestExecutionCallback Runs immediately before test execution
AfterTestExecutionCallback Runs immediately after test execution
ParameterResolver Injects parameters into test methods
TestExecutionExceptionHandler Handles exceptions thrown during tests
ExecutionCondition Conditionally enables/disables tests
TestInstancePostProcessor Post-processes test instances after creation

Example 4: Parameter Injection with ParameterResolver

A common pattern is injecting objects directly into test methods:

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;

public class RandomIntExtension implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext,
                                     ExtensionContext extensionContext) {
        return parameterContext.getParameter().getType() == Integer.class;
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext,
                                   ExtensionContext extensionContext) {
        return (int) (Math.random() * 100);
    }
}

Usage:

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

@ExtendWith(RandomIntExtension.class)
class RandomTest {

    @Test
    void receivesRandomInt(Integer randomValue) {
        System.out.println("Random value: " + randomValue);
    }
}

JUnit will call resolveParameter automatically and pass the result into the test.


Example 5: Applying @ExtendWith at the Method Level

You don’t always need to apply extensions to the whole class:

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

class MixedTest {

    @Test
    void plainTest() {
        // no extensions
    }

    @Test
    @ExtendWith(TimingExtension.class)
    void timedTest() {
        // only this test uses the extension
    }
}

Example 6: Composing Custom Meta-Annotations

You can bundle @ExtendWith inside your own annotation to keep test classes clean:

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

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({TimingExtension.class, MockitoExtension.class})
public @interface IntegrationTest {
}

Then in your tests:

@IntegrationTest
class OrderServiceTest {
    // both TimingExtension and MockitoExtension are active
}

This is a great way to standardize test setup across a codebase.


Alternative: Programmatic Registration with @RegisterExtension

If your extension needs configuration at runtime, use @RegisterExtension on a field instead of @ExtendWith:

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

class ConfigurableTest {

    @RegisterExtension
    static final MyConfigurableExtension extension =
            MyConfigurableExtension.builder()
                    .withTimeout(500)
                    .build();
}

Use @ExtendWith when the extension has no state to configure. Use @RegisterExtension when you need to configure the extension instance.


Summary

Concept Description
@ExtendWith(SomeExtension.class) Registers an extension declaratively
Applied to a class Extension is active for all tests in that class
Applied to a method Extension is active only for that test method
Multiple extensions Use @ExtendWith({A.class, B.class}) or stack them
Custom meta-annotations Bundle multiple extensions under one annotation
@RegisterExtension Alternative for stateful/configurable extensions

Rule of thumb:

Use @ExtendWith when you want to plug in behavior declaratively.
Use @RegisterExtension when the extension needs runtime configuration.

Extensions are the recommended replacement for JUnit 4’s @RunWith and @Rule — and unlike @RunWith, you can compose as many of them as you need.