Skip to content

Commit c9dd718

Browse files
committed
Provide first-class support for @⁠ContextHierarchy with Bean Overrides
Closes spring-projectsgh-34597
1 parent dbd47ff commit c9dd718

File tree

40 files changed

+2331
-47
lines changed

40 files changed

+2331
-47
lines changed

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

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

45+
String contextName = configAttributes.get(0).getName();
4546
Set<BeanOverrideHandler> handlers = new LinkedHashSet<>();
46-
findBeanOverrideHandlers(testClass, handlers);
47+
findBeanOverrideHandlers(testClass, contextName, handlers);
4748
if (handlers.isEmpty()) {
4849
return null;
4950
}
5051
return new BeanOverrideContextCustomizer(handlers);
5152
}
5253

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)));
54+
private void findBeanOverrideHandlers(Class<?> testClass, @Nullable String contextName, Set<BeanOverrideHandler> handlers) {
55+
BeanOverrideHandler.findAllHandlers(testClass).stream()
56+
.filter(handler -> handler.getContextName().isEmpty() || handler.getContextName().equals(contextName))
57+
.forEach(handler -> Assert.state(handlers.add(handler),
58+
() -> "Duplicate BeanOverrideHandler discovered in test class %s: %s"
59+
.formatted(testClass.getName(), handler)));
5860
}
5961

6062
}

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,26 @@ 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
protected BeanOverrideHandler(@Nullable Field field, ResolvableType beanType, @Nullable String beanName,
9496
BeanOverrideStrategy strategy) {
9597

98+
this(field, beanType, beanName, "", strategy);
99+
}
100+
101+
protected BeanOverrideHandler(@Nullable Field field, ResolvableType beanType, @Nullable String beanName,
102+
String contextName, BeanOverrideStrategy strategy) {
103+
96104
this.field = field;
97105
this.qualifierAnnotations = getQualifierAnnotations(field);
98106
this.beanType = beanType;
99107
this.beanName = beanName;
100108
this.strategy = strategy;
109+
this.contextName = contextName;
101110
}
102111

