Skip to content

Commit 9107f7b

Browse files
committed
Honor @⁠Primary before fallback qualifier for Bean Overrides
Prior to this commit, test bean overrides (for example, @⁠MockitoBean, @⁠TestBean, etc.) eagerly honored the name of the annotated field as a fallback qualifier, effectively ignoring @⁠Primary and @⁠Fallback semantics for certain use cases. This led to situations where a bean override for a test would select a different bean than the core container would for the same autowiring metadata. To address that, this commit revises the implementation of BeanOverrideBeanFactoryPostProcessor so that @⁠Primary and @⁠Fallback semantics are consistently honored before attempting to use the annotated field's name as a fallback qualifier. Closes gh-34374
1 parent 1d7cb4f commit 9107f7b

File tree

4 files changed

+169
-37
lines changed

4 files changed

+169
-37
lines changed

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

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -249,26 +249,21 @@ private void wrapBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideH
249249
if (beanName == null) {
250250
// We are wrapping an existing bean by-type.
251251
Set<String> candidateNames = getExistingBeanNamesByType(beanFactory, handler, true);
252-
int candidateCount = candidateNames.size();
253-
if (candidateCount == 1) {
254-
beanName = candidateNames.iterator().next();
252+
String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, field);
253+
if (uniqueCandidate != null) {
254+
beanName = uniqueCandidate;
255255
}
256256
else {
257-
String primaryCandidate = determinePrimaryCandidate(beanFactory, candidateNames, beanType.toClass());
258-
if (primaryCandidate != null) {
259-
beanName = primaryCandidate;
257+
String message = "Unable to select a bean to wrap: ";
258+
int candidateCount = candidateNames.size();
259+
if (candidateCount == 0) {
260+
message += "there are no beans of type %s%s.".formatted(beanType, requiredByField(field));
260261
}
261262
else {
262-
String message = "Unable to select a bean to wrap: ";
263-
if (candidateCount == 0) {
264-
message += "there are no beans of type %s%s.".formatted(beanType, requiredByField(field));
265-
}
266-
else {
267-
message += "found %d beans of type %s%s: %s"
268-
.formatted(candidateCount, beanType, requiredByField(field), candidateNames);
269-
}
270-
throw new IllegalStateException(message);
263+
message += "found %d beans of type %s%s: %s"
264+
.formatted(candidateCount, beanType, requiredByField(field), candidateNames);
271265
}
266+
throw new IllegalStateException(message);
272267
}
273268
beanName = BeanFactoryUtils.transformedBeanName(beanName);
274269
}
@@ -287,18 +282,20 @@ private void wrapBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideH
287282
}
288283

