From d5c7a5e2dbb3ca9864792745e7b98da9d07cc2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Fri, 17 May 2024 16:57:57 +0200 Subject: [PATCH] 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 --- .../annotation-mockitobean.adoc | 13 +-- .../annotation-testbean.adoc | 8 +- .../bean-overriding.adoc | 8 +- .../BeanOverrideBeanFactoryPostProcessor.java | 63 +++++++---- .../bean/override/convention/TestBean.java | 5 +- .../bean/override/mockito/MockitoBean.java | 3 +- .../bean/override/mockito/MockitoSpyBean.java | 3 +- ...OverrideBeanFactoryPostProcessorTests.java | 38 ++++++- .../TestBeanByTypeIntegrationTests.java | 50 +++++++++ .../convention/TestBeanIntegrationTests.java | 39 ++++++- .../override/example/CustomQualifier.java | 32 ++++++ .../MockitoByTypeIntegrationTests.java | 100 ++++++++++++++++++ 12 files changed, 319 insertions(+), 43 deletions(-) create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc index 8e4d42deee4..d6de89bd344 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc @@ -6,17 +6,18 @@ the test's `ApplicationContext` with a Mockito mock or spy, respectively. In the case, the original bean definition is not replaced, but instead an early instance of the bean is captured and wrapped by the spy. -Users are encouraged to make bean overriding as explicit and unambiguous as possible, -typically by specifying a bean `name` in the annotation. -If no bean `name` is specified, the annotated field's type is used to search for candidate -definitions to override. +By default, the annotated field's type is used to search for candidate definitions to +override, but note that `@Qualifier` annotations are also taken into account for the +purpose of matching. Users can also make things entirely explicit by specifying a bean +`name` in the annotation. + Each annotation also defines Mockito-specific attributes to fine-tune the mocking details. The `@MockitoBean` annotation uses the `REPLACE_OR_CREATE_DEFINITION` xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-custom[strategy for test bean overriding]. -It requires that at most one candidate definition exists if a bean name is specified, -or exactly one if no bean name is specified. +It requires that at most one matching candidate definition exists if a bean name +is specified, or exactly one if no bean name is specified. The `@MockitoSpyBean` annotation uses the `WRAP_BEAN` xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-custom[strategy], diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc index 18bc13d4ffa..3da3da2a4fc 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc @@ -11,10 +11,10 @@ but the annotation allows for a specific method name to be provided. The `@TestBean` annotation uses the `REPLACE_DEFINITION` xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-custom[strategy for test bean overriding]. -Users are encouraged to make bean overriding as explicit and unambiguous as possible, -typically by specifying a bean `name` in the annotation. -If no bean `name` is specified, the annotated field's type is used to search for candidate -definitions to override. In that case it is required that exactly one definition matches. +By default, the annotated field's type is used to search for candidate definitions to override. +In that case it is required that exactly one definition matches, but note that `@Qualifier` +annotations are also taken into account for the purpose of matching. +Users can also make things entirely explicit by specifying a bean `name` in the annotation. The following example shows how to fully configure the `@TestBean` annotation, with explicit values equivalent to the defaults: diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc index 974753dc97d..8b65ababcf8 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/bean-overriding.adoc @@ -61,11 +61,11 @@ In contrast to Spring's autowiring mechanism (for example, resolution of an `@Au field), the bean overriding infrastructure in the TestContext framework has limited heuristics it can perform to locate a bean. Either the `BeanOverrideProcessor` can compute the name of the bean to override, or it can be unambiguously selected given the type of -the annotated field. +the annotated field and its qualifying annotations. + +Typically, the bean is selected by type by the `BeanOverrideFactoryPostProcessor`. +Alternatively, the user can directly provide the bean name in the custom annotation. -Typically, the user directly provides the bean name in the custom annotation in order to -make things as explicit as possible. Alternatively, the bean is selected by type by the -`BeanOverrideFactoryPostProcessor`. Some `BeanOverrideProcessor`s could also internally compute a bean name based on a convention or another advanced method. ==== diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java index 78164079511..2ca613782c5 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessor.java @@ -31,6 +31,7 @@ import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.DependencyDescriptor; import org.springframework.beans.factory.config.SmartInstantiationAwareBeanPostProcessor; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; @@ -127,28 +128,34 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, RootBeanDefinition beanDefinition = createBeanDefinition(overrideMetadata); String beanName = overrideMetadata.getBeanName(); + BeanDefinition existingBeanDefinition = null; if (beanName == null) { - final String[] candidates = beanFactory.getBeanNamesForType(overrideMetadata.getBeanType()); - if (candidates.length != 1) { + Set candidates = getExistingBeanNamesByType(beanFactory, overrideMetadata, true); + if (candidates.size() != 1) { Field f = overrideMetadata.getField(); throw new IllegalStateException("Unable to select a bean definition to override, " + - candidates.length+ " bean definitions found of type " + overrideMetadata.getBeanType() + + candidates.size() + " bean definitions found of type " + overrideMetadata.getBeanType() + " (as required by annotated field '" + f.getDeclaringClass().getSimpleName() + "." + f.getName() + "')"); } - beanName = candidates[0]; + beanName = candidates.iterator().next(); + existingBeanDefinition = beanFactory.getBeanDefinition(beanName); + } + else { + Set candidates = getExistingBeanNamesByType(beanFactory, overrideMetadata, false); + if (candidates.contains(beanName)) { + existingBeanDefinition = beanFactory.getBeanDefinition(beanName); + } + else if (enforceExistingDefinition) { + throw new IllegalStateException("Unable to override bean '" + beanName + "'; there is no" + + " bean definition to replace with that name of type " + overrideMetadata.getBeanType()); + } } - BeanDefinition existingBeanDefinition = null; - if (beanFactory.containsBeanDefinition(beanName)) { - existingBeanDefinition = beanFactory.getBeanDefinition(beanName); + if (existingBeanDefinition != null) { copyBeanDefinitionDetails(existingBeanDefinition, beanDefinition); registry.removeBeanDefinition(beanName); } - else if (enforceExistingDefinition) { - throw new IllegalStateException("Unable to override bean '" + beanName + "'; there is no" + - " bean definition to replace with that name"); - } registry.registerBeanDefinition(beanName, beanDefinition); Object override = overrideMetadata.createOverride(beanName, existingBeanDefinition, null); @@ -171,33 +178,40 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, * phase. */ private void registerWrapBean(ConfigurableListableBeanFactory beanFactory, OverrideMetadata metadata) { - Set existingBeanNames = getExistingBeanNames(beanFactory, metadata.getBeanType()); String beanName = metadata.getBeanName(); if (beanName == null) { - if (existingBeanNames.size() != 1) { + Set candidateNames = getExistingBeanNamesByType(beanFactory, metadata, true); + if (candidateNames.size() != 1) { Field f = metadata.getField(); throw new IllegalStateException("Unable to select a bean to override by wrapping, " + - existingBeanNames.size() + " bean instances found of type " + metadata.getBeanType() + + candidateNames.size() + " bean instances found of type " + metadata.getBeanType() + " (as required by annotated field '" + f.getDeclaringClass().getSimpleName() + "." + f.getName() + "')"); } - beanName = existingBeanNames.iterator().next(); + beanName = candidateNames.iterator().next(); } - else if (!existingBeanNames.contains(beanName)) { - throw new IllegalStateException("Unable to override bean '" + beanName + "' by wrapping; " + - "there is no existing bean instance with that name of type " + metadata.getBeanType()); + else { + Set candidates = getExistingBeanNamesByType(beanFactory, metadata, false); + if (!candidates.contains(beanName)) { + throw new IllegalStateException("Unable to override bean '" + beanName + "' by wrapping; there is no" + + " existing bean instance with that name of type " + metadata.getBeanType()); + } } this.overrideRegistrar.markWrapEarly(metadata, beanName); this.overrideRegistrar.registerNameForMetadata(metadata, beanName); } - private RootBeanDefinition createBeanDefinition(OverrideMetadata metadata) { + RootBeanDefinition createBeanDefinition(OverrideMetadata metadata) { RootBeanDefinition definition = new RootBeanDefinition(); definition.setTargetType(metadata.getBeanType()); + definition.setQualifiedElement(metadata.getField()); return definition; } - private Set getExistingBeanNames(ConfigurableListableBeanFactory beanFactory, ResolvableType resolvableType) { + private Set getExistingBeanNamesByType(ConfigurableListableBeanFactory beanFactory, OverrideMetadata metadata, + boolean checkAutowiredCandidate) { + + ResolvableType resolvableType = metadata.getBeanType(); Set beans = new LinkedHashSet<>( Arrays.asList(beanFactory.getBeanNamesForType(resolvableType, true, false))); Class type = resolvableType.resolve(Object.class); @@ -209,7 +223,14 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, beans.add(beanName); } } - beans.removeIf(ScopedProxyUtils::isScopedTarget); + if (checkAutowiredCandidate) { + DependencyDescriptor descriptor = new DependencyDescriptor(metadata.getField(), true); + beans.removeIf(beanName -> ScopedProxyUtils.isScopedTarget(beanName) || + !beanFactory.isAutowireCandidate(beanName, descriptor)); + } + else { + beans.removeIf(ScopedProxyUtils::isScopedTarget); + } return beans; } diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java index 45967dff66a..d1d988ac7a5 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java @@ -30,8 +30,9 @@ import org.springframework.test.context.bean.override.BeanOverride; * *

By default, the bean to override is inferred from the type of the * annotated field. This requires that exactly one matching definition is - * present in the application context. To explicitly specify a bean name to - * replace, set the {@link #value()} or {@link #name()} attribute. + * present in the application context. A {@code @Qualifier} annotation can be + * used to help disambiguate. Alternatively, you can explicitly specify a bean + * name to replace by setting the {@link #value()} or {@link #name()} attribute. * *

The instance is created from a zero-argument static factory method in the * test class whose return type is compatible with the annotated field. In the diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java index 2bfc2c94456..d865e559a6c 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBean.java @@ -32,7 +32,8 @@ import org.springframework.test.context.bean.override.BeanOverride; * *

If no explicit {@link #name()} is specified, a target bean definition is * selected according to the class of the annotated field, and there must be - * exactly one such candidate definition in the context. + * exactly one such candidate definition in the context. A {@code @Qualifier} + * annotation can be used to help disambiguate. * If a {@link #name()} is specified, either the definition exists in the * application context and is replaced, or it doesn't and a new one is added to * the context. diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java index 9ca3a045e7c..1192cec8668 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java @@ -32,7 +32,8 @@ import org.springframework.test.context.bean.override.BeanOverride; * *

If no explicit {@link #name()} is specified, a target bean is selected * according to the class of the annotated field, and there must be exactly one - * such candidate bean. + * such candidate bean. A {@code @Qualifier} annotation can be used to help + * disambiguate. * If a {@link #name()} is specified, it is required that a target bean of that * name has been previously registered in the application context. * diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java index 8b8cefaf513..91bfd9a5668 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java @@ -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 { @Override diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeIntegrationTests.java index ec0ca0c379a..7f291d69760 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeIntegrationTests.java @@ -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) diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanIntegrationTests.java index 7d2782ebd58..a69ab86ee96 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanIntegrationTests.java @@ -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"; + } + } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java new file mode 100644 index 00000000000..390dc2bf2ba --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/example/CustomQualifier.java @@ -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 { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoByTypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoByTypeIntegrationTests.java index 47b06675048..b4491b8b813 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoByTypeIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoByTypeIntegrationTests.java @@ -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