Use test-method scoped ExtensionContext in the SpringExtension

As of Spring Framework 6.2.13, we support JUnit Jupiter 5.12's
ExtensionContextScope.TEST_METHOD behavior in the SpringExtension and
the BeanOverrideTestExecutionListener; however, users can only benefit
from that if they explicitly set the following configuration parameter
for their entire test suite, which may have adverse effects on other
third-party JUnit Jupiter extensions.

junit.jupiter.extensions.testinstantiation.extensioncontextscope.default=test_method

For Spring Framework 7.0, in order to support dependency injection into
test class constructors and fields in @⁠Nested test class hierarchies
from the same ApplicationContext that is already used to perform
dependency injection into lifecycle and test methods (@⁠BeforeEach,
@⁠AfterEach, @⁠Test, etc.), we have decided to configure the
SpringExtension to use ExtensionContextScope.TEST_METHOD by default. In
addition, we have decided to provide a mechanism for users to switch
back to the legacy "test-class scoped ExtensionContext" behavior in
case third-party TestExecutionListener implementations are not yet
compatible with test-method scoped ExtensionContext and TestContext
semantics.

This commit achieves the above goals as follows.

- A new @⁠SpringExtensionConfig annotation has been introduced, which
  allows developers to configure the effective ExtensionContext scope
  used by the SpringExtension.

- The SpringExtension now overrides
  getTestInstantiationExtensionContextScope() to return
  ExtensionContextScope.TEST_METHOD.

- The postProcessTestInstance() and resolveParameter() methods in the
  SpringExtension now find the properly scoped ExtensionContext for the
  supplied test class, based on whether the @⁠Nested test class
  hierarchy is annotated with
  @⁠SpringExtensionConfig(useTestClassScopedExtensionContext = true).