289284
@Nullable
290-
private String getBeanNameForType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler,
285+
private static String getBeanNameForType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler,
291286
boolean requireExistingBean) {
292287

293288
Field field = handler.getField();
294289
ResolvableType beanType = handler.getBeanType();
295290

296291
Set<String> candidateNames = getExistingBeanNamesByType(beanFactory, handler, true);
297-
int candidateCount = candidateNames.size();
298-
if (candidateCount == 1) {
299-
return candidateNames.iterator().next();
292+
String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, field);
293+
if (uniqueCandidate != null) {
294+
return uniqueCandidate;
300295
}
301-
else if (candidateCount == 0) {
296+
297+
int candidateCount = candidateNames.size();
298+
if (candidateCount == 0) {
302299
if (requireExistingBean) {
303300
throw new IllegalStateException(
304301
"Unable to override bean: there are no beans of type %s%s."
@@ -307,18 +304,13 @@ else if (candidateCount == 0) {
307304
return null;
308305
}
309306

310-
String primaryCandidate = determinePrimaryCandidate(beanFactory, candidateNames, beanType.toClass());
311-
if (primaryCandidate != null) {
312-
return primaryCandidate;
313-
}
314-
315307
throw new IllegalStateException(
316308
"Unable to select a bean to override: found %d beans of type %s%s: %s"
317309
.formatted(candidateCount, beanType, requiredByField(field), candidateNames));
318310
}
319311

320-
private Set<String> getExistingBeanNamesByType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler,
321-
boolean checkAutowiredCandidate) {
312+
private static Set<String> getExistingBeanNamesByType(ConfigurableListableBeanFactory beanFactory,
313+
BeanOverrideHandler handler, boolean checkAutowiredCandidate) {
322314

323315
Field field = handler.getField();
324316
ResolvableType resolvableType = handler.getBeanType();
@@ -345,25 +337,56 @@ private Set<String> getExistingBeanNamesByType(ConfigurableListableBeanFactory b
345337
// Filter out scoped proxy targets.
346338
beanNames.removeIf(ScopedProxyUtils::isScopedTarget);
347339

348-
// In case of multiple matches, fall back on the field's name as a last resort.
349-
if (field != null && beanNames.size() > 1) {
340+
return beanNames;
341+
}
342+
343+
/**
344+
* Determine the unique candidate in the given set of bean names.
345+
* <p>Honors both <em>primary</em> and <em>fallback</em> semantics, and
346+
* otherwise matches against the field name as a <em>fallback qualifier</em>.
347+
* @return the name of the unique candidate, or {@code null} if none found
348+
* @since 6.2.3
349+
* @see org.springframework.beans.factory.support.DefaultListableBeanFactory#determineAutowireCandidate
350+
*/
351+
@Nullable
352+
private static String determineUniqueCandidate(ConfigurableListableBeanFactory beanFactory,
353+
Set<String> candidateNames, ResolvableType beanType, @Nullable Field field) {
354+
355+
// Step 0: none or only one
356+
int candidateCount = candidateNames.size();
357+
if (candidateCount == 0) {
358+
return null;
359+
}
360+
if (candidateCount == 1) {
361+
return candidateNames.iterator().next();
362+
}
363+
364+
// Step 1: check primary candidate
365+
String primaryCandidate = determinePrimaryCandidate(beanFactory, candidateNames, beanType.toClass());
366+
if (primaryCandidate != null) {
367+
return primaryCandidate;
368+
}
369+
370+
// Step 2: use the field name as a fallback qualifier
371+
if (field != null) {
350372
String fieldName = field.getName();
351-
if (beanNames.contains(fieldName)) {
352-
return Set.of(fieldName);
373+
if (candidateNames.contains(fieldName)) {
374+
return fieldName;
353375
}
354376
}
355-
return beanNames;
377+
378+
return null;
356379
}
357380

358381
/**
359382
* Determine the primary candidate in the given set of bean names.
360383
* <p>Honors both <em>primary</em> and <em>fallback</em> semantics.
361384
* @return the name of the primary candidate, or {@code null} if none found
362-
* @see org.springframework.beans.factory.support.DefaultListableBeanFactory#determinePrimaryCandidate(Map, Class)
385+
* @see org.springframework.beans.factory.support.DefaultListableBeanFactory#determinePrimaryCandidate
363386
*/
364387
@Nullable
365-
private static String determinePrimaryCandidate(
366-
ConfigurableListableBeanFactory beanFactory, Set<String> candidateBeanNames, Class<?> beanType) {
388+
private static String determinePrimaryCandidate(ConfigurableListableBeanFactory beanFactory,
389+
Set<String> candidateBeanNames, Class<?> beanType) {
367390

368391
if (candidateBeanNames.isEmpty()) {
369392
return null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.context.bean.override.mockito.integration;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.junit.jupiter.api.extension.ExtendWith;
21+
22+
import org.springframework.beans.factory.annotation.Autowired;
23+
import org.springframework.context.ApplicationContext;
24+
import org.springframework.context.annotation.Configuration;
25+
import org.springframework.context.annotation.Import;
26+
import org.springframework.context.annotation.Primary;
27+
import org.springframework.stereotype.Component;
28+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
29+
import org.springframework.test.context.junit.jupiter.SpringExtension;
30+
31+
import static org.mockito.BDDMockito.then;
32+
import static org.springframework.test.mockito.MockitoAssertions.assertIsMock;
33+
import static org.springframework.test.mockito.MockitoAssertions.assertIsNotMock;
34+
35+
/**
36+
* Tests that {@link MockitoBean @MockitoBean} can be used to mock a bean when
37+
* there are multiple candidates; one is primary; and the field name matches
38+
* the name of a candidate which is not the primary candidate.
39+
*
40+
* @author Sam Brannen
41+
* @since 6.2.3
42+
* @see MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests
43+
* @see MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests
44+
* @see MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests
45+
*/
46+
@ExtendWith(SpringExtension.class)
47+
class MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests {
48+
49+
// The name of this field must be "baseService" to match the name of the non-primary candidate.
50+
@MockitoBean
51+
BaseService baseService;
52+
53+
@Autowired
54+
Client client;
55+
56+
57+
@Test // gh-34374
58+
void test(ApplicationContext context) {
59+
assertIsMock(baseService, "baseService field");
60+
assertIsMock(context.getBean("extendedService"), "extendedService bean");
61+
assertIsNotMock(context.getBean("baseService"), "baseService bean");
62+
63+
client.callService();
64+
65+
then(baseService).should().doSomething();
66+
}
67+
68+
69+
@Configuration(proxyBeanMethods = false)
70+
@Import({ BaseService.class, ExtendedService.class, Client.class })
71+
static class Config {
72+
}
73+
74+
@Component("baseService")
75+
static class BaseService {
76+
77+
public void doSomething() {
78+
}
79+
}
80+
81+
@Primary
82+
@Component("extendedService")
83+
static class ExtendedService extends BaseService {
84+
}
85+
86+
@Component("client")
87+
static class Client {
88+
89+
private final BaseService baseService;
90+
91+
public Client(BaseService baseService) {
92+
this.baseService = baseService;
93+
}
94+
95+
public void callService() {
96+
this.baseService.doSomething();
97+
}
98+
}
99+
100+
}

spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/integration/MockitoBeanWithMultipleExistingBeansAndOnePrimaryIntegrationTests.java

Lines changed: 2 additions & 1 deletion
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.
@@ -43,6 +43,7 @@
4343
* @author Sam Brannen
4444
* @author Phillip Webb
4545
* @since 6.2
46+
* @see MockitoBeanWithMultipleExistingBeansAndOnePrimaryAndOneConflictingQualifierIntegrationTests
4647
* @see MockitoBeanWithMultipleExistingBeansAndExplicitBeanNameIntegrationTests
4748
* @see MockitoBeanWithMultipleExistingBeansAndExplicitQualifierIntegrationTests
4849
*/

spring-test/src/test/java/org/springframework/test/mockito/MockitoAssertions.java

Lines changed: 9 additions & 1 deletion
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.
@@ -37,6 +37,14 @@ public static void assertIsMock(Object obj, String message) {
3737
assertThat(isMock(obj)).as("%s is a Mockito mock", message).isTrue();
3838
}
3939

40+
public static void assertIsNotMock(Object obj) {
41+
assertThat(isMock(obj)).as("is a Mockito mock").isFalse();
42+
}
43+
44+
public static void assertIsNotMock(Object obj, String message) {
45+
assertThat(isSpy(obj)).as("%s is a Mockito mock", message).isFalse();
46+
}
47+
4048
public static void assertIsSpy(Object obj) {
4149
assertThat(isSpy(obj)).as("is a Mockito spy").isTrue();
4250
}

0 commit comments

Comments
 (0)