103112
/**
@@ -238,6 +247,21 @@ public final String getBeanName() {
238247
return this.beanName;
239248
}
240249

250+
/**
251+
* Get the name of the context hierarchy level in which this handler should
252+
* be applied.
253+
* <p>An empty string indicates that this handler should be applied to all
254+
* application contexts within a context hierarchy.
255+
* <p>If a context name is configured for this handler, it must match a name
256+
* configured via {@code @ContextConfiguration(name=...)}.
257+
* @since 6.2.6
258+
* @see org.springframework.test.context.ContextHierarchy @ContextHierarchy
259+
* @see org.springframework.test.context.ContextConfiguration#name()
260+
*/
261+
public final String getContextName() {
262+
return this.contextName;
263+
}
264+
241265
/**
242266
* Get the {@link BeanOverrideStrategy} for this {@code BeanOverrideHandler},
243267
* which influences how and when the bean override instance should be created.
@@ -311,6 +335,7 @@ public boolean equals(Object other) {
311335
BeanOverrideHandler that = (BeanOverrideHandler) other;
312336
if (!Objects.equals(this.beanType.getType(), that.beanType.getType()) ||
313337
!Objects.equals(this.beanName, that.beanName) ||
338+
!Objects.equals(this.contextName, that.contextName) ||
314339
!Objects.equals(this.strategy, that.strategy)) {
315340
return false;
316341
}
@@ -330,7 +355,7 @@ public boolean equals(Object other) {
330355

331356
@Override
332357
public int hashCode() {
333-
int hash = Objects.hash(getClass(), this.beanType.getType(), this.beanName, this.strategy);
358+
int hash = Objects.hash(getClass(), this.beanType.getType(), this.beanName, this.contextName, this.strategy);
334359
return (this.beanName != null ? hash : hash +
335360
Objects.hash((this.field != null ? this.field.getName() : null), this.qualifierAnnotations));
336361
}
@@ -341,6 +366,7 @@ public String toString() {
341366
.append("field", this.field)
342367
.append("beanType", this.beanType)
343368
.append("beanName", this.beanName)
369+
.append("contextName", this.contextName)
344370
.append("strategy", this.strategy)
345371
.toString();
346372
}

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@
2626
import org.apache.commons.logging.LogFactory;
2727

2828
import org.springframework.beans.factory.BeanCreationException;
29+
import org.springframework.beans.factory.BeanFactory;
2930
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
31+
import org.springframework.lang.Nullable;
3032
import org.springframework.util.Assert;
3133
import org.springframework.util.ReflectionUtils;
32-
import org.springframework.util.StringUtils;
34+
35+
import static org.springframework.test.context.bean.override.BeanOverrideContextCustomizer.REGISTRY_BEAN_NAME;
3336

3437
/**
3538
* An internal class used to track {@link BeanOverrideHandler}-related state after
@@ -51,10 +54,16 @@ class BeanOverrideRegistry {
5154

5255
private final ConfigurableBeanFactory beanFactory;
5356

57+
@Nullable
58+
private final BeanOverrideRegistry parent;
59+
5460

5561
BeanOverrideRegistry(ConfigurableBeanFactory beanFactory) {
5662
Assert.notNull(beanFactory, "ConfigurableBeanFactory must not be null");
5763
this.beanFactory = beanFactory;
64+
BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory();
65+
this.parent = (parentBeanFactory != null && parentBeanFactory.containsBean(REGISTRY_BEAN_NAME) ?
66+
parentBeanFactory.getBean(REGISTRY_BEAN_NAME, BeanOverrideRegistry.class) : null);
5867
}
5968

6069
/**
@@ -110,14 +119,13 @@ Object wrapBeanIfNecessary(Object bean, String beanName) {
110119
void inject(Object target, BeanOverrideHandler handler) {
111120
Field field = handler.getField();
112121
Assert.notNull(field, () -> "BeanOverrideHandler must have a non-null field: " + handler);
113-
String beanName = this.handlerToBeanNameMap.get(handler);
114-
Assert.state(StringUtils.hasLength(beanName), () -> "No bean found for BeanOverrideHandler: " + handler);
115-
inject(field, target, beanName);
122+
Object bean = getBeanForHandler(handler, field.getType());
123+
Assert.state(bean != null, () -> "No bean found for BeanOverrideHandler: " + handler);
124+
inject(field, target, bean);
116125
}
117126

118-
private void inject(Field field, Object target, String beanName) {
127+
private void inject(Field field, Object target, Object bean) {
119128
try {
120-
Object bean = this.beanFactory.getBean(beanName, field.getType());
121129
ReflectionUtils.makeAccessible(field);
122130
ReflectionUtils.setField(field, target, bean);
123131
}
@@ -126,4 +134,16 @@ private void inject(Field field, Object target, String beanName) {
126134
}
127135
}
128136

137+
@Nullable
138+
private Object getBeanForHandler(BeanOverrideHandler handler, Class<?> requiredType) {
139+
String beanName = this.handlerToBeanNameMap.get(handler);
140+
if (beanName != null) {
141+
return this.beanFactory.getBean(beanName, requiredType);
142+
}
143+
if (this.parent != null) {
144+
return this.parent.getBeanForHandler(handler, requiredType);
145+
}
146+
return null;
147+
}
148+
129149
}

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 within a context hierarchy.
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()
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 within a context hierarchy.
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()
157+
*/
158+
String contextName() default "";
159+
147160
/**
148161
* Extra interfaces that should also be declared by the mock.
149162
* <p>Defaults to none.

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,15 @@ class MockitoBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler {
6363

6464
MockitoBeanOverrideHandler(@Nullable Field field, ResolvableType typeToMock, MockitoBean mockitoBean) {
6565
this(field, typeToMock, (!mockitoBean.name().isBlank() ? mockitoBean.name() : null),
66-
(mockitoBean.enforceOverride() ? REPLACE : REPLACE_OR_CREATE),
67-
mockitoBean.reset(), mockitoBean.extraInterfaces(), mockitoBean.answers(), mockitoBean.serializable());
66+
mockitoBean.contextName(), (mockitoBean.enforceOverride() ? REPLACE : REPLACE_OR_CREATE),
67+
mockitoBean.reset(), mockitoBean.extraInterfaces(), mockitoBean.answers(), mockitoBean.serializable());
6868
}
6969

7070
private MockitoBeanOverrideHandler(@Nullable Field field, ResolvableType typeToMock, @Nullable String beanName,
71-
BeanOverrideStrategy strategy, MockReset reset, Class<?>[] extraInterfaces, Answers answers,
72-
boolean serializable) {
71+
String contextName, BeanOverrideStrategy strategy, MockReset reset, Class<?>[] extraInterfaces,
72+
Answers answers, boolean serializable) {
7373

74-
super(field, typeToMock, beanName, strategy, reset);
74+
super(field, typeToMock, beanName, contextName, strategy, reset);
7575
Assert.notNull(typeToMock, "'typeToMock' must not be null");
7676
this.extraInterfaces = asClassSet(extraInterfaces);
7777
this.answers = answers;
@@ -160,6 +160,7 @@ public String toString() {
160160
.append("field", getField())
161161
.append("beanType", getBeanType())
162162
.append("beanName", getBeanName())
163+
.append("contextName", getContextName())
163164
.append("strategy", getStrategy())
164165
.append("reset", getReset())
165166
.append("extraInterfaces", getExtraInterfaces())

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

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

139+
/**
140+
* The name of the context hierarchy level in which this {@code @MockitoSpyBean}
141+
* should be applied.
142+
* <p>Defaults to an empty string which indicates that this {@code @MockitoSpyBean}
143+
* should be applied to all application contexts within a context hierarchy.
144+
* <p>If a context name is configured, it must match a name configured via
145+
* {@code @ContextConfiguration(name=...)}.
146+
* @since 6.2.6
147+
* @see org.springframework.test.context.ContextHierarchy @ContextHierarchy
148+
* @see org.springframework.test.context.ContextConfiguration#name()
149+
*/
150+
String contextName() default "";
151+
139152
/**
140153
* The reset mode to apply to the spied bean.
141154
* <p>The default is {@link MockReset#AFTER} meaning that spies are automatically

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class MockitoSpyBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler {
5454

5555
MockitoSpyBeanOverrideHandler(@Nullable Field field, ResolvableType typeToSpy, MockitoSpyBean spyBean) {
5656
super(field, typeToSpy, (StringUtils.hasText(spyBean.name()) ? spyBean.name() : null),
57-
BeanOverrideStrategy.WRAP, spyBean.reset());
57+
spyBean.contextName(), BeanOverrideStrategy.WRAP, spyBean.reset());
5858
Assert.notNull(typeToSpy, "typeToSpy must not be null");
5959
}
6060

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

Lines changed: 4 additions & 3 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.
@@ -16,12 +16,13 @@
1616

1717
package org.springframework.test.context.bean.override;
1818

19-
import java.util.Collections;
19+
import java.util.List;
2020
import java.util.function.Consumer;
2121

2222
import org.junit.jupiter.api.Test;
2323

2424
import org.springframework.lang.Nullable;
25+
import org.springframework.test.context.ContextConfigurationAttributes;
2526
import org.springframework.test.context.bean.override.DummyBean.DummyBeanOverrideProcessor.DummyBeanOverrideHandler;
2627

2728
import static org.assertj.core.api.Assertions.assertThat;
@@ -92,7 +93,7 @@ private Consumer<BeanOverrideHandler> dummyHandler(@Nullable String beanName, Cl
9293

9394
@Nullable
9495
private BeanOverrideContextCustomizer createContextCustomizer(Class<?> testClass) {
95-
return this.factory.createContextCustomizer(testClass, Collections.emptyList());
96+
return this.factory.createContextCustomizer(testClass, List.of(new ContextConfigurationAttributes(testClass)));
9697
}
9798

9899

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

Lines changed: 4 additions & 3 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.
@@ -16,10 +16,11 @@
1616

1717
package org.springframework.test.context.bean.override;
1818

19-
import java.util.Collections;
19+
import java.util.List;
2020

2121
import org.springframework.context.ConfigurableApplicationContext;
2222
import org.springframework.lang.Nullable;
23+
import org.springframework.test.context.ContextConfigurationAttributes;
2324
import org.springframework.test.context.ContextCustomizer;
2425
import org.springframework.test.context.MergedContextConfiguration;
2526

@@ -44,7 +45,7 @@ public abstract class BeanOverrideContextCustomizerTestUtils {
4445
*/
4546
@Nullable
4647
public static ContextCustomizer createContextCustomizer(Class<?> testClass) {
47-
return factory.createContextCustomizer(testClass, Collections.emptyList());
48+
return factory.createContextCustomizer(testClass, List.of(new ContextConfigurationAttributes(testClass)));
4849
}
4950

5051
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ private static TestBeanOverrideHandler handlerFor(Field field, Method overrideMe
130130
TestBean annotation = field.getAnnotation(TestBean.class);
131131
String beanName = (StringUtils.hasText(annotation.name()) ? annotation.name() : null);
132132
return new TestBeanOverrideHandler(
133-
field, ResolvableType.forClass(field.getType()), beanName, BeanOverrideStrategy.REPLACE, overrideMethod);
133+
field, ResolvableType.forClass(field.getType()), beanName, "", BeanOverrideStrategy.REPLACE, overrideMethod);
134134
}
135135

136136
static class SampleOneOverride {

0 commit comments

Comments
 (0)