See gh-35680
See gh-35716
Closes gh-35697
This commit is contained in:
Sam Brannen
2025-10-23 14:47:24 +02:00
parent b5557160e0
commit 41ae13df5d
32 changed files with 1667 additions and 783 deletions
@@ -80,6 +80,8 @@ import org.springframework.core.annotation.AliasFor;
*
* @author Sam Brannen
* @since 2.5
* @see org.springframework.test.context.junit.jupiter.SpringExtension SpringExtension
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig @SpringJUnitConfig
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig @SpringJUnitWebConfig
* @see ContextHierarchy @ContextHierarchy
@@ -95,6 +95,9 @@ import org.jspecify.annotations.Nullable;
* <li>{@link TestConstructor @TestConstructor}</li>
* </ul>
*
* <p>Note that {@code @NestedTestConfiguration} does not apply to
* {@link org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig}.
*
* @author Sam Brannen
* @since 5.3
* @see EnclosingConfiguration#INHERIT
@@ -61,6 +61,7 @@ import org.jspecify.annotations.Nullable;
* @see org.springframework.beans.factory.annotation.Autowired @Autowired
* @see jakarta.inject.Inject @jakarta.inject.Inject
* @see org.springframework.test.context.junit.jupiter.SpringExtension SpringExtension
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig @SpringJUnitConfig
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig @SpringJUnitWebConfig
* @see ContextConfiguration @ContextConfiguration
@@ -117,6 +117,7 @@ public interface TestContext extends AttributeAccessor, Serializable {
* invoke {@code testContext.getTestInstance().getClass()} instead of
* {@code testContext.getTestClass()}.
* @return the test class (never {@code null})
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
*/
Class<?> getTestClass();
@@ -169,6 +169,7 @@ public interface TestExecutionListener {
* concrete classes as necessary.
* @param testContext the test context for the test; never {@code null}
* @throws Exception allows any exception to propagate
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
*/
default void prepareTestInstance(TestContext testContext) throws Exception {
}
@@ -48,6 +48,7 @@ import org.junit.platform.commons.annotation.Testable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.ParameterResolutionDelegate;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.core.annotation.RepeatableContainers;
@@ -59,6 +60,8 @@ import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.context.support.PropertyProvider;
import org.springframework.test.context.support.TestConstructorUtils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ConcurrentLruCache;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.ReflectionUtils.MethodFilter;
@@ -66,18 +69,37 @@ import org.springframework.util.ReflectionUtils.MethodFilter;
* {@code SpringExtension} integrates the <em>Spring TestContext Framework</em>
* into the JUnit Jupiter testing framework.
*
* <p>To use this extension, simply annotate a JUnit Jupiter based test class with
* {@code @ExtendWith(SpringExtension.class)}, {@code @SpringJUnitConfig}, or
* {@code @SpringJUnitWebConfig}.
* <p>To use this extension, annotate a JUnit Jupiter based test class with
* {@code @ExtendWith(SpringExtension.class)}, {@code @SpringJUnitConfig},
* {@code @SpringJUnitWebConfig}, or any other annotation that is meta-annotated
* with {@code @ExtendWith(SpringExtension.class)} such as {@code @SpringBootTest},
* etc.
*
* <p>As of Spring Framework 7.0, the {@code SpringExtension} is
* {@linkplain #getTestInstantiationExtensionContextScope(ExtensionContext)
* configured} to use a test-method scoped {@link ExtensionContext}, which
* enables consistent dependency injection into fields and constructors from the
* {@link ApplicationContext} for the current test method in a
* {@link org.junit.jupiter.api.Nested @Nested} test class hierarchy. However,
* if a third-party {@link org.junit.platform.launcher.TestExecutionListener
* TestExecutionListener} is not compatible with the semantics associated with
* a test-method scoped extension context &mdash; or if a developer wishes to
* switch to test-class scoped semantics &mdash; the {@code SpringExtension} can
* be configured to use a test-class scoped extension context by annotating a
* top-level test class with
* {@link SpringExtensionConfig#useTestClassScopedExtensionContext()
* &#64;SpringExtensionConfig(useTestClassScopedExtensionContext = true)}.
*
* @author Sam Brannen
* @author Simon Baslé
* @since 5.0
* @see org.springframework.test.context.junit.jupiter.EnabledIf
* @see org.springframework.test.context.junit.jupiter.DisabledIf
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig
* @see org.springframework.test.context.TestContextManager
* @see org.junit.jupiter.api.extension.ExtendWith @ExtendWith
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig @SpringJUnitConfig
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig @SpringJUnitWebConfig
* @see org.springframework.test.context.junit.jupiter.EnabledIf @EnabledIf
* @see org.springframework.test.context.junit.jupiter.DisabledIf @DisabledIf
* @see org.springframework.test.context.TestContextManager TestContextManager
*/
public class SpringExtension implements BeforeAllCallback, AfterAllCallback, TestInstancePostProcessor,
BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback,
@@ -110,6 +132,14 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
private static final Namespace RECORD_APPLICATION_EVENTS_VALIDATION_NAMESPACE =
Namespace.create(SpringExtension.class.getName() + "#recordApplicationEvents.validation");
/**
* LRU cache for {@link SpringExtensionConfig#useTestClassScopedExtensionContext()}
* mappings, keyed by test class.
* @since 7.0
*/
private static final ConcurrentLruCache<Class<?>, Boolean> useTestClassScopedExtensionContextCache =
new ConcurrentLruCache<>(32, SpringExtension::useTestClassScopedExtensionContext);
// Note that @Test, @TestFactory, @TestTemplate, @RepeatedTest, and @ParameterizedTest
// are all meta-annotated with @Testable.
private static final List<Class<? extends Annotation>> JUPITER_ANNOTATION_TYPES =
@@ -119,6 +149,19 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
ReflectionUtils.USER_DECLARED_METHODS.and(SpringExtension::isAutowiredTestOrLifecycleMethod);
/**
* Returns {@link ExtensionContextScope#TEST_METHOD ExtensionContextScope.TEST_METHOD}.
* <p>This can be effectively overridden by annotating a test class with
* {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}.
* See the {@link SpringExtension class-level Javadoc} for further details.
* @since 7.0
* @see SpringExtensionConfig#useTestClassScopedExtensionContext()
*/
@Override
public ExtensionContextScope getTestInstantiationExtensionContextScope(ExtensionContext rootContext) {
return ExtensionContextScope.TEST_METHOD;
}
/**
* Delegates to {@link TestContextManager#beforeTestClass}.
*/
@@ -151,6 +194,8 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
*/
@Override
public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
context = findProperlyScopedExtensionContext(testInstance.getClass(), context);
validateAutowiredConfig(context);
validateRecordApplicationEventsConfig(context);
TestContextManager testContextManager = getTestContextManager(context);
@@ -332,7 +377,13 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
public @Nullable Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
Parameter parameter = parameterContext.getParameter();
int index = parameterContext.getIndex();
Executable executable = parameterContext.getDeclaringExecutable();
Class<?> testClass = extensionContext.getRequiredTestClass();
if (executable instanceof Constructor<?> constructor) {
testClass = constructor.getDeclaringClass();
extensionContext = findProperlyScopedExtensionContext(testClass, extensionContext);
}
ApplicationContext applicationContext = getApplicationContext(extensionContext);
return ParameterResolutionDelegate.resolveDependency(parameter, index, testClass,
applicationContext.getAutowireCapableBeanFactory());
@@ -390,4 +441,53 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes
return false;
}
/**
* Find the properly {@linkplain ExtensionContextScope scoped} {@link ExtensionContext}
* for the supplied test class.
* <p>If the supplied {@code ExtensionContext} is already properly scoped, it
* will be returned. Otherwise, if the test class is annotated with
* {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)},
* this method searches the {@code ExtensionContext} hierarchy for an
* {@code ExtensionContext} whose test class is the same as the supplied
* test class.
* @since 7.0
* @see SpringExtensionConfig#useTestClassScopedExtensionContext()
* @see ExtensionContextScope
*/
private static ExtensionContext findProperlyScopedExtensionContext(Class<?> testClass, ExtensionContext context) {
if (useTestClassScopedExtensionContextCache.get(testClass)) {
while (context.getRequiredTestClass() != testClass) {
context = context.getParent().get();
}
}
return context;
}
/**
* Determine if the supplied test class, or one of its enclosing classes, is annotated
* with {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}.
* @since 7.0
* @see SpringExtensionConfig#useTestClassScopedExtensionContext()
* @see #useTestClassScopedExtensionContextCache
*/
private static boolean useTestClassScopedExtensionContext(Class<?> testClass) {
MergedAnnotation<SpringExtensionConfig> mergedAnnotation =
MergedAnnotations.search(SearchStrategy.TYPE_HIERARCHY)
.withEnclosingClasses(ClassUtils::isInnerClass)
.from(testClass)
.get(SpringExtensionConfig.class);
if (mergedAnnotation.isPresent()) {
if (mergedAnnotation.getSource() instanceof Class<?> source && ClassUtils.isInnerClass(source)) {
throw new IllegalStateException("""
Test class [%s] must not be annotated with @SpringExtensionConfig. \
@SpringExtensionConfig is only supported on top-level classes.\
""".formatted(source.getName()));
}
return mergedAnnotation.getBoolean("useTestClassScopedExtensionContext");
}
return false;
}
}
@@ -0,0 +1,68 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.context.junit.jupiter;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* {@code @SpringExtensionConfig} is a type-level annotation that can be used to
* configure the behavior of the {@link SpringExtension}.
*
* <p>This annotation is only applicable to {@link org.junit.jupiter.api.Nested @Nested}
* test class hierarchies and should be applied to the top-level enclosing class
* of a {@code @Nested} test class hierarchy. Consequently, there is no need to
* declare this annotation on a test class that does not contain {@code @Nested}
* test classes.
*
* <p>Note that
* {@link org.springframework.test.context.NestedTestConfiguration @NestedTestConfiguration}
* does not apply to this annotation: {@code @SpringExtensionConfig} will always be
* detected within a {@code @Nested} test class hierarchy, effectively disregarding
* any {@code @NestedTestConfiguration(OVERRIDE)} declarations.
*
* @author Sam Brannen
* @since 7.0
* @see org.springframework.test.context.junit.jupiter.SpringExtension SpringExtension
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig @SpringJUnitConfig
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig @SpringJUnitWebConfig
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface SpringExtensionConfig {
/**
* Specify whether the {@link SpringExtension} should use a test-class scoped
* {@link org.junit.jupiter.api.extension.ExtensionContext ExtensionContext}
* within {@link org.junit.jupiter.api.Nested @Nested} test class hierarchies.
*
* <p>By default, the {@code SpringExtension} uses a test-method scoped
* {@code ExtensionContext}. Thus, there is no need to declare this annotation
* attribute with a value of {@code false}.
*
* @see SpringExtension
* @see SpringExtension#getTestInstantiationExtensionContextScope(org.junit.jupiter.api.extension.ExtensionContext)
*/
boolean useTestClassScopedExtensionContext();
}
@@ -42,10 +42,11 @@ import org.springframework.test.context.ContextLoader;
*
* @author Sam Brannen
* @since 5.0
* @see ExtendWith
* @see SpringExtension
* @see ContextConfiguration
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig
* @see org.junit.jupiter.api.extension.ExtendWith @ExtendWith
* @see org.springframework.test.context.junit.jupiter.SpringExtension SpringExtension
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
* @see org.springframework.test.context.junit.ContextConfiguration @ContextConfiguration
* @see org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig @SpringJUnitWebConfig
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@@ -45,11 +45,12 @@ import org.springframework.test.context.web.WebAppConfiguration;
*
* @author Sam Brannen
* @since 5.0
* @see ExtendWith
* @see SpringExtension
* @see ContextConfiguration
* @see WebAppConfiguration
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig
* @see org.junit.jupiter.api.extension.ExtendWith @ExtendWith
* @see org.springframework.test.context.junit.jupiter.SpringExtension SpringExtension
* @see org.springframework.test.context.junit.jupiter.SpringExtensionConfig @SpringExtensionConfig
* @see org.springframework.test.context.junit.ContextConfiguration @ContextConfiguration
* @see org.springframework.test.context.web.WebAppConfiguration @WebAppConfiguration
* @see org.springframework.test.context.junit.jupiter.SpringJUnitConfig @SpringJUnitConfig
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration