Skip to content

Commit 8f7ce50

Browse files
committed
Provide first-class support for Bean Overrides with @⁠ContextHierarchy
This commit provides first-class support for Bean Overrides (@⁠MockitoBean, @⁠MockitoSpyBean, @⁠TestBean, etc.) with @⁠ContextHierarchy. Specifically, bean overrides can now specify which ApplicationContext they target within the context hierarchy by configuring the `contextName` attribute in the annotation. The `contextName` must match a corresponding `name` configured via @⁠ContextConfiguration. For example, the following test class configures the name of the second hierarchy level to be "child" and simultaneously specifies that the ExampleService should be wrapped in a Mockito spy in the context named "child". Consequently, Spring will only attempt to create the spy in the "child" context and will not attempt to create the spy in the parent context. @ExtendWith(SpringExtension.class) @ContextHierarchy({ @ContextConfiguration(classes = Config1.class), @ContextConfiguration(classes = Config2.class, name = "child") }) class MockitoSpyBeanContextHierarchyTests { @MockitoSpyBean(contextName = "child") ExampleService service; // ... } See spring-projectsgh-33293 See spring-projectsgh-34597 See spring-projectsgh-34726 Signed-off-by: Sam Brannen <104798+sbrannen@users.noreply.github.com>
1 parent 3afd551 commit 8f7ce50

File tree

43 files changed

+2538
-52
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2538
-52
lines changed

spring-test/src/main/java/org/springframework/test/context/ContextConfiguration.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,18 @@
292292
* <p>If not specified the name will be inferred based on the numerical level
293293
* within all declared contexts within the hierarchy.
294294
* <p>This attribute is only applicable when used within a test class hierarchy
295-
* or enclosing class hierarchy that is configured using
296-
* {@code @ContextHierarchy}, in which case the name can be used for
297-
* <em>merging</em> or <em>overriding</em> this configuration with configuration
298-
* of the same name in hierarchy levels defined in superclasses or enclosing
299-
* classes. See the Javadoc for {@link ContextHierarchy @ContextHierarchy} for
300-
* details.
295+
* or enclosing class hierarchy that is configured using {@code @ContextHierarchy},
296+
* in which case the name can be used for <em>merging</em> or <em>overriding</em>
297+
* this configuration with configuration of the same name in hierarchy levels
298+
* defined in superclasses or enclosing classes. As of Spring Framework 6.2.6,
299+
* the name can also be used to identify the configuration in which a
300+
* <em>Bean Override</em> should be applied &mdash; for example,
301+
* {@code @MockitoBean(contextName = "child")}. See the Javadoc for
302+
* {@link ContextHierarchy @ContextHierarchy} for details.
301303
* @since 3.2.2
304+
* @see org.springframework.test.context.bean.override.mockito.MockitoBean#contextName @MockitoBean(contextName = ...)
305+
* @see org.springframework.test.context.bean.override.mockito.MockitoSpyBean#contextName @MockitoSpyBean(contextName = ...)
306+
* @see org.springframework.test.context.bean.override.convention.TestBean#contextName @TestBean(contextName = ...)
302307
*/
303308
String name() default "";
304309

spring-test/src/main/java/org/springframework/test/context/ContextHierarchy.java

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -29,10 +29,12 @@
2929
* ApplicationContexts} for integration tests.
3030
*
3131
* <h3>Examples</h3>
32+
*
3233
* <p>The following JUnit-based examples demonstrate common configuration
3334
* scenarios for integration tests that require the use of context hierarchies.
3435
*
3536
* <h4>Single Test Class with Context Hierarchy</h4>
37+
*
3638
* <p>{@code ControllerIntegrationTests} represents a typical integration testing
3739
* scenario for a Spring MVC web application by declaring a context hierarchy
3840
* consisting of two levels, one for the <em>root</em> {@code WebApplicationContext}
@@ -57,6 +59,7 @@
5759
* }</pre>
5860
*
5961
* <h4>Class Hierarchy with Implicit Parent Context</h4>
62+
*
6063
* <p>The following test classes define a context hierarchy within a test class
6164
* hierarchy. {@code AbstractWebTests} declares the configuration for a root
6265
* {@code WebApplicationContext} in a Spring-powered web application. Note,
@@ -83,12 +86,13 @@
8386
* public class RestWebServiceTests extends AbstractWebTests {}</pre>
8487
*
8588
* <h4>Class Hierarchy with Merged Context Hierarchy Configuration</h4>
89+
*
8690
* <p>The following classes demonstrate the use of <em>named</em> hierarchy levels
8791
* in order to <em>merge</em> the configuration for specific levels in a context
88-
* hierarchy. {@code BaseTests} defines two levels in the hierarchy, {@code parent}
89-
* and {@code child}. {@code ExtendedTests} extends {@code BaseTests} and instructs
92+
* hierarchy. {@code BaseTests} defines two levels in the hierarchy, {@code "parent"}
93+
* and {@code "child"}. {@code ExtendedTests} extends {@code BaseTests} and instructs
9094
* the Spring TestContext Framework to merge the context configuration for the
91-
* {@code child} hierarchy level, simply by ensuring that the names declared via
95+
* {@code "child"} hierarchy level, simply by ensuring that the names declared via
9296
* {@link ContextConfiguration#name} are both {@code "child"}. The result is that
9397
* three application contexts will be loaded: one for {@code "/app-config.xml"},
9498
* one for {@code "/user-config.xml"}, and one for <code>{"/user-config.xml",
@@ -111,6 +115,7 @@
111115
* public class ExtendedTests extends BaseTests {}</pre>
112116
*
113117
* <h4>Class Hierarchy with Overridden Context Hierarchy Configuration</h4>
118+
*
114119
* <p>In contrast to the previous example, this example demonstrates how to
115120
* <em>override</em> the configuration for a given named level in a context hierarchy
116121
* by setting the {@link ContextConfiguration#inheritLocations} flag to {@code false}.
@@ -131,6 +136,38 @@
131136
* )
132137
* public class ExtendedTests extends BaseTests {}</pre>
133138
*
139+
* <h4>Class Hierarchies with Bean Overrides</h4>
140+
*
141+
* <p>When {@code @ContextHierarchy} is used in conjunction with bean overrides such as
142+
* {@link org.springframework.test.context.bean.override.convention.TestBean @TestBean},
143+
* {@link org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean}, or
144+
* {@link org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean},
145+
* it may be desirable or necessary to have the override applied to a single level
146+
* in the context hierarchy. To achieve that, the bean override must specify a
147+
* context name that matches a name configured via {@link ContextConfiguration#name}.
148+
*
149+
* <p>The following test class configures the name of the second hierarchy level to be
150+
* {@code "user-config"} and simultaneously specifies that the {@code UserService} should
151+
* be wrapped in a Mockito spy in the context named {@code "user-config"}. Consequently,
152+
* Spring will only attempt to create the spy in the {@code "user-config"} context and will
153+
* not attempt to create the spy in the parent context.
154+
*
155+
* <pre class="code">
156+
* &#064;ExtendWith(SpringExtension.class)
157+
* &#064;ContextHierarchy({
158+
* &#064;ContextConfiguration(classes = AppConfig.class),
159+
* &#064;ContextConfiguration(classes = UserConfig.class, name = "user-config")
160+
* })
161+
* class IntegrationTests {
162+
*
163+
* &#064;MockitoSpyBean(contextName = "user-config")
164+
* UserService userService;
165+
*
166+
* // ...
167+
* }</pre>
168+
*
169+
* <h4>Miscellaneous</h4>
170+
*
134171
* <p>This annotation may be used as a <em>meta-annotation</em> to create custom
135172
* <em>composed annotations</em>.
136173
*

spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideContextCustomizerFactory.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,25 @@ class BeanOverrideContextCustomizerFactory implements ContextCustomizerFactory {
4242
public BeanOverrideContextCustomizer createContextCustomizer(Class<?> testClass,
4343
List<ContextConfigurationAttributes> configAttributes) {
4444

45+
// Base the context name on the "closest" @ContextConfiguration declaration
46+
// within the type and enclosing class hierarchies of the test class.
47+
String contextName = configAttributes.get(0).getName();
4548
Set<BeanOverrideHandler> handlers = new LinkedHashSet<>();
46-
findBeanOverrideHandlers(testClass, handlers);
49+
findBeanOverrideHandlers(testClass, contextName, handlers);
4750
if (handlers.isEmpty()) {
4851
return null;
4952
}
5053
return new BeanOverrideContextCustomizer(handlers);
5154
}
5255

53-
private void findBeanOverrideHandlers(Class<?> testClass, Set<BeanOverrideHandler> handlers) {
54-
BeanOverrideHandler.findAllHandlers(testClass).forEach(handler ->
55-
Assert.state(handlers.add(handler), () ->
56-
"Duplicate BeanOverrideHandler discovered in test class %s: %s"
57-
.formatted(testClass.getName(), handler)));
56+
private void findBeanOverrideHandlers(Class<?> testClass, @Nullable String contextName, Set<BeanOverrideHandler> handlers) {
57+
BeanOverrideHandler.findAllHandlers(testClass).stream()
58+
// If a handler does not specify a context name, it always gets applied.
59+
// Otherwise, the handler's context name must match the current context name.
60+
.filter(handler -> handler.getContextName().isEmpty() || handler.getContextName().equals(contextName))
61+
.forEach(handler -> Assert.state(handlers.add(handler),
62+
() -> "Duplicate BeanOverrideHandler discovered in test class %s: %s"
63+
.formatted(testClass.getName(), handler)));
5864
}
5965

6066
}

spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,17 @@ public abstract class BeanOverrideHandler {
8787
@Nullable
8888
private final String beanName;
8989

90+
private final String contextName;
91+
9092
private final BeanOverrideStrategy strategy;
9193

9294

9395
/**
9496
* Construct a new {@code BeanOverrideHandler} from the supplied values.
97+
* <p>To provide proper support for
98+
* {@link org.springframework.test.context.ContextHierarchy @ContextHierarchy},
99+
* invoke {@link #BeanOverrideHandler(Field, ResolvableType, String, String, BeanOverrideStrategy)}
100+
* instead.
95101
* @param field the {@link Field} annotated with {@link BeanOverride @BeanOverride},
96102
* or {@code null} if {@code @BeanOverride} was declared at the type level
97103
* @param beanType the {@linkplain ResolvableType type} of bean to override
@@ -102,11 +108,31 @@ public abstract class BeanOverrideHandler {
102108
protected BeanOverrideHandler(@Nullable Field field, ResolvableType beanType, @Nullable String beanName,
103109
BeanOverrideStrategy strategy) {
104110

111+
this(field, beanType, beanName, "", strategy);
112+
}
113+
114+
/**
115+
* Construct a new {@code BeanOverrideHandler} from the supplied values.
116+
* @param field the {@link Field} annotated with {@link BeanOverride @BeanOverride},
117+
* or {@code null} if {@code @BeanOverride} was declared at the type level
118+
* @param beanType the {@linkplain ResolvableType type} of bean to override
119+
* @param beanName the name of the bean to override, or {@code null} to look
120+
* for a single matching bean by type
121+
* @param contextName the name of the context hierarchy level in which the
122+
* handler should be applied, or an empty string to indicate that the handler
123+
* should be applied to all application contexts within a context hierarchy
124+
* @param strategy the {@link BeanOverrideStrategy} to use
125+
* @since 6.2.6
126+
*/
127+
protected BeanOverrideHandler(@Nullable Field field, ResolvableType beanType, @Nullable String beanName,
128+
String contextName, BeanOverrideStrategy strategy) {
129+
105130
this.field = field;
106131
this.qualifierAnnotations = getQualifierAnnotations(field);
107132
this.beanType = beanType;
108133
this.beanName = beanName;
109134
this.strategy = strategy;
135+
this.contextName = contextName;
110136
}
111137

112138
/**
@@ -247,6 +273,21 @@ public final String getBeanName() {
247273
return this.beanName;
248274
}
249275

276+
/**
277+
* Get the name of the context hierarchy level in which this handler should
278+
* be applied.
279+
* <p>An empty string indicates that this handler should be applied to all
280+
* application contexts.
281+
* <p>If a context name is configured for this handler, it must match a name
282+
* configured via {@code @ContextConfiguration(name=...)}.
283+
* @since 6.2.6
284+
* @see org.springframework.test.context.ContextHierarchy @ContextHierarchy
285+
* @see org.springframework.test.context.ContextConfiguration#name()
286+
*/
287+
public final String getContextName() {
288+
return this.contextName;
289+
}
290+
250291
/**
251292
* Get the {@link BeanOverrideStrategy} for this {@code BeanOverrideHandler},
252293
* which influences how and when the bean override instance should be created.
@@ -320,6 +361,7 @@ public boolean equals(Object other) {
320361
BeanOverrideHandler that = (BeanOverrideHandler) other;
321362
if (!Objects.equals(this.beanType.getType(), that.beanType.getType()) ||
322363
!Objects.equals(this.beanName, that.beanName) ||
364+
!Objects.equals(this.contextName, that.contextName) ||
323365
!Objects.equals(this.strategy, that.strategy)) {
324366
return false;
325367
}
@@ -339,7 +381,7 @@ public boolean equals(Object other) {
339381

340382
@Override
341383
public int hashCode() {
342-
int hash = Objects.hash(getClass(), this.beanType.getType(), this.beanName, this.strategy);
384+
int hash = Objects.hash(getClass(), this.beanType.getType(), this.beanName, this.contextName, this.strategy);
343385
return (this.beanName != null ? hash : hash +
344386
Objects.hash((this.field != null ? this.field.getName() : null), this.qualifierAnnotations));
345387
}
@@ -350,6 +392,7 @@ public String toString() {
350392
.append("field", this.field)
351393
.append("beanType", this.beanType)
352394
.append("beanName", this.beanName)
395+
.append("contextName", this.contextName)
353396
.append("strategy", this.strategy)
354397
.toString();
355398
}

spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideRegistry.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,22 @@
2424
import org.apache.commons.logging.Log;
2525
import org.apache.commons.logging.LogFactory;
2626

27+
import org.springframework.beans.factory.BeanFactory;
2728
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
2829
import org.springframework.lang.Nullable;
2930
import org.springframework.util.Assert;
3031

32+
import static org.springframework.test.context.bean.override.BeanOverrideContextCustomizer.REGISTRY_BEAN_NAME;
33+
3134
/**
3235
* An internal class used to track {@link BeanOverrideHandler}-related state after
3336
* the bean factory has been processed and to provide lookup facilities to test
3437
* execution listeners.
3538
*
39+
* <p>As of Spring Framework 6.2.6, {@code BeanOverrideRegistry} is hierarchical
40+
* and has access to a potential parent in order to provide first-class support
41+
* for {@link org.springframework.test.context.ContextHierarchy @ContextHierarchy}.
42+
*
3643
* @author Simon Baslé
3744
* @author Sam Brannen
3845
* @since 6.2
@@ -48,10 +55,16 @@ class BeanOverrideRegistry {
4855

4956
private final ConfigurableBeanFactory beanFactory;
5057

58+
@Nullable
59+
private final BeanOverrideRegistry parent;
60+
5161

5262
BeanOverrideRegistry(ConfigurableBeanFactory beanFactory) {
5363
Assert.notNull(beanFactory, "ConfigurableBeanFactory must not be null");
5464
this.beanFactory = beanFactory;
65+
BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory();
66+
this.parent = (parentBeanFactory != null && parentBeanFactory.containsBean(REGISTRY_BEAN_NAME) ?
67+
parentBeanFactory.getBean(REGISTRY_BEAN_NAME, BeanOverrideRegistry.class) : null);
5568
}
5669

5770
/**
@@ -110,7 +123,7 @@ Object wrapBeanIfNecessary(Object bean, String beanName) {
110123
* @param handler the {@code BeanOverrideHandler} that created the bean
111124
* @param requiredType the required bean type
112125
* @return the bean instance, or {@code null} if the provided handler is not
113-
* registered in this registry
126+
* registered in this registry or a parent registry
114127
* @since 6.2.6
115128
* @see #registerBeanOverrideHandler(BeanOverrideHandler, String)
116129
*/
@@ -120,6 +133,9 @@ Object getBeanForHandler(BeanOverrideHandler handler, Class<?> requiredType) {
120133
if (beanName != null) {
121134
return this.beanFactory.getBean(beanName, requiredType);
122135
}
136+
if (this.parent != null) {
137+
return this.parent.getBeanForHandler(handler, requiredType);
138+
}
123139
return null;
124140
}
125141

spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@
164164
*/
165165
String methodName() default "";
166166

167+
/**
168+
* The name of the context hierarchy level in which this {@code @TestBean}
169+
* should be applied.
170+
* <p>Defaults to an empty string which indicates that this {@code @TestBean}
171+
* should be applied to all application contexts.
172+
* <p>If a context name is configured, it must match a name configured via
173+
* {@code @ContextConfiguration(name=...)}.
174+
* @since 6.2.6
175+
* @see org.springframework.test.context.ContextHierarchy @ContextHierarchy
176+
* @see org.springframework.test.context.ContextConfiguration#name() @ContextConfiguration(name=...)
177+
*/
178+
String contextName() default "";
179+
167180
/**
168181
* Whether to require the existence of the bean being overridden.
169182
* <p>Defaults to {@code false} which means that a bean will be created if a

spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideHandler.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ final class TestBeanOverrideHandler extends BeanOverrideHandler {
4343

4444

4545
TestBeanOverrideHandler(Field field, ResolvableType beanType, @Nullable String beanName,
46-
BeanOverrideStrategy strategy, Method factoryMethod) {
46+
String contextName, BeanOverrideStrategy strategy, Method factoryMethod) {
4747

48-
super(field, beanType, beanName, strategy);
48+
super(field, beanType, beanName, contextName, strategy);
4949
this.factoryMethod = factoryMethod;
5050
}
5151

@@ -90,6 +90,7 @@ public String toString() {
9090
.append("field", getField())
9191
.append("beanType", getBeanType())
9292
.append("beanName", getBeanName())
93+
.append("contextName", getContextName())
9394
.append("strategy", getStrategy())
9495
.append("factoryMethod", this.factoryMethod)
9596
.toString();

spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBeanOverrideProcessor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public TestBeanOverrideHandler createHandler(Annotation overrideAnnotation, Clas
8282
}
8383

8484
return new TestBeanOverrideHandler(
85-
field, ResolvableType.forField(field, testClass), beanName, strategy, factoryMethod);
85+
field, ResolvableType.forField(field, testClass), beanName, testBean.contextName(), strategy, factoryMethod);
8686
}
8787

8888
/**

spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ abstract class AbstractMockitoBeanOverrideHandler extends BeanOverrideHandler {
3939

4040

4141
protected AbstractMockitoBeanOverrideHandler(@Nullable Field field, ResolvableType beanType,
42-
@Nullable String beanName, BeanOverrideStrategy strategy, MockReset reset) {
42+
@Nullable String beanName, String contextName, BeanOverrideStrategy strategy,
43+
MockReset reset) {
4344

44-
super(field, beanType, beanName, strategy);
45+
super(field, beanType, beanName, contextName, strategy);
4546
this.reset = (reset != null ? reset : MockReset.AFTER);
4647
}
4748

@@ -92,6 +93,7 @@ public String toString() {
9293
.append("field", getField())
9394
.append("beanType", getBeanType())
9495
.append("beanName", getBeanName())
96+
.append("contextName", getContextName())
9597
.append("strategy", getStrategy())
9698
.append("reset", getReset())
9799
.toString();

spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@
144144
*/
145145
Class<?>[] types() default {};
146146

147+
/**
148+
* The name of the context hierarchy level in which this {@code @MockitoBean}
149+
* should be applied.
150+
* <p>Defaults to an empty string which indicates that this {@code @MockitoBean}
151+
* should be applied to all application contexts.
152+
* <p>If a context name is configured, it must match a name configured via
153+
* {@code @ContextConfiguration(name=...)}.
154+
* @since 6.2.6
155+
* @see org.springframework.test.context.ContextHierarchy @ContextHierarchy
156+
* @see org.springframework.test.context.ContextConfiguration#name() @ContextConfiguration(name=...)
157+
*/
158+
String contextName() default "";
159+
147160
/**
148161
* Extra interfaces that should also be declared by the mock.
149162
* <p>Defaults to none.

0 commit comments

Comments
 (0)