Bean overriding by type uses isAutowireCandidate for matching

This commit uses the bean factory `isAutowiredCandidate` method directly
in `BeanOverrideBeanFactoryPostProcessor` to select a single match among
multiple candidates when matching by type.

The expected consequence, in most cases, is that this will delegate to
a `@Qualifier`-aware `QualifierAnnotationAutowireCandidateResolver`.
In that sense, bean overriding by-type matching is now potentially
taking Qualifier annotations or meta-annotations into account.

It also changes the way existing bean definitions are checked in case
a bean name has been specified: factory beans are now taken into account
when checking the type of an existing definition matches the expected
bean override type.

Closes gh-32822
This commit is contained in:
Simon Baslé
2024-05-17 16:57:57 +02:00
parent b17d1c5124
commit d5c7a5e2db
12 changed files with 319 additions and 43 deletions

View File

@@ -16,6 +16,7 @@
package org.springframework.test.context.bean.override;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
@@ -24,6 +25,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
@@ -75,8 +77,8 @@ class BeanOverrideBeanFactoryPostProcessorTests {
assertThatIllegalStateException()
.isThrownBy(context::refresh)
.withMessage("Unable to override bean 'explicit'; " +
"there is no bean definition to replace with that name");
.withMessage("Unable to override bean 'explicit'; there is no bean definition " +
"to replace with that name of type org.springframework.test.context.bean.override.example.ExampleService");
}
@Test
@@ -176,6 +178,26 @@ class BeanOverrideBeanFactoryPostProcessorTests {
.matches(Predicate.not(BeanDefinition::isPrototype), "!isPrototype");
}
@Test
void createDefinitionShouldSetQualifierElement() {
AnnotationConfigApplicationContext context = createContext(QualifiedBean.class);
context.registerBeanDefinition("singleton", new RootBeanDefinition(String.class, () -> "ORIGINAL"));
context.register(QualifiedBean.class);
assertThatNoException().isThrownBy(context::refresh);
assertThat(context.getBeanDefinition("singleton"))
.isInstanceOfSatisfying(RootBeanDefinition.class, this::isTheValueField);
}
private void isTheValueField(RootBeanDefinition def) {
assertThat(def.getQualifiedElement()).isInstanceOfSatisfying(Field.class, field -> {
assertThat(field.getDeclaringClass()).isEqualTo(QualifiedBean.class);
assertThat(field.getName()).as("annotated field name")
.isEqualTo("value");
});
}
private AnnotationConfigApplicationContext createContext(Class<?>... classes) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
@@ -260,6 +282,18 @@ class BeanOverrideBeanFactoryPostProcessorTests {
}
}
static class QualifiedBean {
@Qualifier("preferThis")
@ExampleBeanOverrideAnnotation(beanName = "singleton",
value = "useThis", createIfMissing = false)
private String value;
static String useThis() {
return "USED THIS";
}
}
static class TestFactoryBean implements FactoryBean<Object> {
@Override

View File

@@ -21,9 +21,11 @@ import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.testkit.engine.EngineExecutionResults;
import org.junit.platform.testkit.engine.EngineTestKit;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.bean.override.example.CustomQualifier;
import org.springframework.test.context.bean.override.example.ExampleService;
import org.springframework.test.context.bean.override.example.RealExampleService;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -39,10 +41,26 @@ public class TestBeanByTypeIntegrationTests {
@TestBean
ExampleService anyNameForService;
@TestBean(methodName = "someString")
@Qualifier("prefer")
StringBuilder anyNameForStringBuilder;
@TestBean(methodName = "someString2")
@CustomQualifier
StringBuilder anyNameForStringBuilder2;
static ExampleService anyNameForServiceTestOverride() {
return new RealExampleService("Mocked greeting");
}
static StringBuilder someString() {
return new StringBuilder("Prefer TestBean String");
}
static StringBuilder someString2() {
return new StringBuilder("CustomQualifier TestBean String");
}
@Test
void overrideIsFoundByType(ApplicationContext ctx) {
assertThat(this.anyNameForService)
@@ -52,6 +70,21 @@ public class TestBeanByTypeIntegrationTests {
assertThat(this.anyNameForService.greeting()).isEqualTo("Mocked greeting");
}
@Test
void overrideIsFoundByTypeWithQualifierDisambiguation(ApplicationContext ctx) {
assertThat(this.anyNameForStringBuilder)
.as("direct qualifier")
.isSameAs(ctx.getBean("two"))
.hasToString("Prefer TestBean String");
assertThat(this.anyNameForStringBuilder2)
.as("meta qualifier")
.isSameAs(ctx.getBean("three"))
.hasToString("CustomQualifier TestBean String");
assertThat(ctx.getBean("one")).as("no qualifier needed").hasToString("Prod One");
}
@Test
void zeroCandidates() {
Class<?> caseClass = CaseNone.class;
@@ -92,6 +125,23 @@ public class TestBeanByTypeIntegrationTests {
ExampleService bean1() {
return new RealExampleService("Production hello");
}
@Bean("one")
StringBuilder beanString1() {
return new StringBuilder("Prod One");
}
@Bean("two")
@Qualifier("prefer")
StringBuilder beanString2() {
return new StringBuilder("Prod Two");
}
@Bean("three")
@CustomQualifier
StringBuilder beanString3() {
return new StringBuilder("Prod Three");
}
}
@SpringJUnitConfig(FailingNone.class)

View File

@@ -92,7 +92,7 @@ public class TestBeanIntegrationTests {
.cause()
.isInstanceOf(IllegalStateException.class)
.hasMessage("Unable to override bean 'noOriginalBean'; " +
"there is no bean definition to replace with that name"));
"there is no bean definition to replace with that name of type java.lang.String"));
}
@Test
@@ -107,7 +107,7 @@ public class TestBeanIntegrationTests {
.cause()
.isInstanceOf(IllegalStateException.class)
.hasMessage("Unable to override bean 'notPresent'; " +
"there is no bean definition to replace with that name"));
"there is no bean definition to replace with that name of type java.lang.String"));
}
@Test
@@ -142,6 +142,20 @@ public class TestBeanIntegrationTests {
"supported candidates [fieldTestOverride]"));
}
@Test
void testBeanFailingBeanOfWrongType() {
EngineExecutionResults results = EngineTestKit.engine("junit-jupiter")//
.selectors(selectClass(Failing5.class))//
.execute();
assertThat(results.allEvents().failed().stream()).hasSize(1).first()
.satisfies(e -> assertThat(e.getRequiredPayload(TestExecutionResult.class)
.getThrowable()).get(THROWABLE)
.rootCause().isInstanceOf(IllegalStateException.class)
.hasMessage("Unable to override bean 'notString'; there is no bean definition to replace with " +
"that name of type java.lang.String"));
}
@Nested
@DisplayName("With @TestBean on enclosing class")
class TestBeanNested {
@@ -262,4 +276,25 @@ public class TestBeanIntegrationTests {
fail("should fail earlier");
}
}
@SpringJUnitConfig
static class Failing5 {
@Bean("notString")
StringBuilder bean1() {
return new StringBuilder("not a String");
}
@TestBean(name = "notString")
String field;
@Test
void ignored() {
fail("should fail earlier");
}
static String fieldTestOverride() {
return "should be ignored";
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2002-2024 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.bean.override.example;
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;
import org.springframework.beans.factory.annotation.Qualifier;
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Qualifier
public @interface CustomQualifier {
}

View File

@@ -23,16 +23,21 @@ import org.junit.platform.testkit.engine.EngineExecutionResults;
import org.junit.platform.testkit.engine.EngineTestKit;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.test.context.bean.override.example.CustomQualifier;
import org.springframework.test.context.bean.override.example.ExampleService;
import org.springframework.test.context.bean.override.example.RealExampleService;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.InstanceOfAssertFactories.THROWABLE;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.BDDMockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -47,6 +52,14 @@ public class MockitoByTypeIntegrationTests {
@MockitoBean
ExampleService anyNameForService;
@MockitoBean
@Qualifier("prefer")
StringBuilder ambiguous;
@MockitoBean
@CustomQualifier
StringBuilder ambiguousMeta;
@Test
void overrideIsFoundByType(ApplicationContext ctx) {
assertThat(this.anyNameForService)
@@ -62,6 +75,39 @@ public class MockitoByTypeIntegrationTests {
verifyNoMoreInteractions(this.anyNameForService);
}
@Test
void overrideIsFoundByTypeAndDisambiguatedByQualifier(ApplicationContext ctx) {
assertThat(this.ambiguous)
.satisfies(o -> assertThat(Mockito.mockingDetails(o).isMock())
.as("isMock").isTrue())
.isSameAs(ctx.getBean("ambiguous2"));
assertThatException().isThrownBy(() -> ctx.getBean(StringBuilder.class))
.withMessageEndingWith("but found 2: ambiguous2,ambiguous1");
assertThat(this.ambiguous.length()).isZero();
assertThat(this.ambiguous.substring(0)).isNull();
verify(this.ambiguous, times(1)).length();
verify(this.ambiguous, times(1)).substring(anyInt());
verifyNoMoreInteractions(this.ambiguous);
}
@Test
void overrideIsFoundByTypeAndDisambiguatedByMetaQualifier(ApplicationContext ctx) {
assertThat(this.ambiguousMeta)
.satisfies(o -> assertThat(Mockito.mockingDetails(o).isMock())
.as("isMock").isTrue())
.isSameAs(ctx.getBean("ambiguous1"));
assertThatException().isThrownBy(() -> ctx.getBean(StringBuilder.class))
.withMessageEndingWith("but found 2: ambiguous2,ambiguous1");
assertThat(this.ambiguousMeta.length()).isZero();
assertThat(this.ambiguousMeta.substring(0)).isNull();
verify(this.ambiguousMeta, times(1)).length();
verify(this.ambiguousMeta, times(1)).substring(anyInt());
verifyNoMoreInteractions(this.ambiguousMeta);
}
@Test
void zeroCandidates() {
@@ -127,6 +173,14 @@ public class MockitoByTypeIntegrationTests {
@MockitoSpyBean
ExampleService anyNameForService;
@MockitoSpyBean
@Qualifier("prefer")
StringBuilder ambiguous;
@MockitoSpyBean
@CustomQualifier
StringBuilder ambiguousMeta;
@Test
void overrideIsFoundByType(ApplicationContext ctx) {
assertThat(this.anyNameForService)
@@ -140,6 +194,38 @@ public class MockitoByTypeIntegrationTests {
verifyNoMoreInteractions(this.anyNameForService);
}
@Test
void overrideIsFoundByTypeAndDisambiguatedByQualifier(ApplicationContext ctx) {
assertThat(this.ambiguous)
.satisfies(o -> assertThat(Mockito.mockingDetails(o).isSpy())
.as("isSpy").isTrue())
.isSameAs(ctx.getBean("ambiguous2"));
assertThatException().isThrownBy(() -> ctx.getBean(StringBuilder.class))
.withMessageEndingWith("but found 2: ambiguous1,ambiguous2");
assertThat(this.ambiguous.toString()).isEqualTo("bean3");
assertThat(this.ambiguous.length()).isEqualTo(5);
verify(this.ambiguous, times(1)).length();
verifyNoMoreInteractions(this.ambiguous); //mockito doesn't verify toString
}
@Test
void overrideIsFoundByTypeAndDisambiguatedByMetaQualifier(ApplicationContext ctx) {
assertThat(this.ambiguousMeta)
.satisfies(o -> assertThat(Mockito.mockingDetails(o).isSpy())
.as("isSpy").isTrue())
.isSameAs(ctx.getBean("ambiguous1"));
assertThatException().isThrownBy(() -> ctx.getBean(StringBuilder.class))
.withMessageEndingWith("but found 2: ambiguous1,ambiguous2");
assertThat(this.ambiguousMeta.toString()).isEqualTo("bean2");
assertThat(this.ambiguousMeta.length()).isEqualTo(5);
verify(this.ambiguousMeta, times(1)).length();
verifyNoMoreInteractions(this.ambiguousMeta); //mockito doesn't verify toString
}
@Test
void zeroCandidates() {
Class<?> caseClass = CaseNone.class;
@@ -203,6 +289,20 @@ public class MockitoByTypeIntegrationTests {
ExampleService bean1() {
return new RealExampleService("Production hello");
}
@Bean("ambiguous1")
@Order(1)
@CustomQualifier
StringBuilder bean2() {
return new StringBuilder("bean2");
}
@Bean("ambiguous2")
@Order(2)
@Qualifier("prefer")
StringBuilder bean3() {
return new StringBuilder("bean3");
}
}
@Configuration