From 43b01382ed5768ee62a174b6f72c2089e000a0d7 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:04:33 +0100 Subject: [PATCH] Support global ExtensionContext scope configuration for SpringExtension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, the extension context scope used by the SpringExtension defaulted to test-method scoped and could only be changed on a per-class basis via the @⁠SpringExtensionConfig annotation. However, having to annotate each affected test class is cumbersome for developers who wish to use test-class scoped extension context semantics across their code base. To address that, this commit introduces support for configuring the global default extension context scope for the SpringExtension via Spring properties or JUnit properties. Specifically, this commit introduces a `spring.test.extension.context.scope` property that can be set via the SpringProperties mechanism or via a JUnit Platform configuration parameter. See gh-35697 Closes gh-36460 --- .../modules/ROOT/pages/appendix.adoc | 5 + .../integration-junit-jupiter.adoc | 36 +-- .../support-classes.adoc | 9 +- .../test/context/TestConstructor.java | 3 +- .../junit/jupiter/SpringExtension.java | 192 ++++++++++++---- .../junit/jupiter/SpringExtensionConfig.java | 6 +- ...ngExtensionExtensionContextScopeTests.java | 208 ++++++++++++++++++ 7 files changed, 401 insertions(+), 58 deletions(-) create mode 100644 spring-test/src/test/java/org/springframework/test/context/junit/jupiter/SpringExtensionExtensionContextScopeTests.java diff --git a/framework-docs/modules/ROOT/pages/appendix.adoc b/framework-docs/modules/ROOT/pages/appendix.adoc index dcfaacb27b3..b65a9ab4c45 100644 --- a/framework-docs/modules/ROOT/pages/appendix.adoc +++ b/framework-docs/modules/ROOT/pages/appendix.adoc @@ -138,6 +138,11 @@ xref:testing/testcontext-framework/ctx-management/context-pausing.adoc[Context P in the _Spring TestContext Framework_. See xref:testing/testcontext-framework/ctx-management/failure-threshold.adoc[Context Failure Threshold]. +| `spring.test.extension.context.scope` +| The default _extension context scope_ used by the `SpringExtension` in `@Nested` test +class hierarchies. See +xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-annotations-springextensionconfig[`@SpringExtensionConfig`]. + | `spring.test.enclosing.configuration` | The default _enclosing configuration inheritance mode_ to use if `@NestedTestConfiguration` is not present on a test class. See diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc index 03ea705eaab..1ae4d6c4e1b 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-junit-jupiter.adoc @@ -3,7 +3,7 @@ The following annotations are supported when used in conjunction with the xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-extension[`SpringExtension`] -and JUnit Jupiter (that is, the programming model in JUnit): +and the JUnit Jupiter testing framework: * xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-annotations-springextensionconfig[`@SpringExtensionConfig`] * xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-annotations-junit-jupiter-springjunitconfig[`@SpringJUnitConfig`] @@ -30,30 +30,40 @@ developer wishes to switch to test-class scoped semantics — the `SpringExtensi configured to use a test-class scoped `ExtensionContext` by annotating a top-level test class with `@SpringExtensionConfig(useTestClassScopedExtensionContext = true)`. +Alternatively, you can change the global default by setting the +`spring.test.extension.context.scope` property to `test_class`. The property is resolved +first via the +{spring-framework-api}/org/springframework/core/SpringProperties.html[`SpringProperties`] +mechanism (in a `spring.properties` file on the classpath or via JVM system properties, +for example `-Dspring.test.extension.context.scope=test_class`). If the Spring property +has not been set, the `SpringExtension` will attempt to resolve the property as a +https://docs.junit.org/current/running-tests/configuration-parameters.html[JUnit Platform configuration parameter] +as a fallback mechanism. If the property has not been set via either of those mechanisms, +the `SpringExtension` will use a test-method scoped extension context by default. Note, +however, that a `@SpringExtensionConfig` declaration always takes precedence over this +property. + [TIP] ==== -If your top-level test class is configured to use JUnit Jupiter's -`@TestInstance(Lifecycle.PER_CLASS)` semantics, the `SpringExtension` will always use a -test-class scoped `ExtensionContext`, and there is no need to declare -`@SpringExtensionConfig(useTestClassScopedExtensionContext = true)`. +If a test class uses JUnit Jupiter's `@TestInstance(Lifecycle.PER_CLASS)` semantics, the +`SpringExtension` will always use a test-class scoped `ExtensionContext`, and +configuration via `@SpringExtensionConfig(useTestClassScopedExtensionContext = true)` or +the `spring.test.extension.context.scope` property will have no effect for that test +class. ==== [NOTE] ==== This annotation is currently only applicable to `@Nested` test class hierarchies and should be applied to the top-level enclosing class of a `@Nested` test class hierarchy. - Consequently, there is no need to declare this annotation on a test class that does not contain `@Nested` test classes. -==== -[NOTE] -==== +In addition, xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-annotations-nestedtestconfiguration[`@NestedTestConfiguration`] -does not apply to this annotation. - -`@SpringExtensionConfig` will always be detected within a `@Nested` test class hierarchy, -effectively disregarding any `@NestedTestConfiguration(OVERRIDE)` declarations. +does not apply to this annotation. `@SpringExtensionConfig` will always be detected +within a `@Nested` test class hierarchy, effectively disregarding any +`@NestedTestConfiguration(OVERRIDE)` declarations. ==== [[integration-testing-annotations-junit-jupiter-springjunitconfig]] diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc index 54b9cd7b5cf..5f77d9e08b4 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc @@ -396,11 +396,10 @@ recursively. ==== As of Spring Framework 7.0, the `SpringExtension` uses a test-method scoped `ExtensionContext` within `@Nested` test class hierarchies by default. However, the -`SpringExtension` can be configured to use a test-class scoped `ExtensionContext`. - -See the documentation for -xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-annotations-springextensionconfig[`@SpringExtensionConfig`] -for details. +`SpringExtension` can be configured to use a test-class scoped `ExtensionContext` — for +example via `@SpringExtensionConfig` or the `spring.test.extension.context.scope` Spring +property (see +xref:testing/annotations/integration-junit-jupiter.adoc#integration-testing-annotations-springextensionconfig[`@SpringExtensionConfig`]). ==== [TIP] diff --git a/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java b/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java index 1e96aeb6e3d..9e667d4d24c 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestConstructor.java @@ -87,8 +87,7 @@ public @interface TestConstructor { * semantics by default. *

May alternatively be configured via the * {@link org.springframework.core.SpringProperties SpringProperties} - * mechanism. - *

This property may also be configured as a + * mechanism or as a * JUnit * Platform configuration parameter. * @see #autowireMode diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java index 84a68a76ab1..8a9f35d410d 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java @@ -23,6 +23,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.Arrays; import java.util.List; +import java.util.Locale; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterAll; @@ -42,12 +43,14 @@ import org.junit.jupiter.api.extension.ExtensionContext.Store; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestInstancePostProcessor; +import org.junit.jupiter.api.extension.TestInstantiationAwareExtension; import org.junit.jupiter.api.parallel.ExecutionMode; 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.SpringProperties; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; @@ -61,9 +64,9 @@ 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; +import org.springframework.util.StringUtils; /** * {@code SpringExtension} integrates the Spring TestContext Framework @@ -85,15 +88,15 @@ import org.springframework.util.ReflectionUtils.MethodFilter; * TestExecutionListener} is not compatible with the semantics associated with * a test-method scoped extension context — or if a developer wishes to * switch to test-class scoped semantics — the {@code SpringExtension} can - * be configured to use a test-class scoped extension context by annotating a - * top-level test class with + * be configured by annotating a top-level test class with * {@link SpringExtensionConfig#useTestClassScopedExtensionContext() - * @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}. Note, - * however, that the {@code SpringExtension} will always use a test-class scoped - * {@code ExtensionContext} if your top-level test class is configured to use JUnit - * Jupiter’s {@code @TestInstance(Lifecycle.PER_CLASS)} semantics, in which case - * there is no need to declare - * {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}. + * @SpringExtensionConfig(useTestClassScopedExtensionContext = true)} or by + * setting the {@value #EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME} property to + * {@link ExtensionContextScope#TEST_CLASS test_class}. Note that an explicit + * {@code @SpringExtensionConfig} declaration overrides the globally configured + * property. Furthermore, the {@code SpringExtension} will always use a test-class + * scoped {@code ExtensionContext} for a test class that is configured to use JUnit + * Jupiter’s {@code @TestInstance(Lifecycle.PER_CLASS)} semantics. * *

NOTE: This class requires JUnit Jupiter 6.0 or higher. * @@ -112,12 +115,42 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback, ParameterResolver { + /** + * JVM system property used to configure the default {@link ExtensionContextScope} + * for the {@code SpringExtension}: {@value}. + *

Acceptable values include enum constants defined in {@code ExtensionContextScope}, + * ignoring case. For example, the default may be changed to + * {@link ExtensionContextScope#TEST_CLASS} by supplying the following JVM + * system property via the command line. + *

-Dspring.test.extension.context.scope=test_class
+ *

If the property is not set, {@link ExtensionContextScope#TEST_METHOD} + * semantics will apply. Note, however, that {@code @SpringExtensionConfig} + * takes precedence over this property. + *

May alternatively be configured via the + * {@link org.springframework.core.SpringProperties SpringProperties} + * mechanism or as a + * JUnit + * Platform configuration parameter. + * @since 7.0.7 + * @see ExtensionContextScope + * @see SpringExtensionConfig + */ + public static final String EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME = "spring.test.extension.context.scope"; + /** * {@link Namespace} in which {@code TestContextManagers} are stored, keyed * by test class. */ private static final Namespace TEST_CONTEXT_MANAGER_NAMESPACE = Namespace.create(SpringExtension.class); + /** + * {@link Namespace} in which the resolved default {@link ExtensionContextScope} + * is stored. + * @since 7.0.7 + */ + private static final Namespace DEFAULT_EXTENSION_CONTEXT_SCOPE_NAMESPACE = + Namespace.create(SpringExtension.class.getName() + "#default.extension.context.scope"); + /** * {@link Namespace} in which {@code @Autowired} validation error messages * are stored, keyed by test class. @@ -139,14 +172,6 @@ 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, 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> JUPITER_ANNOTATION_TYPES = @@ -157,16 +182,23 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes /** - * Returns {@link ExtensionContextScope#TEST_METHOD ExtensionContextScope.TEST_METHOD}. - *

This can be effectively overridden by annotating a test class with - * {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}. - * See the {@linkplain SpringExtension class-level Javadoc} for further details. + * Returns {@link TestInstantiationAwareExtension.ExtensionContextScope#TEST_METHOD + * ExtensionContextScope.TEST_METHOD}. + *

This can be overridden locally via the + * {@link SpringExtensionConfig @SpringExtensionConfig} annotation or + * globally via the {@value #EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME} + * property. See the {@linkplain SpringExtension class-level Javadoc} for further + * details. * @since 7.0 * @see SpringExtensionConfig#useTestClassScopedExtensionContext() + * @see #EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME + * @see ExtensionContextScope */ @Override - public ExtensionContextScope getTestInstantiationExtensionContextScope(ExtensionContext rootContext) { - return ExtensionContextScope.TEST_METHOD; + public TestInstantiationAwareExtension.ExtensionContextScope getTestInstantiationExtensionContextScope( + ExtensionContext rootContext) { + + return TestInstantiationAwareExtension.ExtensionContextScope.TEST_METHOD; } /** @@ -464,20 +496,19 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes } /** - * Find the properly {@linkplain ExtensionContextScope scoped} {@link ExtensionContext} - * for the supplied test class. + * Find the properly scoped {@link ExtensionContext} for the supplied test class. *

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. + * will be returned. Otherwise, if test-class scoped semantics apply (see + * {@linkplain SpringExtension class-level Javadoc}), 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 #EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME * @see ExtensionContextScope */ private static ExtensionContext findProperlyScopedExtensionContext(Class testClass, ExtensionContext context) { - if (useTestClassScopedExtensionContextCache.get(testClass)) { + if (shouldUseTestClassScopedExtensionContext(testClass, context)) { while (context.getRequiredTestClass() != testClass) { context = context.getParent().get(); } @@ -486,13 +517,11 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes } /** - * Determine if the supplied test class, or one of its enclosing classes, is annotated - * with {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}. + * Determine whether test-class scoped {@code ExtensionContext} semantics apply + * for the supplied test class. * @since 7.0 - * @see SpringExtensionConfig#useTestClassScopedExtensionContext() - * @see #useTestClassScopedExtensionContextCache */ - private static boolean useTestClassScopedExtensionContext(Class testClass) { + private static boolean shouldUseTestClassScopedExtensionContext(Class testClass, ExtensionContext context) { MergedAnnotation mergedAnnotation = MergedAnnotations.search(SearchStrategy.TYPE_HIERARCHY) .withEnclosingClasses(ClassUtils::isInnerClass) @@ -509,7 +538,96 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes return mergedAnnotation.getBoolean("useTestClassScopedExtensionContext"); } - return false; + return (resolveDefaultExtensionContextScope(context) == ExtensionContextScope.TEST_CLASS); + } + + /** + * Resolve the default {@link ExtensionContextScope} from the + * {@value #EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME} property, first via + * {@link SpringProperties} and then via + * {@link ExtensionContext#getConfigurationParameter(String)} as a fallback + * strategy if the Spring property is not set. + * @param context the current {@code ExtensionContext} + * @return the resolved scope, or {@link ExtensionContextScope#TEST_METHOD} + * if the property is not set + * @since 7.0.7 + */ + private static ExtensionContextScope resolveDefaultExtensionContextScope(ExtensionContext context) { + return context.getRoot().getStore(DEFAULT_EXTENSION_CONTEXT_SCOPE_NAMESPACE) + .computeIfAbsent(ExtensionContextScope.class, key -> { + String springValue = SpringProperties.getProperty(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME); + ExtensionContextScope scope = ExtensionContextScope.from(springValue); + if (scope != null) { + return scope; + } + rejectUnsupportedScope(springValue); + + String junitValue = context.getConfigurationParameter(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME) + .orElse(null); + scope = ExtensionContextScope.from(junitValue); + if (scope != null) { + return scope; + } + rejectUnsupportedScope(junitValue); + + // Default to test-method scope. + return ExtensionContextScope.TEST_METHOD; + }, ExtensionContextScope.class); + } + + private static void rejectUnsupportedScope(@Nullable String scope) { + if (StringUtils.hasText(scope)) { + throw new IllegalArgumentException("Unsupported value '%s' for property '%s'" + .formatted(scope.strip(), EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME)); + } + } + + + /** + * Enumeration of extension context scopes for configuring how the + * {@link SpringExtension} resolves an {@link ExtensionContext} within + * {@code @Nested} test class hierarchies. + * + * @since 7.0.7 + * @see SpringExtension#EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME + * @see SpringExtensionConfig + * @see org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope + */ + public enum ExtensionContextScope { + + /** + * Use a test-method scoped {@link ExtensionContext} within {@code @Nested} + * test class hierarchies. + * @see org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope#TEST_METHOD + */ + TEST_METHOD, + + /** + * Use a test-class scoped {@link ExtensionContext} within {@code @Nested} + * test class hierarchies. + * @see org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope#DEFAULT + */ + TEST_CLASS; + + + /** + * Get the {@link ExtensionContextScope} enum constant with the supplied name, + * {@linkplain String#strip() stripped} and ignoring case. + * @param name the name of the enum constant to retrieve + * @return the corresponding enum constant, or {@code null} if not found + * @see ExtensionContextScope#valueOf(String) + */ + static @Nullable ExtensionContextScope from(@Nullable String name) { + if (!StringUtils.hasText(name)) { + return null; + } + try { + return ExtensionContextScope.valueOf(name.strip().toUpperCase(Locale.ROOT)); + } + catch (IllegalArgumentException ex) { + return null; + } + } } } diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtensionConfig.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtensionConfig.java index 6d7d58a5750..702586bb17d 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtensionConfig.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtensionConfig.java @@ -65,8 +65,12 @@ public @interface SpringExtensionConfig { * will always use a test-class scoped {@code ExtensionContext}, and there is no need * to declare {@code @SpringExtensionConfig(useTestClassScopedExtensionContext = true)}. * + *

Furthermore, this attribute takes precedence over global configuration + * of the {@code spring.test.extension.context.scope} property. + * * @see SpringExtension - * @see SpringExtension#getTestInstantiationExtensionContextScope(org.junit.jupiter.api.extension.ExtensionContext) + * @see SpringExtension#EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME + * @see SpringExtension.ExtensionContextScope */ boolean useTestClassScopedExtensionContext(); diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/SpringExtensionExtensionContextScopeTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/SpringExtensionExtensionContextScopeTests.java new file mode 100644 index 00000000000..92de037a721 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/SpringExtensionExtensionContextScopeTests.java @@ -0,0 +1,208 @@ +/* + * 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 org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.platform.testkit.engine.EngineTestKit; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.SpringProperties; +import org.springframework.core.env.Environment; +import org.springframework.test.context.NestedTestConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension.ExtensionContextScope; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration.OVERRIDE; +import static org.springframework.test.context.junit.jupiter.SpringExtension.EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME; +import static org.springframework.test.context.junit.jupiter.SpringExtension.ExtensionContextScope.TEST_CLASS; +import static org.springframework.test.context.junit.jupiter.SpringExtension.ExtensionContextScope.TEST_METHOD; + +/** + * Tests for {@link SpringExtension.ExtensionContextScope} and + * {@link SpringExtension#EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME}. + * + * @author Sam Brannen + * @since 7.0.7 + */ +class SpringExtensionExtensionContextScopeTests { + + @Test + void extensionContextScopeFromString() { + assertThat(ExtensionContextScope.from(null)).isNull(); + assertThat(ExtensionContextScope.from("")).isNull(); + assertThat(ExtensionContextScope.from(" ")).isNull(); + assertThat(ExtensionContextScope.from("bogus")).isNull(); + + assertThat(ExtensionContextScope.from("TEST_METHOD")).isSameAs(TEST_METHOD); + assertThat(ExtensionContextScope.from("test_method")).isSameAs(TEST_METHOD); + assertThat(ExtensionContextScope.from("Test_Method")).isSameAs(TEST_METHOD); + + assertThat(ExtensionContextScope.from("TEST_CLASS")).isSameAs(TEST_CLASS); + assertThat(ExtensionContextScope.from("test_class")).isSameAs(TEST_CLASS); + assertThat(ExtensionContextScope.from("Test_Class")).isSameAs(TEST_CLASS); + } + + @Test + void invalidExtensionContextScopeIsRejectedWhenConfiguredViaSpringProperties() { + SpringProperties.setProperty(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, "bogus"); + try { + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(InvalidScopeTestCase.class)) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(1).failed(1)); + } + finally { + SpringProperties.setProperty(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, null); + } + } + + @Test + void invalidExtensionContextScopeIsRejectedWhenConfiguredViaJUnitConfigurationParameter() { + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(InvalidScopeTestCase.class)) + .configurationParameter(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, "bogus") + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(1).failed(1)); + } + + @Test + void testClassScopeConfiguredViaSpringProperties() { + SpringProperties.setProperty(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, TEST_CLASS.name()); + try { + var results = EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(GlobalClassScopedConfigurationTestCase.class)) + .execute(); + results.containerEvents() + .assertStatistics(stats -> stats.started(3).succeeded(3).failed(0)); + results.testEvents() + .assertStatistics(stats -> stats.started(2).succeeded(2).failed(0)); + } + finally { + SpringProperties.setProperty(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, null); + } + } + + @Test + void testClassScopeConfiguredViaJUnitConfigurationParameter() { + var results = EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(GlobalClassScopedConfigurationTestCase.class)) + .configurationParameter(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, TEST_CLASS.name()) + .execute(); + results.containerEvents() + .assertStatistics(stats -> stats.started(3).succeeded(3).failed(0)); + results.testEvents() + .assertStatistics(stats -> stats.started(2).succeeded(2).failed(0)); + } + + @Test + void springExtensionConfigOverridesGlobalTestClassScopeConfiguration() { + EngineTestKit.engine("junit-jupiter") + .selectors(selectClass(SpringExtensionConfigOverridesGlobalPropertyTestCase.class)) + .configurationParameter(EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME, TEST_CLASS.name()) + .execute() + .testEvents() + .assertStatistics(stats -> stats.started(2).succeeded(2).failed(0)); + } + + + @SpringJUnitConfig + static class InvalidScopeTestCase { + + @Test + void test() { + // no-op + } + } + + @SpringJUnitConfig + @TestPropertySource(properties = "p1 = v1") + @NestedTestConfiguration(OVERRIDE) + @FailingTestCase + static class GlobalClassScopedConfigurationTestCase { + + @Autowired + Environment env1; + + @Test + void propertiesInEnvironment() { + assertThat(env1.getProperty("p1")).isEqualTo("v1"); + } + + @Nested + @SpringJUnitConfig(Config.class) + @TestPropertySource(properties = "p2 = v2") + class ConfigOverriddenByDefaultTests { + + @Autowired + Environment env2; + + @Test + void propertiesInEnvironment() { + assertThat(env1.getProperty("p1")).isEqualTo("v1"); + assertThat(env1).isNotSameAs(env2); + assertThat(env2.getProperty("p1")).isNull(); + assertThat(env2.getProperty("p2")).isEqualTo("v2"); + } + } + + @Configuration + static class Config { + } + } + + @SpringJUnitConfig + @SpringExtensionConfig(useTestClassScopedExtensionContext = false) + @TestPropertySource(properties = "p1 = v1") + @NestedTestConfiguration(OVERRIDE) + static class SpringExtensionConfigOverridesGlobalPropertyTestCase { + + @Autowired + Environment env1; + + @Test + void propertiesInEnvironment() { + assertThat(env1.getProperty("p1")).isEqualTo("v1"); + } + + @Nested + @SpringJUnitConfig(Config.class) + @TestPropertySource(properties = "p2 = v2") + class ConfigOverriddenByDefaultTests { + + @Autowired + Environment env2; + + @Test + void propertiesInEnvironment() { + assertThat(env1).isSameAs(env2); + assertThat(env2.getProperty("p1")).isNull(); + assertThat(env2.getProperty("p2")).isEqualTo("v2"); + } + } + + @Configuration + static class Config { + } + } + +}