I implemented a TemplateSpringExtension which extends SpringExtension to separate Spring context per JUnit Jupiter ClassTemplate parameter and still benefit from cached contexts.
It works because I can update the JUnit Jupiter 5 ExtensionContext.Store with prepared context like below and because SpringExtension uses store.getOrComputeIfAbsent which won't have an effect as TestContextManager is already defined by TemplateSpringExtension.
private static final ExtensionContext.Namespace TEST_CONTEXT_MANAGER_NAMESPACE =
ExtensionContext.Namespace.create(SpringExtension.class);
private void prepareTemplateInvocation(ExtensionContext context) {
Class<?> testClass = context.getRequiredTestClass();
ExtensionContext.Store store = context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE);
store.remove(testClass);
store.put(testClass, new TestContextManager(getBootstraper(testClass)));
}
Can I assume that it won't break with future Spring updates?
Full code looks like:
public class TemplateSpringExtension extends SpringExtension {
private final TemplateContext templateContext;
public record TemplateContext(String displayName, Object param, String... properties) {}
public interface WithTemplateParameter {
void withTemplateParameter(Object value);
}
public static ClassTemplateInvocationContext getClassTemplateInvocationContext(
TemplateContext templateContext) {
return new InvocationContext(templateContext);
}
@SuppressWarnings("unused")
public TemplateSpringExtension() {
this(null);
}
public TemplateSpringExtension(TemplateContext templateContext) {
this.templateContext = templateContext;
}
private static final ExtensionContext.Namespace TEST_CONTEXT_MANAGER_NAMESPACE =
ExtensionContext.Namespace.create(SpringExtension.class);
private void prepareTemplateInvocation(ExtensionContext context) {
Class<?> testClass = context.getRequiredTestClass();
ExtensionContext.Store store = context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE);
store.remove(testClass);
store.put(testClass, new TestContextManager(getBootstrapper(testClass)));
}
/**
* Similar to what BootstrapUtils.resolveTestContextBootstrapper(testClass) but with support for
* class template parameter.
*
* <p>Name of {@link TestContextBootstrapper} is part of the {@link MergedContextConfiguration} so
* We return {@link SpringBootTestContextBootstrapper} used by default by SpringBoot if there are
* no custom properties.
*
* <p>Otherwise, we return bootstrapper with properties customized by test template.
*/
private TestContextBootstrapper getBootstrapper(Class<?> testClass) {
DefaultCacheAwareContextLoaderDelegate contextLoader =
new DefaultCacheAwareContextLoaderDelegate();
DefaultBootstrapContext bootstrapContext =
new DefaultBootstrapContext(testClass, contextLoader);
String[] contextProperties = getContextProperties();
TestContextBootstrapper bootstrapper =
contextProperties != null
? new Bootstrapper(contextProperties)
: new SpringBootTestContextBootstrapper();
bootstrapper.setBootstrapContext(bootstrapContext);
return bootstrapper;
}
private String[] getContextProperties() {
if (templateContext != null
&& templateContext.properties() != null
&& templateContext.properties().length > 0) {
return templateContext.properties();
}
return null;
}
private static class Bootstrapper extends SpringBootTestContextBootstrapper {
private final String[] contextProperties;
public Bootstrapper(String... contextProperties) {
this.contextProperties = contextProperties;
}
@Override
protected String[] getProperties(Class<?> testClass) {
return contextProperties;
}
}
private static class InvocationContext implements ClassTemplateInvocationContext {
private final TemplateContext templateContext;
private final TemplateSpringExtension envExtension;
private InvocationContext(TemplateContext templateContext) {
this.templateContext = templateContext;
this.envExtension = new TemplateSpringExtension(templateContext);
}
@Override
public String getDisplayName(int invocationIndex) {
return templateContext.displayName();
}
@Override
public void prepareInvocation(ExtensionContext context) {
envExtension.prepareTemplateInvocation(context);
}
@Override
public List<Extension> getAdditionalExtensions() {
List<Extension> result = new ArrayList<>();
result.add(envExtension);
TestInstancePostProcessor testInstancePostProcessor =
(testInstance, context) -> {
if (testInstance instanceof WithTemplateParameter withTestParam) {
withTestParam.withTemplateParameter(templateContext.param());
}
};
result.add(testInstancePostProcessor);
return result;
}
}
}
Comment From: sbrannen
Hi @kodstark,
Congratulations on opening your first issue for the Spring Framework! 👍
The custom work you've done definitely looks intriguing; however, it's not immediately clear what exactly you are trying to achieve.
For example, it's not clear how or where your TemplateContext gets created. It's also not clear what purpose templateContext.param() serves or how this all works with a JUnit Jupiter @ClassTemplate.
It works because I can update the JUnit Jupiter 5
ExtensionContext.Storewith prepared context like below and becauseSpringExtensionusesstore.getOrComputeIfAbsentwhich won't have an effect asTestContextManageris already defined byTemplateSpringExtension.
As of Spring Framework 7, the SpringExtension actually uses ExtensionContext.Store.computeIfAbsent(...) (introduced in JUnit Jupiter 6), but that should not have any impact on that behavior.
However, your TemplateSpringExtension relies on knowledge of the internal implementation details of the SpringExtension, which I would not recommend.
Although I do not foresee that we would change the way we save the TestContextManager in the root ExtensionContext.Store, I cannot promise that it will always work exactly like that.
Can I assume that it won't break with future Spring updates?
As I alluded to above, it's never safe to rely on internal implementation details, since the maintainers of the code reserve the right to change the internal implementation.
The only API in SpringExtension that we support for use in third-party code is SpringExtension.getApplicationContext(ExtensionContext).
In summary, if you continue to maintain third-party code that relies on internal implementation details, you do so at your own risk.
However, if you would like to propose enhancements to the SpringExtension or Spring TestContext Framework, please open a dedicated issue to discuss your use case and proposal.
In light of the above, I am closing this issue.
Cheers,
Sam
Comment From: kodstark
Thanks @sbrannen for the reply.
I know that such API is internal but I didn't find other way to separate Spring context per @ClassTemplate parameter.
I have 50+ test classes doing blackbox testing for an application storing document with Avro in bytea column. I would like to have minimal changes to run those tests also with storing JSONB with separate memory database. Each test feeds unique data so I don't need cleardown and thanks to context caching tests run fast. However if I use @ParameterizedClass (it will share Spring context for all parameters) then in corner case (like retries) test will receive data from shared in-memory database from other mode which is not supported by real application. Thus having separate Spring context for each parameter of @ClassTemplate solves that nicely.
It is very similar use case like JUnit parametrized test with parametrized Spring ApplicationContext configuration. Its reply links to few Spring Framework opened issues - especially issue #20849.
JUnit Jupiter 5.13 added recently @ClassTemplate (May 2025) and I use it as below:
@ClassTemplate
@ExtendWith(AvroJsonContextProvider.class)
@ActiveProfiles(value = "test_unit", resolver = EnvProfilesResolver.class)
@ContextConfiguration(
classes = {
...
})
public abstract class AbstractAvroJsonPublisherTests {}
/**
* {@link org.junit.jupiter.api.ClassTemplate} provider separating spring context especially to
* separate memory datasource for JSONB and AVRO.
*/
public class AvroJsonContextProvider implements ClassTemplateInvocationContextProvider {
@Override
public boolean supportsClassTemplate(ExtensionContext context) {
return true;
}
/**
* Uses spring-context in JSONB mode - it does not provide properties as relies on default in
* test_unit profile.
*/
private static final TemplateSpringExtension.TemplateContext JSONB =
new TemplateSpringExtension.TemplateContext(
"jsonb", JSON);
/** Uses spring-context in AVRO mode */
private static final TemplateSpringExtension.TemplateContext AVRO =
new TemplateSpringExtension.TemplateContext(
"avro",
AVRO,
"...typeToStore=AVRO",
"testEnv.segment=avro");
@Override
public Stream<ClassTemplateInvocationContext> provideClassTemplateInvocationContexts(
ExtensionContext context) {
return Stream.of(JSONB, AVRO).map(TemplateSpringExtension::getClassTemplateInvocationContext);
}
}
Comment From: kodstark
Purpose of templateContext.param() is to re-run tests for different @ClassTemplate parameter but reusing the same Spring context.
private static final TemplateSpringExtension.TemplateContext AVRO1 =
new TemplateSpringExtension.TemplateContext(
"avro1",
"config1",
"testEnv.segment=avro");
private static final TemplateSpringExtension.TemplateContext AVRO2 =
new TemplateSpringExtension.TemplateContext(
"avro2",
"config2",
"testEnv.segment=avro");
...
public abstract class AbstractAvroJsonPublisherTests implements TemplateSpringExtension.WithTemplateParameter {
protected String config;
@Override
public void withTemplateParameter(Object value) {
if (value instanceof String config0) {
config = config0;
}
}
}
Comment From: kodstark
Actually it looks like that I can avoid extending SpringExtension and use @SpringBootTest like below. For official support having API to reset and pre-populate Junit5 store with provided TestContext would be enough.
@ClassTemplate
@ExtendWith(AvroJsonContextProvider.class)
@ActiveProfiles(value = "test_unit", resolver = EnvProfilesResolver.class)
@SpringBootTest(classes = { ... })
public abstract class AbstractAvroJsonPublisherTests {}
...
public class AvroJsonContextProvider implements ClassTemplateInvocationContextProvider {
@Override
public boolean supportsClassTemplate(ExtensionContext context) {
return true;
}
/**
* Uses spring-context in JSONB mode - it does not provide properties as relies on default in
* test_unit profile.
*/
private static final TemplateSpringExtension.TemplateContext JSONB =
new TemplateSpringExtension.TemplateContext(
"jsonb", JSON);
/** Uses spring-context in AVRO mode */
private static final TemplateSpringExtension.TemplateContext AVRO =
new TemplateSpringExtension.TemplateContext(
"avro",
AVRO,
"...typeToStore=AVRO",
"testEnv.segment=avro");
@Override
public Stream<ClassTemplateInvocationContext> provideClassTemplateInvocationContexts(
ExtensionContext context) {
return Stream.of(JSONB, AVRO).map(TemplateSpringExtension::getClassTemplateInvocationContext);
}
}
...
public class TemplateSpringExtension {
public record TemplateContext(String displayName, Object param, String... properties) {}
public interface WithTemplateParameter {
void withTemplateParameter(Object value);
}
public static ClassTemplateInvocationContext getClassTemplateInvocationContext(
TemplateContext templateContext) {
return new InvocationContext(templateContext);
}
@SuppressWarnings("ClassCanBeRecord")
private static class InvocationContext implements ClassTemplateInvocationContext {
private static final ExtensionContext.Namespace TEST_CONTEXT_MANAGER_NAMESPACE =
ExtensionContext.Namespace.create(SpringExtension.class);
private final TemplateContext templateContext;
private InvocationContext(TemplateContext templateContext) {
this.templateContext = templateContext;
}
@Override
public String getDisplayName(int invocationIndex) {
return templateContext.displayName();
}
@Override
public void prepareInvocation(ExtensionContext context) {
Class<?> testClass = context.getRequiredTestClass();
ExtensionContext.Store store = context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE);
store.remove(testClass);
store.put(testClass, new TestContextManager(getBootstrapper(testClass)));
}
/**
* Similar to what BootstrapUtils.resolveTestContextBootstrapper(testClass) but with support for
* class template parameter.
*
* <p>Name of {@link TestContextBootstrapper} is part of the {@link MergedContextConfiguration}
* so We return {@link SpringBootTestContextBootstrapper} used by default by SpringBoot if there
* are no custom properties.
*
* <p>Otherwise, we return bootstrapper with properties customized by test template.
*/
private TestContextBootstrapper getBootstrapper(Class<?> testClass) {
DefaultCacheAwareContextLoaderDelegate contextLoader =
new DefaultCacheAwareContextLoaderDelegate();
DefaultBootstrapContext bootstrapContext =
new DefaultBootstrapContext(testClass, contextLoader);
String[] contextProperties = getContextProperties();
TestContextBootstrapper bootstrapper =
contextProperties != null
? new Bootstrapper(contextProperties)
: new SpringBootTestContextBootstrapper();
bootstrapper.setBootstrapContext(bootstrapContext);
return bootstrapper;
}
private String[] getContextProperties() {
if (templateContext != null
&& templateContext.properties() != null
&& templateContext.properties().length > 0) {
return templateContext.properties();
}
return null;
}
@Override
public List<Extension> getAdditionalExtensions() {
List<Extension> result = new ArrayList<>();
TestInstancePostProcessor testInstancePostProcessor =
(testInstance, context) -> {
if (testInstance instanceof WithTemplateParameter withTestParam) {
withTestParam.withTemplateParameter(templateContext.param());
}
};
result.add(testInstancePostProcessor);
return result;
}
}
private static class Bootstrapper extends SpringBootTestContextBootstrapper {
private final String[] contextProperties;
public Bootstrapper(String... contextProperties) {
this.contextProperties = contextProperties;
}
@Override
protected String[] getProperties(Class<?> testClass) {
return contextProperties;
}
}
}
Comment From: kodstark
Unfortunately those solutions do not work nicely with BeforeTestClassEvent / AfterTestClassEvent events.
If org.junit.jupiter.api.extension.ClassTemplateInvocationContext#getAdditionalExtensions returns instance of TemplateSpringExtension then its beforeAll is not executed.
If I try to define test with explicit TemplateSpringExtension.class then its beforeAll method is executed but it happens before org.junit.jupiter.api.extension.ClassTemplateInvocationContext#prepareInvocation.
@ClassTemplate
@ExtendWith({AvroJsonContextProvider.class, TemplateSpringExtension.class})
I can try a code like below but it means that templateManager registered by beforeAll is lost with their handlers.
private static final ExtensionContext.Namespace TEST_CONTEXT_MANAGER_NAMESPACE =
ExtensionContext.Namespace.create(SpringExtension.class);
private static final ExtensionContext.Namespace TEMPLATE_MANAGER_NAMESPACE =
ExtensionContext.Namespace.create(TemplateSpringExtension.class);
private void prepareTemplateInvocation(ExtensionContext context) {
Class<?> testClass = context.getRequiredTestClass();
ExtensionContext.Store store = context.getRoot().getStore(TEST_CONTEXT_MANAGER_NAMESPACE);
ExtensionContext.Store templateStore = context.getRoot().getStore(TEMPLATE_MANAGER_NAMESPACE);
TestContextManager templateManager =
templateStore.getOrComputeIfAbsent(
getTemplateKey(testClass),
k -> new TestContextManager(getBootstrapper(k.testClass)),
TestContextManager.class);
store.put(testClass, templateManager);
}