Have you ever duplicated an entire test class just to run it once with H2, once with PostgreSQL, and once with a mock? Copy–paste works—until it doesn’t. Maintenance explodes, and your original test intent gets buried under a mountain of boilerplate.
Testing is the backbone of high-quality software, but as our codebases grow, so does the redundancy in our test suites. While @ParameterizedTest is the go-to for data-driven testing, JUnit 5 offers a more powerful, architectural tool for these scenarios: @TestTemplate.
In this guide, we’ll dive deep into how to use @TestTemplate to build flexible, reusable test logic that scales with your application.
What is JUnit 5 @TestTemplate?
At its core, @TestTemplate is not a test case itself. Instead, it is a blueprint or a container for dynamic test execution.
Unlike a standard @Test which runs once, a @TestTemplate is designed to be invoked multiple times based on a set of Invocation Contexts provided by a registered provider. Think of it as a skeleton that gets “fleshed out” by different configurations at runtime.
Visualizing the Mental Model
To understand how the pieces fit together, visualize the flow of execution:
@TestTemplate method (The Logic)
↓
TestTemplateInvocationContextProvider (The Decision Maker)
↓
Stream of Invocation Contexts (The Scenarios)
↓
Each context = 1 test execution + its own unique extensions/parameters
Why not just use @ParameterizedTest?
Although they look similar at first glance, these two annotations solve fundamentally different problems:
| Feature | @ParameterizedTest | @TestTemplate |
| Primary Goal | Data-driven testing | Context-driven testing |
| Input Type | Simple values (Strings, Ints, Enums) | Full Extension Contexts (Mocks, Resolvers) |
| Complexity | Low (uses @ValueSource, @CsvSource) | Medium/High (requires custom classes) |
| Best For | Testing edge cases with many inputs | Testing behavior across environments |
- @ParameterizedTest: Best for testing the same logic with different data inputs.
- @TestTemplate: Best for testing the same logic with different behavioral contexts or extensions (e.g., running the same test against different database drivers or with different security roles).
Example 1: Basic Provider Logic (Messaging Service)
Let’s say we want to test a MessagingService that should behave identically whether we are using a Mock Service or a Real Integration Service.
1. Define the Context Provider
The provider is the “brain.” It checks if the template is supported and prepares the stream of contexts.
import org.junit.jupiter.api.extension.*;
import java.util.stream.Stream;
import java.util.Collections;
import java.util.List;
public class MessagingTestProvider implements TestTemplateInvocationContextProvider {
@Override
public boolean supportsTestTemplate(ExtensionContext context) {
return true;
}
@Override
public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
return Stream.of(
messagingContext("Mock-Provider"),
messagingContext("Real-Provider")
);
}
private TestTemplateInvocationContext messagingContext(String name) {
return new TestTemplateInvocationContext() {
@Override
public String getDisplayName(int invocationIndex) {
return "Invocation #" + invocationIndex + ": " + name;
}
@Override
public List<Extension> getAdditionalExtensions() {
return Collections.singletonList(new ParameterResolver() {
@Override
public boolean supportsParameter(ParameterContext pContext, ExtensionContext eContext) {
return pContext.getParameter().getType().equals(String.class);
}
@Override
public Object resolveParameter(ParameterContext pContext, ExtensionContext eContext) {
return name;
}
});
}
};
}
}
2. Create the Test Template
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class MessagingServiceTest {
@TestTemplate
@ExtendWith(MessagingTestProvider.class)
void shouldSendMessageSuccessfully(String providerName) {
System.out.println("LOG: Testing Messaging with " + providerName);
assertNotNull(providerName);
}
}
Console Output for Example 1:
LOG: Testing Messaging with Mock-Provider
LOG: Testing Messaging with Real-Provider
Test Results:
✔ Invocation #1: Mock-Provider
✔ Invocation #2: Real-Provider
Example 2: Real-World Scenario (Database Dialect Testing)
In enterprise apps, you often need to ensure your repository logic works across different database dialects (e.g., H2 for local dev and PostgreSQL for production). Instead of writing two test classes, we use a template.
1. The Database Provider
public class DatabaseDialectProvider implements TestTemplateInvocationContextProvider {
@Override
public boolean supportsTestTemplate(ExtensionContext context) {
return context.getRequiredTestClass().equals(RepositoryIntegrationTest.class);
}
@Override
public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
return Stream.of(
dialectContext("H2-In-Memory", "jdbc:h2:mem:testdb"),
dialectContext("PostgreSQL-Container", "jdbc:postgresql://localhost:5432/test")
);
}
private TestTemplateInvocationContext dialectContext(String label, String url) {
return new TestTemplateInvocationContext() {
@Override
public String getDisplayName(int invocationIndex) {
return "Dialect: " + label;
}
@Override
public List<Extension> getAdditionalExtensions() {
// Here we could inject a custom DataSource or Connection object
return Collections.singletonList(new ParameterResolver() {
@Override
public boolean supportsParameter(ParameterContext p, ExtensionContext e) {
return p.getParameter().getType().equals(String.class);
}
@Override
public Object resolveParameter(ParameterContext p, ExtensionContext e) {
return url;
}
});
}
};
}
}
2. The Repository Test
public class RepositoryIntegrationTest {
@TestTemplate
@ExtendWith(DatabaseDialectProvider.class)
void testSaveUserAcrossDialects(String jdbcUrl) {
System.out.println("LOG: Initializing connection to " + jdbcUrl);
// Repository save logic here...
}
}
Console Output for Example 2:
LOG: Initializing connection to jdbc:h2:mem:testdb
LOG: Initializing connection to jdbc:postgresql://localhost:5432/test
Test Results:
✔ Dialect: H2-In-Memory
✔ Dialect: PostgreSQL-Container
Deep Dive: The Lifecycle of a Test Template
Understanding the lifecycle is crucial for debugging and designing robust test architectures. When the JUnit 5 Jupiter engine encounters a method annotated with @TestTemplate, it executes a specialized multi-step workflow to resolve and run the test:
- Provider Discovery: It identifies all registered
TestTemplateInvocationContextProviderinstances. This includes those registered via@ExtendWithon the method or class level, as well as those registered globally via the JavaServiceLoadermechanism. - Compatibility Check: For every discovered provider, JUnit calls
supportsTestTemplate(ExtensionContext). This allows the provider to inspect the test method, class, or annotations to decide if it should provide data for this specific scenario. - Context Resolution: If a provider signals support, JUnit calls
provideTestTemplateInvocationContexts(). This method must return aStreamofTestTemplateInvocationContextobjects. If this stream is empty, no tests are executed for that provider. - Invocation Loop: For each context in the resulting
Stream, JUnit creates a unique execution “sandbox”:- Test Descriptor Creation: A dedicated node is added to the test tree, which is why you see individual entries in your IDE’s test runner for each invocation.
- Extension Application: It applies the extensions returned by
getAdditionalExtensions(). These can includeParameterResolvers,BeforeEachCallbacks, orTestExecutionExceptionHandler. This is the mechanism that allows each invocation to have its own unique set of injected mocks or system properties. - Standard Lifecycle Execution: It executes the full standard JUnit lifecycle around the template method. This means
@BeforeEach,@AfterEach, and any registered lifecycle callbacks are triggered separately for every single invocation.
This encapsulated approach ensures that each “branch” of the template is isolated, preventing side effects from one context (like a database connection) from bleeding into another.
When to Use @TestTemplate (Best Use Cases)
- Cross-Browser Testing: Inject different
WebDriverinstances into the same UI test suite. - Security Roles: Run functional tests while automatically switching security contexts (e.g.,
ROLE_ADMIN,ROLE_USER). - OS-Specific Testing: Dynamically skip or modify tests based on Windows, Linux, or macOS.
When NOT to Use @TestTemplate
Balance builds trust. Avoid this annotation when:
- You only need different input values (use
@ParameterizedTest). - You don’t need custom extensions or complex lifecycle control.
- Readability matters more than architectural reuse; templates add abstraction that can sometimes hide test intent.
Performance & Maintainability Note
Because each invocation is a full JUnit execution, excessive contexts can slow down large test suites. While it’s tempting to test every combination, prefer fewer, meaningful contexts over exhaustive ones. This keeps your CI/CD pipeline fast and your results actionable.
Common Pitfalls to Avoid
- Empty Streams: If your provider returns an empty
Stream, the test will be considered “passed” with zero executions. - Heavy Extensions: Since extensions are created per-invocation, avoid expensive logic inside the
TestTemplateInvocationContext. - State Contamination: Ensure that
@BeforeEachcorrectly resets any shared state between template invocations.
Conclusion
The @TestTemplate annotation is one of the most underrated features of JUnit 5. If @ParameterizedTest answers “what values should I test?”, then @TestTemplate answers “under which system conditions should this behavior hold?”
By separating the what (the test logic) from the how (the invocation context), you create a test suite that is not just a safety net, but a flexible piece of engineering.
Happy Testing!