From f9523a785b456e0167133489233b3ff8b786a67e Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:29:02 +0100 Subject: [PATCH] =?UTF-8?q?Support=20@=E2=81=A0MockitoBean=20and=20@?= =?UTF-8?q?=E2=81=A0MockitoSpyBean=20on=20test=20constructor=20parameters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, @⁠MockitoBean and @⁠MockitoSpyBean could be declared on fields or at the type level (on test classes and test interfaces), but not on constructor parameters. Consequently, a test class could not use constructor injection for bean overrides. To address that, this commit introduces support for @⁠MockitoBean and @⁠MockitoSpyBean on constructor parameters in JUnit Jupiter test classes. Specifically, the Bean Override infrastructure has been overhauled to support constructor parameters as declaration sites and injection points alongside fields, and the SpringExtension now recognizes composed @⁠BeanOverride annotations on constructor parameters in supportsParameter() and resolves them properly in resolveParameter(). Note, however, that this support has not been introduced for @⁠TestBean. For example, the following which uses field injection: @⁠SpringJUnitConfig(TestConfig.class) class BeanOverrideTests { @⁠MockitoBean CustomService customService; // tests... } Can now be rewritten to use constructor injection: @⁠SpringJUnitConfig(TestConfig.class) class BeanOverrideTests { private final CustomService customService; BeanOverrideTests(@⁠MockitoBean CustomService customService) { this.customService = customService; } // tests... } With Kotlin this can be achieved even more succinctly via a compact constructor declaration: @⁠SpringJUnitConfig(TestConfig::class) class BeanOverrideTests(@⁠MockitoBean val customService: CustomService) { // tests... } Of course, if one is a fan of so-called "test records", that can also be achieved succinctly with a Java record: @⁠SpringJUnitConfig(TestConfig.class) record BeanOverrideTests(@⁠MockitoBean CustomService customService) { // tests... } Closes gh-36096 --- .../annotation-mockitobean.adoc | 164 +++++++++++++- .../bean-overriding.adoc | 11 +- .../support-classes.adoc | 4 + .../context/bean/override/BeanOverride.java | 6 +- .../BeanOverrideBeanFactoryPostProcessor.java | 82 ++++--- .../bean/override/BeanOverrideHandler.java | 104 +++++++-- .../bean/override/BeanOverrideProcessor.java | 31 +++ .../bean/override/BeanOverrideUtils.java | 123 +++++++++- .../AbstractMockitoBeanOverrideHandler.java | 10 + .../bean/override/mockito/MockitoBean.java | 26 ++- .../mockito/MockitoBeanOverrideHandler.java | 20 +- .../mockito/MockitoBeanOverrideProcessor.java | 21 ++ .../bean/override/mockito/MockitoSpyBean.java | 30 +-- .../MockitoSpyBeanOverrideHandler.java | 9 +- .../junit/jupiter/SpringExtension.java | 26 ++- .../override/example/CustomQualifier.java | 2 +- .../MockitoBeanConfigurationErrorTests.java | 70 ++++++ .../MockitoBeanOverrideHandlerTests.java | 2 +- .../MockitoBeanOverrideProcessorTests.java | 76 +++++++ ...MockitoSpyBeanConfigurationErrorTests.java | 55 +++++ ...ConstructorParametersIntegrationTests.java | 175 ++++++++++++++ ...uctorParametersIntegrationRecordTests.java | 55 +++++ ...ConstructorParametersIntegrationTests.java | 207 +++++++++++++++++ ...ConstructorParametersIntegrationTests.java | 165 ++++++++++++++ ...ConstructorParametersIntegrationTests.java | 213 ++++++++++++++++++ .../typelevel/ConstructorService01.java | 20 ++ .../MockitoBeansByNameIntegrationTests.java | 29 +++ .../MockitoBeansByTypeIntegrationTests.java | 12 + .../mockito/typelevel/MockitoBeansTests.java | 6 +- ...rametersIntegrationKotlinDataClassTests.kt | 44 ++++ ...tructorParametersIntegrationKotlinTests.kt | 44 ++++ 31 files changed, 1730 insertions(+), 112 deletions(-) create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByNameLookupForConstructorParametersIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationRecordTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByNameLookupForConstructorParametersIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByTypeLookupForConstructorParametersIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/ConstructorService01.java create mode 100644 spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinDataClassTests.kt create mode 100644 spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinTests.kt 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 b0ad7707ff3..591d3a50c35 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 @@ -12,18 +12,20 @@ The annotations can be applied in the following ways. * On a non-static field in a test class or any of its superclasses. * On a non-static field in an enclosing class for a `@Nested` test class or in any class in the type hierarchy or enclosing class hierarchy above the `@Nested` test class. +* On a parameter in the constructor for a test class. * At the type level on a test class or any superclass or implemented interface in the type hierarchy above the test class. * At the type level on an enclosing class for a `@Nested` test class or on any class or interface in the type hierarchy or enclosing class hierarchy above the `@Nested` test class. -When `@MockitoBean` or `@MockitoSpyBean` is declared on a field, the bean to mock or spy -is inferred from the type of the annotated field. If multiple candidates exist in the -`ApplicationContext`, a `@Qualifier` annotation can be declared on the field to help -disambiguate. In the absence of a `@Qualifier` annotation, the name of the annotated -field will be used as a _fallback qualifier_. Alternatively, you can explicitly specify a -bean name to mock or spy by setting the `value` or `name` attribute in the annotation. +When `@MockitoBean` or `@MockitoSpyBean` is declared on a field or constructor parameter, +the bean to mock or spy is inferred from the type of the annotated field or parameter. If +multiple candidates exist in the `ApplicationContext`, a `@Qualifier` annotation can be +declared on the field or parameter to help disambiguate. In the absence of a `@Qualifier` +annotation, the name of the annotated field or parameter will be used as a _fallback +qualifier_. Alternatively, you can explicitly specify a bean name to mock or spy by +setting the `value` or `name` attribute in the annotation. When `@MockitoBean` or `@MockitoSpyBean` is declared at the type level, the type of bean (or beans) to mock or spy must be supplied via the `types` attribute in the annotation – @@ -201,6 +203,82 @@ Kotlin:: <1> Replace the bean named `service` with a Mockito mock. ====== +The following example shows how to use `@MockitoBean` on a constructor parameter for a +by-type lookup. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + private final CustomService customService; + + BeanOverrideTests(@MockitoBean CustomService customService) { // <1> + this.customService = customService; + } + + // tests... + } +---- +<1> Replace the bean with type `CustomService` with a Mockito mock and inject it into + the constructor. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig::class) + class BeanOverrideTests(@MockitoBean val customService: CustomService) { // <1> + + // tests... + } +---- +<1> Replace the bean with type `CustomService` with a Mockito mock and inject it into + the constructor. +====== + +The following example shows how to use `@MockitoBean` on a constructor parameter for a +by-name lookup. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + private final CustomService customService; + + BeanOverrideTests(@MockitoBean("service") CustomService customService) { // <1> + this.customService = customService; + } + + // tests... + } +---- +<1> Replace the bean named `service` with a Mockito mock and inject it into the + constructor. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig::class) + class BeanOverrideTests(@MockitoBean("service") val customService: CustomService) { // <1> + + // tests... + } +---- +<1> Replace the bean named `service` with a Mockito mock and inject it into the + constructor. +====== + The following `@SharedMocks` annotation registers two mocks by-type and one mock by-name. [tabs] @@ -375,6 +453,80 @@ Kotlin:: <1> Wrap the bean named `service` with a Mockito spy. ====== +The following example shows how to use `@MockitoSpyBean` on a constructor parameter for +a by-type lookup. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + private final CustomService customService; + + BeanOverrideTests(@MockitoSpyBean CustomService customService) { // <1> + this.customService = customService; + } + + // tests... + } +---- +<1> Wrap the bean with type `CustomService` with a Mockito spy and inject it into the + constructor. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig::class) + class BeanOverrideTests(@MockitoSpyBean val customService: CustomService) { // <1> + + // tests... + } +---- +<1> Wrap the bean with type `CustomService` with a Mockito spy and inject it into the + constructor. +====== + +The following example shows how to use `@MockitoSpyBean` on a constructor parameter for +a by-name lookup. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig.class) + class BeanOverrideTests { + + private final CustomService customService; + + BeanOverrideTests(@MockitoSpyBean("service") CustomService customService) { // <1> + this.customService = customService; + } + + // tests... + } +---- +<1> Wrap the bean named `service` with a Mockito spy and inject it into the constructor. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(TestConfig::class) + class BeanOverrideTests(@MockitoSpyBean("service") val customService: CustomService) { // <1> + + // tests... + } +---- +<1> Wrap the bean named `service` with a Mockito spy and inject it into the constructor. +====== + The following `@SharedSpies` annotation registers two spies by-type and one spy by-name. [tabs] 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 055b718feaa..7fbb0ceeb0c 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 @@ -2,8 +2,9 @@ = Bean Overriding in Tests Bean overriding in tests refers to the ability to override specific beans in the -`ApplicationContext` for a test class, by annotating the test class or one or more -non-static fields in the test class. +`ApplicationContext` for a test class, by annotating the test class, one or more +non-static fields in the test class, or one or more parameters in the constructor for the +test class. NOTE: This feature is intended as a less risky alternative to the practice of registering a bean via `@Bean` with the `DefaultListableBeanFactory` @@ -42,9 +43,9 @@ The `spring-test` module registers implementations of the latter two {spring-framework-code}/spring-test/src/main/resources/META-INF/spring.factories[`META-INF/spring.factories` properties file]. -The bean overriding infrastructure searches for annotations on test classes as well as -annotations on non-static fields in test classes that are meta-annotated with -`@BeanOverride` and instantiates the corresponding `BeanOverrideProcessor` which is +The bean overriding infrastructure searches for annotations on test classes, non-static +fields in test classes, and parameters in test class constructors that are meta-annotated +with `@BeanOverride`, and instantiates the corresponding `BeanOverrideProcessor` which is responsible for creating an appropriate `BeanOverrideHandler`. The internal `BeanOverrideBeanFactoryPostProcessor` then uses bean override handlers to diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc index 5f77d9e08b4..a98f0f72f9c 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/support-classes.adoc @@ -179,6 +179,10 @@ If a specific parameter in a constructor for a JUnit Jupiter test class is of ty `ApplicationContext` (or a sub-type thereof) or is annotated or meta-annotated with `@Autowired`, `@Qualifier`, or `@Value`, Spring injects the value for that specific parameter with the corresponding bean or value from the test's `ApplicationContext`. +Similarly, if a specific parameter is annotated with `@MockitoBean` or `@MockitoSpyBean`, +Spring will inject a Mockito mock or spy, respectively — see +xref:testing/annotations/integration-spring/annotation-mockitobean.adoc[`@MockitoBean` and `@MockitoSpyBean`] +for details. Spring can also be configured to autowire all arguments for a test class constructor if the constructor is considered to be _autowirable_. A constructor is considered to be diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java index f34c46028e0..2bc94bdbecc 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverride.java @@ -34,8 +34,10 @@ import org.springframework.aot.hint.annotation.Reflective; * fields, it is expected that the composed annotation is meta-annotated with * {@link Target @Target(ElementType.FIELD)}. However, certain bean override * annotations may be declared with an additional {@code ElementType.TYPE} target - * for use at the type level, as is the case for {@code @MockitoBean} which can - * be declared on a field, test class, or test interface. + * for use at the type level. Similarly, as of Spring Framework 7.1, certain bean + * override annotations may be declared with an additional {@code ElementType.PARAMETER} + * target for use on constructor parameters. For example, {@code @MockitoBean} can + * be declared on a field, constructor parameter, test class, or test interface. * *

For concrete examples of such composed annotations, see * {@link org.springframework.test.context.bean.override.convention.TestBean @TestBean}, 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 2a609b9ac6b..0a74fde6da3 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 @@ -16,7 +16,7 @@ package org.springframework.test.context.bean.override; -import java.lang.reflect.Field; +import java.lang.reflect.AnnotatedElement; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashSet; @@ -54,7 +54,9 @@ import org.springframework.util.Assert; * {@linkplain BeanOverrideStrategy override strategy}. The bean override instance * is created, if necessary, and the related infrastructure is updated to allow * the bean override instance to be injected into the corresponding - * {@linkplain BeanOverrideHandler#getField() field} of the test class. + * {@linkplain BeanOverrideHandler#getField() field} of the test class or + * {@linkplain BeanOverrideHandler#getParameter() parameter} of the test class + * constructor. * *

This processor does not work against a particular test class but rather * only prepares the bean factory for the identified, unique set of bean overrides. @@ -113,7 +115,7 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, Assert.state(!BeanFactoryUtils.isFactoryDereference(beanName), () -> """ Unable to override bean '%s'%s: a FactoryBean cannot be overridden. \ To override the bean created by the FactoryBean, remove the '&' prefix.""" - .formatted(beanName, forField(handler.getField()))); + .formatted(beanName, forDescription(handler))); switch (handler.getStrategy()) { case REPLACE -> replaceOrCreateBean(beanFactory, handler, generatedBeanNames, true); @@ -175,12 +177,11 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, setQualifiedElement(existingBeanDefinition, handler); } else if (requireExistingBean) { - Field field = handler.getField(); throw new IllegalStateException(""" Unable to replace bean: there is no bean with name '%s' and type %s%s. \ If the bean is defined in a @Bean method, make sure the return type is the \ most specific type possible (for example, the concrete implementation type).""" - .formatted(beanName, handler.getBeanType(), requiredByField(field))); + .formatted(beanName, handler.getBeanType(), requiredByDescription(handler))); } // 4) We are creating a bean by-name with the provided beanName. } @@ -253,13 +254,12 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, */ private void wrapBean(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler) { String beanName = handler.getBeanName(); - Field field = handler.getField(); ResolvableType beanType = handler.getBeanType(); if (beanName == null) { // We are wrapping an existing bean by-type. Set candidateNames = getExistingBeanNamesByType(beanFactory, handler, true); - String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, field); + String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, handler); if (uniqueCandidate != null) { beanName = uniqueCandidate; } @@ -271,11 +271,11 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, there are no beans of type %s%s. \ If the bean is defined in a @Bean method, make sure the return type is the \ most specific type possible (for example, the concrete implementation type).""" - .formatted(beanType, requiredByField(field)); + .formatted(beanType, requiredByDescription(handler)); } else { message += "found %d beans of type %s%s: %s" - .formatted(candidateCount, beanType, requiredByField(field), candidateNames); + .formatted(candidateCount, beanType, requiredByDescription(handler), candidateNames); } throw new IllegalStateException(message); } @@ -289,7 +289,7 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, Unable to wrap bean: there is no bean with name '%s' and type %s%s. \ If the bean is defined in a @Bean method, make sure the return type is the \ most specific type possible (for example, the concrete implementation type).""" - .formatted(beanName, beanType, requiredByField(field))); + .formatted(beanName, beanType, requiredByDescription(handler))); } } @@ -301,11 +301,10 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, private static @Nullable String getBeanNameForType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler, boolean requireExistingBean) { - Field field = handler.getField(); ResolvableType beanType = handler.getBeanType(); Set candidateNames = getExistingBeanNamesByType(beanFactory, handler, true); - String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, field); + String uniqueCandidate = determineUniqueCandidate(beanFactory, candidateNames, beanType, handler); if (uniqueCandidate != null) { return uniqueCandidate; } @@ -317,20 +316,19 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, Unable to override bean: there are no beans of type %s%s. \ If the bean is defined in a @Bean method, make sure the return type is the \ most specific type possible (for example, the concrete implementation type).""" - .formatted(beanType, requiredByField(field))); + .formatted(beanType, requiredByDescription(handler))); } return null; } throw new IllegalStateException( "Unable to select a bean to override: found %d beans of type %s%s: %s" - .formatted(candidateCount, beanType, requiredByField(field), candidateNames)); + .formatted(candidateCount, beanType, requiredByDescription(handler), candidateNames)); } private static Set getExistingBeanNamesByType(ConfigurableListableBeanFactory beanFactory, BeanOverrideHandler handler, boolean checkAutowiredCandidate) { - Field field = handler.getField(); ResolvableType resolvableType = handler.getBeanType(); Class type = resolvableType.toClass(); @@ -348,9 +346,11 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, } // Filter out non-matching autowire candidates. - if (field != null && checkAutowiredCandidate) { - DependencyDescriptor descriptor = new DependencyDescriptor(field, true); - beanNames.removeIf(beanName -> !beanFactory.isAutowireCandidate(beanName, descriptor)); + if (checkAutowiredCandidate) { + DependencyDescriptor descriptor = handler.fieldOrParameterDependencyDescriptor(); + if (descriptor != null) { + beanNames.removeIf(beanName -> !beanFactory.isAutowireCandidate(beanName, descriptor)); + } } // Filter out scoped proxy targets. beanNames.removeIf(ScopedProxyUtils::isScopedTarget); @@ -361,13 +361,14 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, /** * Determine the unique candidate in the given set of bean names. *

Honors both primary and fallback semantics, and - * otherwise matches against the field name as a fallback qualifier. + * otherwise matches against the field name or parameter name as a fallback + * qualifier. * @return the name of the unique candidate, or {@code null} if none found * @since 6.2.3 * @see org.springframework.beans.factory.support.DefaultListableBeanFactory#determineAutowireCandidate */ private static @Nullable String determineUniqueCandidate(ConfigurableListableBeanFactory beanFactory, - Set candidateNames, ResolvableType beanType, @Nullable Field field) { + Set candidateNames, ResolvableType beanType, BeanOverrideHandler handler) { // Step 0: none or only one int candidateCount = candidateNames.size(); @@ -384,12 +385,10 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, return primaryCandidate; } - // Step 2: use the field name as a fallback qualifier - if (field != null) { - String fieldName = field.getName(); - if (candidateNames.contains(fieldName)) { - return fieldName; - } + // Step 2: use the field name or parameter name as a fallback qualifier + String fieldOrPropertyName = handler.fieldOrParameterName(); + if (fieldOrPropertyName != null && candidateNames.contains(fieldOrPropertyName)) { + return fieldOrPropertyName; } return null; @@ -445,8 +444,9 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, * whose {@linkplain RootBeanDefinition#getTargetType() target type} and * {@linkplain RootBeanDefinition#getQualifiedElement() qualified element} are * the {@linkplain BeanOverrideHandler#getBeanType() bean type} and - * the {@linkplain BeanOverrideHandler#getField() field} of the {@code BeanOverrideHandler}, - * respectively. + * the {@linkplain BeanOverrideHandler#getField() field} or + * {@linkplain BeanOverrideHandler#getParameter() parameter} of the + * {@code BeanOverrideHandler}, respectively. *

The returned bean definition should not be used to create * a bean instance but rather only for the purpose of having suitable bean * definition metadata available in the {@code BeanFactory} — for example, @@ -462,15 +462,16 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, /** * Set the {@linkplain RootBeanDefinition#setQualifiedElement(java.lang.reflect.AnnotatedElement) * qualified element} in the supplied {@link BeanDefinition} to the - * {@linkplain BeanOverrideHandler#getField() field} of the supplied + * {@linkplain BeanOverrideHandler#getField() field} or + * {@linkplain BeanOverrideHandler#getParameter() parameter} of the supplied * {@code BeanOverrideHandler}. *

This is necessary for proper autowiring candidate resolution. * @since 6.2.6 */ private static void setQualifiedElement(BeanDefinition beanDefinition, BeanOverrideHandler handler) { - Field field = handler.getField(); - if (field != null && beanDefinition instanceof RootBeanDefinition rbd) { - rbd.setQualifiedElement(field); + AnnotatedElement fieldOrParameter = handler.fieldOrParameter(); + if (fieldOrParameter != null && beanDefinition instanceof RootBeanDefinition rbd) { + rbd.setQualifiedElement(fieldOrParameter); } } @@ -500,19 +501,14 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, dlbf.destroySingleton(beanName); } - private static String forField(@Nullable Field field) { - if (field == null) { - return ""; - } - return " for field '%s.%s'".formatted(field.getDeclaringClass().getSimpleName(), field.getName()); + private static String forDescription(BeanOverrideHandler handler) { + String description = handler.fieldOrParameterDescription(); + return (description != null ? " for " + description : ""); } - private static String requiredByField(@Nullable Field field) { - if (field == null) { - return ""; - } - return " (as required by field '%s.%s')".formatted( - field.getDeclaringClass().getSimpleName(), field.getName()); + private static String requiredByDescription(BeanOverrideHandler handler) { + String description = handler.fieldOrParameterDescription(); + return (description != null ? " (as required by %s)".formatted(description) : ""); } } diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java index 13b3fc5a9c2..87f37de67d9 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideHandler.java @@ -17,7 +17,9 @@ package org.springframework.test.context.bean.override; import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -27,10 +29,13 @@ import java.util.Set; import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.DependencyDescriptor; import org.springframework.beans.factory.config.SingletonBeanRegistry; +import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.style.ToStringCreator; +import org.springframework.util.Assert; /** * Handler for Bean Override injection points that is responsible for creating @@ -49,7 +54,7 @@ import org.springframework.core.style.ToStringCreator; *

Concrete implementations of {@code BeanOverrideHandler} can store additional * metadata to use during override {@linkplain #createOverrideInstance instance * creation} — for example, based on further processing of the annotation, - * the annotated field, or the annotated class. + * the annotated field, the annotated parameter, or the annotated class. * *

Singleton Semantics

* @@ -62,7 +67,7 @@ import org.springframework.core.style.ToStringCreator; *

When replacing a bean created by a * {@link org.springframework.beans.factory.FactoryBean FactoryBean}, the * {@code FactoryBean} itself will be replaced with a singleton bean corresponding - * to bean override instance created by the handler. + * to the bean override instance created by the handler. * *

When wrapping a bean created by a * {@link org.springframework.beans.factory.FactoryBean FactoryBean}, the object @@ -79,6 +84,8 @@ public abstract class BeanOverrideHandler { private final @Nullable Field field; + private final @Nullable Parameter parameter; + private final Set qualifierAnnotations; private final ResolvableType beanType; @@ -128,8 +135,36 @@ public abstract class BeanOverrideHandler { protected BeanOverrideHandler(@Nullable Field field, ResolvableType beanType, @Nullable String beanName, String contextName, BeanOverrideStrategy strategy) { + this(field, null, beanType, beanName, contextName, strategy); + } + + /** + * Construct a new {@code BeanOverrideHandler} from the supplied values. + * @param parameter the constructor {@link Parameter} annotated with + * {@link BeanOverride @BeanOverride} + * @param beanType the {@linkplain ResolvableType type} of bean to override + * @param beanName the name of the bean to override, or {@code null} to look + * for a single matching bean by type + * @param contextName the name of the context hierarchy level in which the + * handler should be applied, or an empty string to indicate that the handler + * should be applied to all application contexts within a context hierarchy + * @param strategy the {@link BeanOverrideStrategy} to use + * @since 7.1 + */ + protected BeanOverrideHandler(Parameter parameter, ResolvableType beanType, @Nullable String beanName, + String contextName, BeanOverrideStrategy strategy) { + + this(null, parameter, beanType, beanName, contextName, strategy); + } + + private BeanOverrideHandler(@Nullable Field field, @Nullable Parameter parameter, ResolvableType beanType, + @Nullable String beanName, String contextName, BeanOverrideStrategy strategy) { + + Assert.state(field == null || parameter == null, "The field and parameter cannot both be non-null"); + this.field = field; - this.qualifierAnnotations = getQualifierAnnotations(field); + this.parameter = parameter; + this.qualifierAnnotations = getQualifierAnnotations(field != null ? field : parameter); this.beanType = beanType; this.beanName = beanName; this.strategy = strategy; @@ -156,12 +191,58 @@ public abstract class BeanOverrideHandler { /** - * Get the {@link Field} annotated with {@link BeanOverride @BeanOverride}. + * Get the {@link Field} annotated with {@link BeanOverride @BeanOverride}, + * or {@code null} if this handler was not created for a field. */ public final @Nullable Field getField() { return this.field; } + /** + * Get the constructor {@link Parameter} annotated with {@link BeanOverride @BeanOverride}, + * or {@code null} if this handler was not created for a parameter. + * @since 7.1 + */ + public final @Nullable Parameter getParameter() { + return this.parameter; + } + + final @Nullable AnnotatedElement fieldOrParameter() { + return (this.field != null ? this.field : this.parameter); + } + + final @Nullable String fieldOrParameterName() { + if (this.field != null) { + return this.field.getName(); + } + if (this.parameter != null) { + return this.parameter.getName(); + } + return null; + } + + final @Nullable String fieldOrParameterDescription() { + if (this.field != null) { + return "field '%s.%s'".formatted(this.field.getDeclaringClass().getSimpleName(), + this.field.getName()); + } + if (this.parameter != null) { + return "parameter '%s' in constructor for %s".formatted(this.parameter.getName(), + this.parameter.getDeclaringExecutable().getName()); + } + return null; + } + + final @Nullable DependencyDescriptor fieldOrParameterDependencyDescriptor() { + if (this.field != null) { + return new DependencyDescriptor(this.field, true); + } + if (this.parameter != null) { + return new DependencyDescriptor(MethodParameter.forParameter(this.parameter), true); + } + return null; + } + /** * Get the bean {@linkplain ResolvableType type} to override. */ @@ -276,24 +357,21 @@ public abstract class BeanOverrideHandler { } // by-type lookup - if (this.field == null) { - return (that.field == null); - } - return (that.field != null && this.field.getName().equals(that.field.getName()) && + return (Objects.equals(fieldOrParameterName(), that.fieldOrParameterName()) && this.qualifierAnnotations.equals(that.qualifierAnnotations)); } @Override public int hashCode() { int hash = Objects.hash(getClass(), this.beanType.getType(), this.beanName, this.contextName, this.strategy); - return (this.beanName != null ? hash : hash + - Objects.hash((this.field != null ? this.field.getName() : null), this.qualifierAnnotations)); + return (this.beanName != null ? hash : hash + Objects.hash(fieldOrParameterName(), this.qualifierAnnotations)); } @Override public String toString() { return new ToStringCreator(this) .append("field", this.field) + .append("parameter", this.parameter) .append("beanType", this.beanType) .append("beanName", this.beanName) .append("contextName", this.contextName) @@ -302,11 +380,11 @@ public abstract class BeanOverrideHandler { } - private static Set getQualifierAnnotations(@Nullable Field field) { - if (field == null) { + private static Set getQualifierAnnotations(@Nullable AnnotatedElement element) { + if (element == null) { return Collections.emptySet(); } - Annotation[] candidates = field.getDeclaredAnnotations(); + Annotation[] candidates = element.getDeclaredAnnotations(); if (candidates.length == 0) { return Collections.emptySet(); } diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java index 9c19266ed77..e67738d96f4 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideProcessor.java @@ -18,9 +18,12 @@ package org.springframework.test.context.bean.override; import java.lang.annotation.Annotation; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import java.util.Collections; import java.util.List; +import org.jspecify.annotations.Nullable; + /** * Strategy interface for Bean Override processing, which creates * {@link BeanOverrideHandler} instances that drive how target beans are @@ -51,10 +54,37 @@ public interface BeanOverrideProcessor { * @param testClass the test class to process * @param field the annotated field * @return the {@code BeanOverrideHandler} that should handle the given field + * @see #createHandler(Annotation, Class, Parameter) * @see #createHandlers(Annotation, Class) */ BeanOverrideHandler createHandler(Annotation overrideAnnotation, Class testClass, Field field); + /** + * Create a {@link BeanOverrideHandler} for the given test class constructor + * parameter. + *

This method will only be invoked when a {@link BeanOverride @BeanOverride} + * annotation is declared on a constructor parameter — for example, if + * the supplied constructor parameter is annotated with {@code @MockitoBean}. + *

The default implementation returns {@code null}, signaling that this + * {@code BeanOverrideProcessor} does not support {@code @BeanOverride} + * declarations on constructor parameters. Can be overridden by concrete + * implementations to support constructor parameter use cases. + * @param overrideAnnotation the composed annotation that declares the + * {@code @BeanOverride} annotation which registers this processor + * @param testClass the test class to process + * @param parameter the annotated constructor parameter + * @return the {@code BeanOverrideHandler} that should handle the given constructor + * parameter + * @since 7.1 + * @see #createHandler(Annotation, Class, Field) + * @see #createHandlers(Annotation, Class) + */ + default @Nullable BeanOverrideHandler createHandler(Annotation overrideAnnotation, + Class testClass, Parameter parameter) { + + return null; + } + /** * Create a list of {@link BeanOverrideHandler} instances for the given override * annotation and test class. @@ -75,6 +105,7 @@ public interface BeanOverrideProcessor { * @return the list of {@code BeanOverrideHandlers} for the annotated class * @since 6.2.2 * @see #createHandler(Annotation, Class, Field) + * @see #createHandler(Annotation, Class, Parameter) */ default List createHandlers(Annotation overrideAnnotation, Class testClass) { return Collections.emptyList(); diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideUtils.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideUtils.java index 5def4c4ac47..b00e825db49 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/BeanOverrideUtils.java @@ -18,9 +18,12 @@ package org.springframework.test.context.bean.override; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -28,7 +31,15 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; +import kotlin.reflect.KFunction; +import kotlin.reflect.full.KClasses; +import kotlin.reflect.jvm.ReflectJvmMapping; +import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeanUtils; +import org.springframework.core.KotlinDetector; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.test.context.TestContextAnnotationUtils; @@ -47,6 +58,8 @@ import static org.springframework.core.annotation.MergedAnnotations.SearchStrate */ public abstract class BeanOverrideUtils { + private static final boolean KOTLIN_REFLECT_PRESENT = KotlinDetector.isKotlinReflectPresent(); + private static final Comparator> reversedMetaDistance = Comparator.> comparingInt(MergedAnnotation::getDistance).reversed(); @@ -55,8 +68,10 @@ public abstract class BeanOverrideUtils { * Process the given {@code testClass} and build the corresponding * {@link BeanOverrideHandler} list derived from {@link BeanOverride @BeanOverride} * fields in the test class and its type hierarchy. - *

This method does not search the enclosing class hierarchy and does not - * search for {@code @BeanOverride} declarations on classes or interfaces. + *

This method does not search the enclosing class hierarchy, does not + * search for {@code @BeanOverride} declarations on classes or interfaces, and + * does not search for {@code @BeanOverride} declarations on constructor + * parameters. * @param testClass the test class to process * @return a list of bean override handlers * @see #findAllHandlers(Class) @@ -69,7 +84,8 @@ public abstract class BeanOverrideUtils { * Process the given {@code testClass} and build the corresponding * {@link BeanOverrideHandler} list derived from {@link BeanOverride @BeanOverride} * fields in the test class and in its type hierarchy as well as from - * {@code @BeanOverride} declarations on classes and interfaces. + * {@code @BeanOverride} declarations on classes and interfaces and + * {@code @BeanOverride} declarations on constructor parameters. *

This method additionally searches for {@code @BeanOverride} declarations * in the enclosing class hierarchy based on * {@link TestContextAnnotationUtils#searchEnclosingClass(Class)} semantics. @@ -83,7 +99,7 @@ public abstract class BeanOverrideUtils { private static List findHandlers(Class testClass, boolean localFieldsOnly) { List handlers = new ArrayList<>(); - findHandlers(testClass, testClass, handlers, localFieldsOnly, new HashSet<>()); + findHandlers(testClass, testClass, handlers, localFieldsOnly, false, new HashSet<>()); return handlers; } @@ -97,11 +113,12 @@ public abstract class BeanOverrideUtils { * @param testClass the original test class * @param handlers the list of handlers found * @param localFieldsOnly whether to search only on local fields within the type hierarchy + * @param fromNestedClass whether the search originated from a nested test class * @param visitedTypes the set of types already visited * @since 6.2.2 */ private static void findHandlers(Class clazz, Class testClass, List handlers, - boolean localFieldsOnly, Set> visitedTypes) { + boolean localFieldsOnly, boolean fromNestedClass, Set> visitedTypes) { // 0) Ensure that we do not process the same class or interface multiple times. if (!visitedTypes.add(clazz)) { @@ -110,26 +127,39 @@ public abstract class BeanOverrideUtils { // 1) Search enclosing class hierarchy. if (!localFieldsOnly && TestContextAnnotationUtils.searchEnclosingClass(clazz)) { - findHandlers(clazz.getEnclosingClass(), testClass, handlers, localFieldsOnly, visitedTypes); + findHandlers(clazz.getEnclosingClass(), testClass, handlers, localFieldsOnly, true, visitedTypes); } // 2) Search class hierarchy. Class superclass = clazz.getSuperclass(); if (superclass != null && superclass != Object.class) { - findHandlers(superclass, testClass, handlers, localFieldsOnly, visitedTypes); + findHandlers(superclass, testClass, handlers, localFieldsOnly, false, visitedTypes); } if (!localFieldsOnly) { // 3) Search interfaces. for (Class ifc : clazz.getInterfaces()) { - findHandlers(ifc, testClass, handlers, localFieldsOnly, visitedTypes); + findHandlers(ifc, testClass, handlers, localFieldsOnly, false, visitedTypes); } // 4) Process current class. processClass(clazz, testClass, handlers); + + // 5) Process test class constructor parameters. + // Specifically, we only process the constructor for the current test class + // and enclosing test classes. In other words, we do not process constructors + // for superclasses. + if (testClass == clazz || fromNestedClass) { + Constructor constructor = findConstructorWithParameters(clazz); + if (constructor != null) { + for (Parameter parameter : constructor.getParameters()) { + processParameter(parameter, testClass, handlers); + } + } + } } - // 5) Process fields in current class. + // 6) Process fields in current class. ReflectionUtils.doWithLocalFields(clazz, field -> processField(field, testClass, handlers)); } @@ -138,7 +168,33 @@ public abstract class BeanOverrideUtils { processor.createHandlers(composedAnnotation, testClass).forEach(handlers::add)); } + private static void processParameter(Parameter parameter, Class testClass, List handlers) { + AtomicBoolean overrideAnnotationFound = new AtomicBoolean(); + processElement(parameter, (processor, composedAnnotation) -> { + Assert.state(overrideAnnotationFound.compareAndSet(false, true), + () -> "Multiple @BeanOverride annotations found on parameter: " + parameter); + BeanOverrideHandler handler = processor.createHandler(composedAnnotation, testClass, parameter); + Assert.state(handler != null, + () -> "BeanOverrideProcessor [%s] returned null BeanOverrideHandler for parameter [%s]" + .formatted(processor.getClass().getSimpleName(), parameter)); + handlers.add(handler); + }); + } + private static void processField(Field field, Class testClass, List handlers) { + Class declaringClass = field.getDeclaringClass(); + // For Java records, the Java compiler propagates @BeanOverride annotations from + // canonical constructor parameters to the corresponding component fields, resulting + // in duplicates. Similarly for Kotlin types, the Kotlin compiler propagates + // @BeanOverride annotations from primary constructor parameters to their corresponding + // backing fields, resulting in duplicates. Thus, if we detect either of those scenarios, + // we ignore the field. + if (declaringClass.isRecord() || (KOTLIN_REFLECT_PRESENT && + KotlinDetector.isKotlinType(declaringClass) && + KotlinDelegate.isFieldForBeanOverrideConstructorParameter(field))) { + return; + } + AtomicBoolean overrideAnnotationFound = new AtomicBoolean(); processElement(field, (processor, composedAnnotation) -> { Assert.state(!Modifier.isStatic(field.getModifiers()), @@ -164,4 +220,53 @@ public abstract class BeanOverrideUtils { }); } + /** + * Find a single constructor for the supplied test class that declares parameters. + *

If the test class declares multiple constructors, this method returns + * {@code null}. + * @param testClass the test class to process + * @return the candidate constructor, or {@code null} if no suitable candidate + * was found + * @since 7.1 + */ + private static @Nullable Constructor findConstructorWithParameters(Class testClass) { + List> constructors = Arrays.stream(testClass.getDeclaredConstructors()) + .filter(constructor -> !constructor.isSynthetic() && constructor.getParameterCount() > 0) + .toList(); + return (constructors.size() == 1 ? constructors.get(0) : null); + } + + + /** + * Inner class to avoid a hard dependency on Kotlin at runtime. + * @since 7.1 + */ + private static class KotlinDelegate { + + /** + * Determine if the supplied field corresponds to a primary constructor + * parameter in the field's declaring Kotlin class, where the primary + * constructor parameter is annotated with {@link BeanOverride @BeanOverride}. + */ + static boolean isFieldForBeanOverrideConstructorParameter(Field field) { + KClass kClass = JvmClassMappingKt.getKotlinClass(field.getDeclaringClass()); + KFunction primaryConstructor = KClasses.getPrimaryConstructor(kClass); + if (primaryConstructor == null) { + return false; + } + Constructor javaConstructor = ReflectJvmMapping.getJavaConstructor(primaryConstructor); + if (javaConstructor == null) { + return false; + } + String fieldName = field.getName(); + for (Parameter parameter : javaConstructor.getParameters()) { + if (parameter.getName().equals(fieldName) && + MergedAnnotations.from(parameter).isPresent(BeanOverride.class)) { + return true; + } + } + return false; + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java index b39800a1846..77558b4caad 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/AbstractMockitoBeanOverrideHandler.java @@ -17,6 +17,7 @@ package org.springframework.test.context.bean.override.mockito; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import org.jspecify.annotations.Nullable; @@ -47,6 +48,14 @@ abstract class AbstractMockitoBeanOverrideHandler extends BeanOverrideHandler { this.reset = (reset != null ? reset : MockReset.AFTER); } + protected AbstractMockitoBeanOverrideHandler(Parameter parameter, ResolvableType beanType, + @Nullable String beanName, String contextName, BeanOverrideStrategy strategy, + MockReset reset) { + + super(parameter, beanType, beanName, contextName, strategy); + this.reset = (reset != null ? reset : MockReset.AFTER); + } + /** * Return the mock reset mode. @@ -92,6 +101,7 @@ abstract class AbstractMockitoBeanOverrideHandler extends BeanOverrideHandler { public String toString() { return new ToStringCreator(this) .append("field", getField()) + .append("parameter", getParameter()) .append("beanType", getBeanType()) .append("beanName", getBeanName()) .append("contextName", getContextName()) 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 b7563a439ce..bbffe209cca 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 @@ -41,6 +41,7 @@ import org.springframework.test.context.bean.override.BeanOverride; *

  • On a non-static field in an enclosing class for a {@code @Nested} test class * or in any class in the type hierarchy or enclosing class hierarchy above the * {@code @Nested} test class.
  • + *
  • On a parameter in the constructor for the test class.
  • *
  • At the type level on a test class or any superclass or implemented interface * in the type hierarchy above the test class.
  • *
  • At the type level on an enclosing class for a {@code @Nested} test class @@ -48,13 +49,14 @@ import org.springframework.test.context.bean.override.BeanOverride; * above the {@code @Nested} test class.
  • * * - *

    When {@code @MockitoBean} is declared on a field, the bean to mock is inferred - * from the type of the annotated field. If multiple candidates exist in the - * {@code ApplicationContext}, a {@code @Qualifier} annotation can be declared - * on the field to help disambiguate. In the absence of a {@code @Qualifier} - * annotation, the name of the annotated field will be used as a fallback - * qualifier. Alternatively, you can explicitly specify a bean name to mock - * by setting the {@link #value() value} or {@link #name() name} attribute. + *

    When {@code @MockitoBean} is declared on a field or parameter, the bean to + * mock is inferred from the type of the annotated field or parameter. If multiple + * candidates exist in the {@code ApplicationContext}, a {@code @Qualifier} annotation + * can be declared on the field or parameter to help disambiguate. In the absence + * of a {@code @Qualifier} annotation, the name of the annotated field or parameter + * will be used as a fallback qualifier. Alternatively, you can explicitly + * specify a bean name to mock by setting the {@link #value() value} or + * {@link #name() name} attribute. * *

    When {@code @MockitoBean} is declared at the type level, the type of bean * (or beans) to mock must be supplied via the {@link #types() types} attribute. @@ -116,7 +118,7 @@ import org.springframework.test.context.bean.override.BeanOverride; * @see org.springframework.test.context.bean.override.mockito.MockitoSpyBean @MockitoSpyBean * @see org.springframework.test.context.bean.override.convention.TestBean @TestBean */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(MockitoBeans.class) @@ -135,9 +137,9 @@ public @interface MockitoBean { /** * Name of the bean to mock. *

    If left unspecified, the bean to mock is selected according to the - * configured {@link #types() types} or the annotated field's type, taking - * qualifiers into account if necessary. See the {@linkplain MockitoBean - * class-level documentation} for details. + * configured {@link #types() types} or the type of the annotated field or + * parameter, taking qualifiers into account if necessary. See the + * {@linkplain MockitoBean class-level documentation} for details. * @see #value() */ @AliasFor("value") @@ -148,7 +150,7 @@ public @interface MockitoBean { *

    Defaults to none. *

    Each type specified will result in a mock being created and registered * with the {@code ApplicationContext}. - *

    Types must be omitted when the annotation is used on a field. + *

    Types must be omitted when the annotation is used on a field or parameter. *

    When {@code @MockitoBean} also defines a {@link #name name}, this attribute * can only contain a single value. * @return the types to mock diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java index c0532f6f52b..79339d2d514 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandler.java @@ -17,6 +17,7 @@ package org.springframework.test.context.bean.override.mockito; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; @@ -58,7 +59,7 @@ class MockitoBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { MockitoBeanOverrideHandler(ResolvableType typeToMock, MockitoBean mockitoBean) { - this(null, typeToMock, mockitoBean); + this((Field) null, typeToMock, mockitoBean); } MockitoBeanOverrideHandler(@Nullable Field field, ResolvableType typeToMock, MockitoBean mockitoBean) { @@ -67,6 +68,12 @@ class MockitoBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { mockitoBean.reset(), mockitoBean.extraInterfaces(), mockitoBean.answers(), mockitoBean.serializable()); } + MockitoBeanOverrideHandler(Parameter parameter, ResolvableType typeToMock, MockitoBean mockitoBean) { + this(parameter, typeToMock, (!mockitoBean.name().isBlank() ? mockitoBean.name() : null), + mockitoBean.contextName(), (mockitoBean.enforceOverride() ? REPLACE : REPLACE_OR_CREATE), + mockitoBean.reset(), mockitoBean.extraInterfaces(), mockitoBean.answers(), mockitoBean.serializable()); + } + private MockitoBeanOverrideHandler(@Nullable Field field, ResolvableType typeToMock, @Nullable String beanName, String contextName, BeanOverrideStrategy strategy, MockReset reset, Class[] extraInterfaces, Answers answers, boolean serializable) { @@ -78,6 +85,16 @@ class MockitoBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { this.serializable = serializable; } + private MockitoBeanOverrideHandler(Parameter parameter, ResolvableType typeToMock, @Nullable String beanName, + String contextName, BeanOverrideStrategy strategy, MockReset reset, Class[] extraInterfaces, + Answers answers, boolean serializable) { + + super(parameter, typeToMock, beanName, contextName, strategy, reset); + Assert.notNull(typeToMock, "'typeToMock' must not be null"); + this.extraInterfaces = asClassSet(extraInterfaces); + this.answers = answers; + this.serializable = serializable; + } private static Set> asClassSet(Class[] classes) { if (classes.length == 0) { @@ -158,6 +175,7 @@ class MockitoBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { public String toString() { return new ToStringCreator(this) .append("field", getField()) + .append("parameter", getParameter()) .append("beanType", getBeanType()) .append("beanName", getBeanName()) .append("contextName", getContextName()) diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java index fe2cdfcbe41..7bd7468c6ac 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessor.java @@ -18,9 +18,12 @@ package org.springframework.test.context.bean.override.mockito; import java.lang.annotation.Annotation; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.List; +import org.jspecify.annotations.Nullable; + import org.springframework.core.ResolvableType; import org.springframework.test.context.bean.override.BeanOverrideHandler; import org.springframework.test.context.bean.override.BeanOverrideProcessor; @@ -56,6 +59,24 @@ class MockitoBeanOverrideProcessor implements BeanOverrideProcessor { .formatted(field.getDeclaringClass().getName(), field.getName())); } + @Override + public @Nullable BeanOverrideHandler createHandler(Annotation overrideAnnotation, Class testClass, Parameter parameter) { + if (overrideAnnotation instanceof MockitoBean mockitoBean) { + Assert.state(mockitoBean.types().length == 0, + "The @MockitoBean 'types' attribute must be omitted when declared on a parameter"); + return new MockitoBeanOverrideHandler(parameter, ResolvableType.forParameter(parameter), mockitoBean); + } + else if (overrideAnnotation instanceof MockitoSpyBean mockitoSpyBean) { + Assert.state(mockitoSpyBean.types().length == 0, + "The @MockitoSpyBean 'types' attribute must be omitted when declared on a parameter"); + return new MockitoSpyBeanOverrideHandler(parameter, ResolvableType.forParameter(parameter), mockitoSpyBean); + } + throw new IllegalStateException(""" + Invalid annotation passed to MockitoBeanOverrideProcessor: \ + expected either @MockitoBean or @MockitoSpyBean on parameter '%s' in constructor %s""" + .formatted(parameter.getName(), parameter.getDeclaringExecutable().getName())); + } + @Override public List createHandlers(Annotation overrideAnnotation, Class testClass) { if (overrideAnnotation instanceof MockitoBean mockitoBean) { 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 7ad92818a36..c58790aec3e 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 @@ -38,6 +38,7 @@ import org.springframework.test.context.bean.override.BeanOverride; *

  • On a non-static field in an enclosing class for a {@code @Nested} test class * or in any class in the type hierarchy or enclosing class hierarchy above the * {@code @Nested} test class.
  • + *
  • On a parameter in the constructor for the test class.
  • *
  • At the type level on a test class or any superclass or implemented interface * in the type hierarchy above the test class.
  • *
  • At the type level on an enclosing class for a {@code @Nested} test class @@ -45,15 +46,16 @@ import org.springframework.test.context.bean.override.BeanOverride; * above the {@code @Nested} test class.
  • * * - *

    When {@code @MockitoSpyBean} is declared on a field, the bean to spy is - * inferred from the type of the annotated field. If multiple candidates exist in - * the {@code ApplicationContext}, a {@code @Qualifier} annotation can be declared - * on the field to help disambiguate. In the absence of a {@code @Qualifier} - * annotation, the name of the annotated field will be used as a fallback - * qualifier. Alternatively, you can explicitly specify a bean name to spy - * by setting the {@link #value() value} or {@link #name() name} attribute. If a - * bean name is specified, it is required that a target bean with that name has - * been previously registered in the application context. + *

    When {@code @MockitoSpyBean} is declared on a field or parameter, the bean + * to spy is inferred from the type of the annotated field or parameter. If multiple + * candidates exist in the {@code ApplicationContext}, a {@code @Qualifier} annotation + * can be declared on the field or parameter to help disambiguate. In the absence + * of a {@code @Qualifier} annotation, the name of the annotated field or parameter + * will be used as a fallback qualifier. Alternatively, you can explicitly + * specify a bean name to spy by setting the {@link #value() value} or + * {@link #name() name} attribute. If a bean name is specified, it is required that + * a target bean with that name has been previously registered in the application + * context. * *

    When {@code @MockitoSpyBean} is declared at the type level, the type of bean * (or beans) to spy must be supplied via the {@link #types() types} attribute. @@ -123,7 +125,7 @@ import org.springframework.test.context.bean.override.BeanOverride; * @see org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean * @see org.springframework.test.context.bean.override.convention.TestBean @TestBean */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(MockitoSpyBeans.class) @@ -142,9 +144,9 @@ public @interface MockitoSpyBean { /** * Name of the bean to spy. *

    If left unspecified, the bean to spy is selected according to the - * configured {@link #types() types} or the annotated field's type, taking - * qualifiers into account if necessary. See the {@linkplain MockitoSpyBean - * class-level documentation} for details. + * configured {@link #types() types} or the type of the annotated field or + * parameter, taking qualifiers into account if necessary. See the + * {@linkplain MockitoSpyBean class-level documentation} for details. * @see #value() */ @AliasFor("value") @@ -155,7 +157,7 @@ public @interface MockitoSpyBean { *

    Defaults to none. *

    Each type specified will result in a spy being created and registered * with the {@code ApplicationContext}. - *

    Types must be omitted when the annotation is used on a field. + *

    Types must be omitted when the annotation is used on a field or parameter. *

    When {@code @MockitoSpyBean} also defines a {@link #name name}, this * attribute can only contain a single value. * @return the types to spy diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java index e9913ab44e0..d8cddb67489 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java @@ -17,6 +17,7 @@ package org.springframework.test.context.bean.override.mockito; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import java.lang.reflect.Proxy; import org.jspecify.annotations.Nullable; @@ -49,7 +50,7 @@ class MockitoSpyBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { MockitoSpyBeanOverrideHandler(ResolvableType typeToSpy, MockitoSpyBean spyBean) { - this(null, typeToSpy, spyBean); + this((Field) null, typeToSpy, spyBean); } MockitoSpyBeanOverrideHandler(@Nullable Field field, ResolvableType typeToSpy, MockitoSpyBean spyBean) { @@ -58,6 +59,12 @@ class MockitoSpyBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { Assert.notNull(typeToSpy, "typeToSpy must not be null"); } + MockitoSpyBeanOverrideHandler(Parameter parameter, ResolvableType typeToSpy, MockitoSpyBean spyBean) { + super(parameter, typeToSpy, (StringUtils.hasText(spyBean.name()) ? spyBean.name() : null), + spyBean.contextName(), BeanOverrideStrategy.WRAP, spyBean.reset()); + Assert.notNull(typeToSpy, "typeToSpy must not be null"); + } + @Override protected Object createOverrideInstance(String beanName, @Nullable BeanDefinition existingBeanDefinition, diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java index debb5708369..92f186c7804 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java @@ -24,6 +24,8 @@ import java.lang.reflect.Parameter; import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Optional; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterAll; @@ -58,6 +60,9 @@ import org.springframework.core.annotation.RepeatableContainers; import org.springframework.test.context.MethodInvoker; import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.test.context.TestContextManager; +import org.springframework.test.context.bean.override.BeanOverride; +import org.springframework.test.context.bean.override.BeanOverrideHandler; +import org.springframework.test.context.bean.override.BeanOverrideUtils; import org.springframework.test.context.event.ApplicationEvents; import org.springframework.test.context.event.RecordApplicationEvents; import org.springframework.test.context.support.PropertyProvider; @@ -370,6 +375,9 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes * invoked with a fallback {@link PropertyProvider} that delegates its lookup * to {@link ExtensionContext#getConfigurationParameter(String)}. *

  • The parameter is of type {@link ApplicationContext} or a sub-type thereof.
  • + *
  • The parameter is annotated or meta-annotated with a + * {@link BeanOverride @BeanOverride} composed annotation — for example, + * {@code @MockitoBean} or {@code @MockitoSpyBean}.
  • *
  • The parameter is of type {@link ApplicationEvents} or a sub-type thereof.
  • *
  • {@link ParameterResolutionDelegate#isAutowirable} returns {@code true}.
  • * @@ -396,6 +404,7 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes extensionContext.getConfigurationParameter(propertyName).orElse(null); return (TestConstructorUtils.isAutowirableConstructor(executable, junitPropertyProvider) || ApplicationContext.class.isAssignableFrom(parameterType) || + isBeanOverride(parameter) || supportsApplicationEvents(parameterType, executable) || ParameterResolutionDelegate.isAutowirable(parameter, parameterContext.getIndex())); } @@ -414,7 +423,7 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes * retrieving the corresponding dependency from the test's {@link ApplicationContext}. *

    Delegates to {@link ParameterResolutionDelegate}. * @see #supportsParameter - * @see ParameterResolutionDelegate#resolveDependency + * @see ParameterResolutionDelegate#resolveDependency(Parameter, int, String, Class, org.springframework.beans.factory.config.AutowireCapableBeanFactory) */ @Override public @Nullable Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { @@ -428,6 +437,16 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes } ApplicationContext applicationContext = getApplicationContext(extensionContext); + if (isBeanOverride(parameter)) { + Optional beanName = BeanOverrideUtils.findAllHandlers(testClass).stream() + .filter(handler -> parameter.equals(handler.getParameter())) + .map(BeanOverrideHandler::getBeanName) + .filter(Objects::nonNull) + .findFirst(); + if (beanName.isPresent()) { + return applicationContext.getBean(beanName.get()); + } + } return ParameterResolutionDelegate.resolveDependency(parameter, index, testClass, applicationContext.getAutowireCapableBeanFactory()); } @@ -495,6 +514,11 @@ public class SpringExtension implements BeforeAllCallback, AfterAllCallback, Tes return false; } + private static boolean isBeanOverride(Parameter parameter) { + return (parameter.getDeclaringExecutable() instanceof Constructor && + MergedAnnotations.from(parameter).isPresent(BeanOverride.class)); + } + /** * Find the properly scoped {@link ExtensionContext} for the supplied test class. *

    If the supplied {@code ExtensionContext} is already properly scoped, it 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 index 6f1e5a73c2e..47ed8c0c792 100644 --- 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 @@ -24,7 +24,7 @@ import java.lang.annotation.Target; import org.springframework.beans.factory.annotation.Qualifier; -@Target({ElementType.FIELD, ElementType.METHOD}) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Qualifier diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java index cec11d42147..d7aabf7d2da 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanConfigurationErrorTests.java @@ -88,6 +88,65 @@ class MockitoBeanConfigurationErrorTests { List.of("bean1", "bean2")); } + @Test // gh-36096 + void cannotOverrideBeanByNameWithNoSuchBeanNameOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("anotherBean", String.class, () -> "example"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByNameLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'beanToOverride' and type \ + java.lang.String (as required by parameter 'example' in constructor for %s). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type).""", + FailureByNameLookupOnConstructorParameter.class.getName()); + } + + @Test // gh-36096 + void cannotOverrideBeanByNameWithBeanOfWrongTypeOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("beanToOverride", Integer.class, () -> 42); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByNameLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to replace bean: there is no bean with name 'beanToOverride' and type \ + java.lang.String (as required by parameter 'example' in constructor for %s). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type).""", + FailureByNameLookupOnConstructorParameter.class.getName()); + } + + @Test // gh-36096 + void cannotOverrideBeanByTypeWithNoSuchBeanTypeOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByTypeLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to override bean: there are no beans of type java.lang.String \ + (as required by parameter 'example' in constructor for %s). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type).""", + FailureByTypeLookupOnConstructorParameter.class.getName()); + } + + @Test // gh-36096 + void cannotOverrideBeanByTypeWithTooManyBeansOfThatTypeOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("bean1", String.class, () -> "example1"); + context.registerBean("bean2", String.class, () -> "example2"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(FailureByTypeLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to override: found 2 beans of type java.lang.String \ + (as required by parameter 'example' in constructor for %s): %s""", + FailureByTypeLookupOnConstructorParameter.class.getName(), + List.of("bean1", "bean2")); + } + static class FailureByTypeLookup { @@ -99,7 +158,18 @@ class MockitoBeanConfigurationErrorTests { @MockitoBean(name = "beanToOverride", enforceOverride = true) String example; + } + + static class FailureByTypeLookupOnConstructorParameter { + + FailureByTypeLookupOnConstructorParameter(@MockitoBean(enforceOverride = true) String example) { + } + } + + static class FailureByNameLookupOnConstructorParameter { + FailureByNameLookupOnConstructorParameter(@MockitoBean(name = "beanToOverride", enforceOverride = true) String example) { + } } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java index 3fe2c80157a..3b9234fb5bf 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideHandlerTests.java @@ -194,7 +194,7 @@ class MockitoBeanOverrideHandlerTests { private MockitoBeanOverrideHandler createHandler(Class clazz) { MockitoBean annotation = AnnotatedElementUtils.getMergedAnnotation(clazz, MockitoBean.class); - return new MockitoBeanOverrideHandler(null, ResolvableType.forClass(annotation.types()[0]), annotation); + return new MockitoBeanOverrideHandler((Field) null, ResolvableType.forClass(annotation.types()[0]), annotation); } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java index 78fe74349c4..ff025944095 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanOverrideProcessorTests.java @@ -18,6 +18,7 @@ package org.springframework.test.context.bean.override.mockito; import java.lang.annotation.Annotation; import java.lang.reflect.Field; +import java.lang.reflect.Parameter; import java.util.List; import org.jspecify.annotations.Nullable; @@ -102,6 +103,81 @@ class MockitoBeanOverrideProcessorTests { } } + @Nested // gh-36096 + class CreateHandlerForParameterTests { + + private final Parameter parameter = TestCase.class.getDeclaredConstructors()[0].getParameters()[0]; + + + @Test + void mockAnnotationCreatesMockitoBeanOverrideHandler() { + MockitoBean annotation = AnnotationUtils.synthesizeAnnotation(MockitoBean.class); + BeanOverrideHandler handler = processor.createHandler(annotation, TestCase.class, parameter); + + assertThat(handler).isExactlyInstanceOf(MockitoBeanOverrideHandler.class); + } + + @Test + void spyAnnotationCreatesMockitoSpyBeanOverrideHandler() { + MockitoSpyBean annotation = AnnotationUtils.synthesizeAnnotation(MockitoSpyBean.class); + BeanOverrideHandler handler = processor.createHandler(annotation, TestCase.class, parameter); + + assertThat(handler).isExactlyInstanceOf(MockitoSpyBeanOverrideHandler.class); + } + + @Test + void otherAnnotationThrows() { + Annotation annotation = parameter.getAnnotation(Nullable.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandler(annotation, TestCase.class, parameter)) + .withMessage("Invalid annotation passed to MockitoBeanOverrideProcessor: expected either " + + "@MockitoBean or @MockitoSpyBean on parameter '%s' in constructor %s", + parameter.getName(), parameter.getDeclaringExecutable().getName()); + } + + @Test + void typesAttributeNotSupportedForMockitoBean() { + Parameter parameter = TypesNotSupportedForMockitoBeanTestCase.class + .getDeclaredConstructors()[0].getParameters()[0]; + MockitoBean annotation = parameter.getAnnotation(MockitoBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandler(annotation, TypesNotSupportedForMockitoBeanTestCase.class, parameter)) + .withMessage("The @MockitoBean 'types' attribute must be omitted when declared on a parameter"); + } + + @Test + void typesAttributeNotSupportedForMockitoSpyBean() { + Parameter parameter = TypesNotSupportedForMockitoSpyBeanTestCase.class + .getDeclaredConstructors()[0].getParameters()[0]; + MockitoSpyBean annotation = parameter.getAnnotation(MockitoSpyBean.class); + + assertThatIllegalStateException() + .isThrownBy(() -> processor.createHandler(annotation, TypesNotSupportedForMockitoSpyBeanTestCase.class, parameter)) + .withMessage("The @MockitoSpyBean 'types' attribute must be omitted when declared on a parameter"); + } + + + static class TestCase { + + TestCase(@MockitoBean @MockitoSpyBean @Nullable Integer number) { + } + } + + static class TypesNotSupportedForMockitoBeanTestCase { + + TypesNotSupportedForMockitoBeanTestCase(@MockitoBean(types = Integer.class) String param) { + } + } + + static class TypesNotSupportedForMockitoSpyBeanTestCase { + + TypesNotSupportedForMockitoSpyBeanTestCase(@MockitoSpyBean(types = Integer.class) String param) { + } + } + } + @Nested class CreateHandlersTests { diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java index 387b4af0864..71f63337af4 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanConfigurationErrorTests.java @@ -113,6 +113,50 @@ class MockitoSpyBeanConfigurationErrorTests { to spy on a scoped proxy, which is not supported."""); } + @Test // gh-36096 + void contextCustomizerCannotBeCreatedWithNoSuchBeanNameOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("present", String.class, () -> "example"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(ByNameSingleLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to wrap bean: there is no bean with name 'beanToSpy' and type \ + java.lang.String (as required by parameter 'example' in constructor for %s). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type).""", + ByNameSingleLookupOnConstructorParameter.class.getName()); + } + + @Test // gh-36096 + void contextCustomizerCannotBeCreatedWithNoSuchBeanTypeOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(ByTypeSingleLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to wrap: there are no beans of type java.lang.String \ + (as required by parameter 'example' in constructor for %s). \ + If the bean is defined in a @Bean method, make sure the return type is the most \ + specific type possible (for example, the concrete implementation type).""", + ByTypeSingleLookupOnConstructorParameter.class.getName()); + } + + @Test // gh-36096 + void contextCustomizerCannotBeCreatedWithTooManyBeansOfThatTypeOnConstructorParameter() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean("bean1", String.class, () -> "example1"); + context.registerBean("bean2", String.class, () -> "example2"); + BeanOverrideContextCustomizerTestUtils.customizeApplicationContext(ByTypeSingleLookupOnConstructorParameter.class, context); + assertThatIllegalStateException() + .isThrownBy(context::refresh) + .withMessage(""" + Unable to select a bean to wrap: found 2 beans of type java.lang.String \ + (as required by parameter 'example' in constructor for %s): %s""", + ByTypeSingleLookupOnConstructorParameter.class.getName(), + List.of("bean1", "bean2")); + } + static class ByTypeSingleLookup { @@ -124,7 +168,18 @@ class MockitoSpyBeanConfigurationErrorTests { @MockitoSpyBean("beanToSpy") String example; + } + + static class ByTypeSingleLookupOnConstructorParameter { + + ByTypeSingleLookupOnConstructorParameter(@MockitoSpyBean String example) { + } + } + static class ByNameSingleLookupOnConstructorParameter { + + ByNameSingleLookupOnConstructorParameter(@MockitoSpyBean("beanToSpy") String example) { + } } static class ScopedProxyTestCase { diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByNameLookupForConstructorParametersIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByNameLookupForConstructorParametersIntegrationTests.java new file mode 100644 index 00000000000..e312efb3c80 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByNameLookupForConstructorParametersIntegrationTests.java @@ -0,0 +1,175 @@ +/* + * Copyright 2002-present 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.mockito.constructor; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +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.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} that use by-name lookup + * on constructor parameters. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + * @see org.springframework.test.context.bean.override.mockito.MockitoBeanByNameLookupTestMethodScopedExtensionContextIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanByNameLookupForConstructorParametersIntegrationTests { + + final ExampleService service0A; + + final ExampleService service0B; + + final ExampleService service0C; + + final ExampleService nonExisting; + + + MockitoBeanByNameLookupForConstructorParametersIntegrationTests( + @MockitoBean ExampleService s0A, + @MockitoBean(name = "s0B") ExampleService service0B, + @MockitoBean @Qualifier("s0C") ExampleService service0C, + @MockitoBean("nonExistingBean") ExampleService nonExisting) { + + this.service0A = s0A; + this.service0B = service0B; + this.service0C = service0C; + this.nonExisting = nonExisting; + } + + + @Test + void parameterNameIsUsedAsBeanName(ApplicationContext ctx) { + assertThat(this.service0A) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("s0A")); + + assertThat(this.service0A.greeting()).as("mocked greeting").isNull(); + } + + @Test + void explicitBeanNameOverridesParameterName(ApplicationContext ctx) { + assertThat(this.service0B) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("s0B")); + + assertThat(this.service0B.greeting()).as("mocked greeting").isNull(); + } + + @Test + void qualifierIsUsedToResolveByName(ApplicationContext ctx) { + assertThat(this.service0C) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("s0C")); + + assertThat(this.service0C.greeting()).as("mocked greeting").isNull(); + } + + @Test + void mockIsCreatedWhenNoBeanExistsWithProvidedName(ApplicationContext ctx) { + assertThat(this.nonExisting) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("nonExistingBean")); + + assertThat(this.nonExisting.greeting()).as("mocked greeting").isNull(); + } + + + @Nested + class NestedTests { + + @Autowired + @Qualifier("s0A") + ExampleService localService0A; + + @Autowired + @Qualifier("nonExistingBean") + ExampleService localNonExisting; + + final ExampleService nestedNonExisting; + + + NestedTests(@MockitoBean("nestedNonExistingBean") ExampleService nestedNonExisting) { + this.nestedNonExisting = nestedNonExisting; + } + + + @Test + void mockFromEnclosingClassIsAccessibleViaAutowiring(ApplicationContext ctx) { + assertThat(this.localService0A) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(service0A) + .isSameAs(ctx.getBean("s0A")); + + assertThat(this.localService0A.greeting()).as("mocked greeting").isNull(); + } + + @Test + void mockForNonExistingBeanFromEnclosingClassIsAccessibleViaAutowiring(ApplicationContext ctx) { + assertThat(this.localNonExisting) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(nonExisting) + .isSameAs(ctx.getBean("nonExistingBean")); + + assertThat(this.localNonExisting.greeting()).as("mocked greeting").isNull(); + } + + @Test + void nestedConstructorParameterIsMockedWhenNoBeanExistsWithProvidedName(ApplicationContext ctx) { + assertThat(this.nestedNonExisting) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("nestedNonExistingBean")); + + assertThat(this.nestedNonExisting.greeting()).as("mocked greeting").isNull(); + } + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService s0A() { + return new RealExampleService("prod s0A"); + } + + @Bean + ExampleService s0B() { + return new RealExampleService("prod s0B"); + } + + @Bean + ExampleService s0C() { + return new RealExampleService("prod s0C"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationRecordTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationRecordTests.java new file mode 100644 index 00000000000..958b3c4ccca --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationRecordTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-present 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.mockito.constructor; + +import org.junit.jupiter.api.Test; + +import org.springframework.test.context.bean.override.example.ExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} that use by-type lookup + * on constructor parameters in a Java record. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + */ +@SpringJUnitConfig +record MockitoBeanByTypeLookupForConstructorParametersIntegrationRecordTests( + @MockitoBean ExampleService exampleService) { + + @Test + void test() { + assertIsMock(this.exampleService); + + when(this.exampleService.greeting()).thenReturn("Mocked greeting"); + + assertThat(this.exampleService.greeting()).isEqualTo("Mocked greeting"); + verify(this.exampleService, times(1)).greeting(); + verifyNoMoreInteractions(this.exampleService); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationTests.java new file mode 100644 index 00000000000..c88587bd6aa --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoBeanByTypeLookupForConstructorParametersIntegrationTests.java @@ -0,0 +1,207 @@ +/* + * Copyright 2002-present 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.mockito.constructor; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.NoUniqueBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +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.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.mockito.MockitoAssertions.assertIsMock; + +/** + * Integration tests for {@link MockitoBean @MockitoBean} that use by-type lookup + * on constructor parameters. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + * @see org.springframework.test.context.bean.override.mockito.MockitoBeanByTypeLookupIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanByTypeLookupForConstructorParametersIntegrationTests { + + final AnotherService serviceIsNotABean; + + final ExampleService anyNameForService; + + final StringBuilder ambiguous; + + final StringBuilder ambiguousMeta; + + + MockitoBeanByTypeLookupForConstructorParametersIntegrationTests( + @MockitoBean AnotherService serviceIsNotABean, + @MockitoBean ExampleService anyNameForService, + @MockitoBean @Qualifier("prefer") StringBuilder ambiguous, + @MockitoBean @CustomQualifier StringBuilder ambiguousMeta) { + + this.serviceIsNotABean = serviceIsNotABean; + this.anyNameForService = anyNameForService; + this.ambiguous = ambiguous; + this.ambiguousMeta = ambiguousMeta; + } + + + @Test + void mockIsCreatedWhenNoCandidateIsFound() { + assertIsMock(this.serviceIsNotABean); + + when(this.serviceIsNotABean.hello()).thenReturn("Mocked hello"); + + assertThat(this.serviceIsNotABean.hello()).isEqualTo("Mocked hello"); + verify(this.serviceIsNotABean, times(1)).hello(); + verifyNoMoreInteractions(this.serviceIsNotABean); + } + + @Test + void overrideIsFoundByType(ApplicationContext ctx) { + assertThat(this.anyNameForService) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + + when(this.anyNameForService.greeting()).thenReturn("Mocked greeting"); + + assertThat(this.anyNameForService.greeting()).isEqualTo("Mocked greeting"); + verify(this.anyNameForService, times(1)).greeting(); + verifyNoMoreInteractions(this.anyNameForService); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByQualifier(ApplicationContext ctx) { + assertThat(this.ambiguous) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("ambiguous2")); + + assertThatExceptionOfType(NoUniqueBeanDefinitionException.class) + .isThrownBy(() -> ctx.getBean(StringBuilder.class)) + .satisfies(ex -> assertThat(ex.getBeanNamesFound()).containsOnly("ambiguous1", "ambiguous2")); + + assertThat(this.ambiguous).isEmpty(); + 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(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("ambiguous1")); + + assertThatExceptionOfType(NoUniqueBeanDefinitionException.class) + .isThrownBy(() -> ctx.getBean(StringBuilder.class)) + .satisfies(ex -> assertThat(ex.getBeanNamesFound()).containsOnly("ambiguous1", "ambiguous2")); + + assertThat(this.ambiguousMeta).isEmpty(); + assertThat(this.ambiguousMeta.substring(0)).isNull(); + verify(this.ambiguousMeta, times(1)).length(); + verify(this.ambiguousMeta, times(1)).substring(anyInt()); + verifyNoMoreInteractions(this.ambiguousMeta); + } + + + @Nested + class NestedTests { + + @Autowired + ExampleService localAnyNameForService; + + final NestedService nestedService; + + + NestedTests(@MockitoBean NestedService nestedService) { + this.nestedService = nestedService; + } + + + @Test + void mockFromEnclosingClassConstructorParameterIsAccessibleViaAutowiring(ApplicationContext ctx) { + assertThat(this.localAnyNameForService) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(anyNameForService) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + } + + @Test + void nestedConstructorParameterIsAMock() { + assertIsMock(this.nestedService); + + when(this.nestedService.hello()).thenReturn("Nested hello"); + assertThat(this.nestedService.hello()).isEqualTo("Nested hello"); + verify(this.nestedService).hello(); + verifyNoMoreInteractions(this.nestedService); + } + } + + + public interface AnotherService { + + String hello(); + } + + public interface NestedService { + + String hello(); + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("example") + 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"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByNameLookupForConstructorParametersIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByNameLookupForConstructorParametersIntegrationTests.java new file mode 100644 index 00000000000..0852a2d41cd --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByNameLookupForConstructorParametersIntegrationTests.java @@ -0,0 +1,165 @@ +/* + * Copyright 2002-present 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.mockito.constructor; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +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.ExampleService; +import org.springframework.test.context.bean.override.example.RealExampleService; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Integration tests for {@link MockitoSpyBean @MockitoSpyBean} that use by-name + * lookup on constructor parameters. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + * @see org.springframework.test.context.bean.override.mockito.MockitoSpyBeanByNameLookupTestMethodScopedExtensionContextIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanByNameLookupForConstructorParametersIntegrationTests { + + final ExampleService service1; + + final ExampleService service2; + + final ExampleService service3; + + + MockitoSpyBeanByNameLookupForConstructorParametersIntegrationTests( + @MockitoSpyBean ExampleService s1, + @MockitoSpyBean("s2") ExampleService service2, + @MockitoSpyBean @Qualifier("s3") ExampleService service3) { + + this.service1 = s1; + this.service2 = service2; + this.service3 = service3; + } + + + @Test + void parameterNameIsUsedAsBeanName(ApplicationContext ctx) { + assertThat(this.service1) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("s1")); + + assertThat(this.service1.greeting()).isEqualTo("prod 1"); + verify(this.service1).greeting(); + verifyNoMoreInteractions(this.service1); + } + + @Test + void explicitBeanNameOverridesParameterName(ApplicationContext ctx) { + assertThat(this.service2) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("s2")); + + assertThat(this.service2.greeting()).isEqualTo("prod 2"); + verify(this.service2).greeting(); + verifyNoMoreInteractions(this.service2); + } + + @Test + void qualifierIsUsedToResolveByName(ApplicationContext ctx) { + assertThat(this.service3) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("s3")); + + assertThat(this.service3.greeting()).isEqualTo("prod 3"); + verify(this.service3).greeting(); + verifyNoMoreInteractions(this.service3); + } + + + @Nested + class NestedTests { + + @Autowired + @Qualifier("s1") + ExampleService localService1; + + final ExampleService nestedSpy; + + + NestedTests(@MockitoSpyBean("s4") ExampleService nestedSpy) { + this.nestedSpy = nestedSpy; + } + + + @Test + void spyFromEnclosingClassIsAccessibleViaAutowiring(ApplicationContext ctx) { + assertThat(this.localService1) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(service1) + .isSameAs(ctx.getBean("s1")); + + assertThat(this.localService1.greeting()).isEqualTo("prod 1"); + verify(this.localService1).greeting(); + verifyNoMoreInteractions(this.localService1); + } + + @Test + void nestedConstructorParameterIsASpy(ApplicationContext ctx) { + assertThat(this.nestedSpy) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("s4")); + + assertThat(this.nestedSpy.greeting()).isEqualTo("prod 4"); + verify(this.nestedSpy).greeting(); + verifyNoMoreInteractions(this.nestedSpy); + } + } + + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + ExampleService s1() { + return new RealExampleService("prod 1"); + } + + @Bean + ExampleService s2() { + return new RealExampleService("prod 2"); + } + + @Bean + ExampleService s3() { + return new RealExampleService("prod 3"); + } + + @Bean + ExampleService s4() { + return new RealExampleService("prod 4"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByTypeLookupForConstructorParametersIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByTypeLookupForConstructorParametersIntegrationTests.java new file mode 100644 index 00000000000..ca6b3bb6c87 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/constructor/MockitoSpyBeanByTypeLookupForConstructorParametersIntegrationTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2002-present 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.mockito.constructor; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +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.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.mockito.MockitoAssertions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Integration tests for {@link MockitoSpyBean @MockitoSpyBean} that use by-type + * lookup on constructor parameters. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + * @see org.springframework.test.context.bean.override.mockito.MockitoSpyBeanByTypeLookupIntegrationTests + */ +@SpringJUnitConfig +class MockitoSpyBeanByTypeLookupForConstructorParametersIntegrationTests { + + final ExampleService anyNameForService; + + final StringHolder ambiguous; + + final StringHolder ambiguousMeta; + + + MockitoSpyBeanByTypeLookupForConstructorParametersIntegrationTests( + @MockitoSpyBean ExampleService anyNameForService, + @MockitoSpyBean @Qualifier("prefer") StringHolder ambiguous, + @MockitoSpyBean @CustomQualifier StringHolder ambiguousMeta) { + + this.anyNameForService = anyNameForService; + this.ambiguous = ambiguous; + this.ambiguousMeta = ambiguousMeta; + } + + + @Test + void overrideIsFoundByType(ApplicationContext ctx) { + assertThat(this.anyNameForService) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + + assertThat(this.anyNameForService.greeting()).isEqualTo("Production hello"); + verify(this.anyNameForService).greeting(); + verifyNoMoreInteractions(this.anyNameForService); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByQualifier(ApplicationContext ctx) { + assertThat(this.ambiguous) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("ambiguous2")); + + assertThatException() + .isThrownBy(() -> ctx.getBean(StringHolder.class)) + .withMessageEndingWith("but found 2: ambiguous1,ambiguous2"); + + assertThat(this.ambiguous.getValue()).isEqualTo("bean3"); + assertThat(this.ambiguous.size()).isEqualTo(5); + verify(this.ambiguous).getValue(); + verify(this.ambiguous).size(); + verifyNoMoreInteractions(this.ambiguous); + } + + @Test + void overrideIsFoundByTypeAndDisambiguatedByMetaQualifier(ApplicationContext ctx) { + assertThat(this.ambiguousMeta) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("ambiguous1")); + + assertThatException() + .isThrownBy(() -> ctx.getBean(StringHolder.class)) + .withMessageEndingWith("but found 2: ambiguous1,ambiguous2"); + + assertThat(this.ambiguousMeta.getValue()).isEqualTo("bean2"); + assertThat(this.ambiguousMeta.size()).isEqualTo(5); + verify(this.ambiguousMeta).getValue(); + verify(this.ambiguousMeta).size(); + verifyNoMoreInteractions(this.ambiguousMeta); + } + + + @Nested + class NestedTests { + + @Autowired + ExampleService localAnyNameForService; + + final AnotherService nestedSpy; + + + NestedTests(@MockitoSpyBean AnotherService nestedSpy) { + this.nestedSpy = nestedSpy; + } + + + @Test + void spyFromEnclosingClassConstructorParameterIsAccessibleViaAutowiring(ApplicationContext ctx) { + assertThat(this.localAnyNameForService) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(anyNameForService) + .isSameAs(ctx.getBean("example")) + .isSameAs(ctx.getBean(ExampleService.class)); + + assertThat(this.localAnyNameForService.greeting()).isEqualTo("Production hello"); + verify(this.localAnyNameForService).greeting(); + verifyNoMoreInteractions(this.localAnyNameForService); + } + + @Test + void nestedConstructorParameterIsASpy(ApplicationContext ctx) { + assertThat(this.nestedSpy) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("anotherService")) + .isSameAs(ctx.getBean(AnotherService.class)); + + assertThat(this.nestedSpy.hello()).isEqualTo("Another hello"); + verify(this.nestedSpy).hello(); + verifyNoMoreInteractions(this.nestedSpy); + } + } + + + interface AnotherService { + + String hello(); + } + + static class StringHolder { + + private final String value; + + StringHolder(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + public int size() { + return this.value.length(); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean("example") + ExampleService bean1() { + return new RealExampleService("Production hello"); + } + + @Bean("ambiguous1") + @Order(1) + @CustomQualifier + StringHolder bean2() { + return new StringHolder("bean2"); + } + + @Bean("ambiguous2") + @Order(2) + @Qualifier("prefer") + StringHolder bean3() { + return new StringHolder("bean3"); + } + + @Bean + AnotherService anotherService() { + return new AnotherService() { + @Override + public String hello() { + return "Another hello"; + } + }; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/ConstructorService01.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/ConstructorService01.java new file mode 100644 index 00000000000..adc28db41fa --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/ConstructorService01.java @@ -0,0 +1,20 @@ +/* + * Copyright 2002-present 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.mockito.typelevel; + +interface ConstructorService01 extends Service { +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java index 87424d76770..52f576a6a96 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByNameIntegrationTests.java @@ -48,6 +48,10 @@ import static org.springframework.test.mockito.MockitoAssertions.assertIsNotMock @MockitoBean(name = "s2", types = ExampleService.class) class MockitoBeansByNameIntegrationTests { + final ExampleService service0A; + final ExampleService service0B; + final ExampleService service0C; + @Autowired ExampleService s1; @@ -62,6 +66,16 @@ class MockitoBeansByNameIntegrationTests { ExampleService service4; + MockitoBeansByNameIntegrationTests(@MockitoBean ExampleService s0A, + @MockitoBean(name = "s0B") ExampleService service0B, + @MockitoBean @Qualifier("s0C") ExampleService service0C) { + + this.service0A = s0A; + this.service0B = service0B; + this.service0C = service0C; + } + + @BeforeEach void configureMocks() { assertIsMock(s1, "s1"); @@ -86,6 +100,21 @@ class MockitoBeansByNameIntegrationTests { @Configuration static class Config { + @Bean + ExampleService s0A() { + return () -> "prod 0A"; + } + + @Bean + ExampleService s0B() { + return () -> "prod 0B"; + } + + @Bean + ExampleService s0C() { + return () -> "prod 0C"; + } + @Bean ExampleService s1() { return () -> "prod 1"; diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java index e3e5207b4de..f6f529220cc 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansByTypeIntegrationTests.java @@ -63,10 +63,17 @@ class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { @Autowired Service06 service06; + final ConstructorService01 constructorService01; + @MockitoBean Service07 service07; + MockitoBeansByTypeIntegrationTests(@MockitoBean ConstructorService01 constructorService01) { + this.constructorService01 = constructorService01; + } + + @BeforeEach void configureMocks() { assertIsMock(service01, "service01"); @@ -75,6 +82,7 @@ class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { assertIsMock(service04, "service04"); assertIsMock(service05, "service05"); assertIsMock(service06, "service06"); + assertIsMock(constructorService01, "constructorService01"); assertIsMock(service07, "service07"); given(service01.greeting()).willReturn("mock 01"); @@ -83,6 +91,7 @@ class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { given(service04.greeting()).willReturn("mock 04"); given(service05.greeting()).willReturn("mock 05"); given(service06.greeting()).willReturn("mock 06"); + given(constructorService01.greeting()).willReturn("mock constructor 01"); given(service07.greeting()).willReturn("mock 07"); } @@ -94,6 +103,7 @@ class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { assertThat(service04.greeting()).isEqualTo("mock 04"); assertThat(service05.greeting()).isEqualTo("mock 05"); assertThat(service06.greeting()).isEqualTo("mock 06"); + assertThat(constructorService01.greeting()).isEqualTo("mock constructor 01"); assertThat(service07.greeting()).isEqualTo("mock 07"); } @@ -133,6 +143,7 @@ class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { assertIsMock(service04, "service04"); assertIsMock(service05, "service05"); assertIsMock(service06, "service06"); + assertIsMock(constructorService01, "constructorService01"); assertIsMock(service07, "service07"); assertIsMock(service08, "service08"); assertIsMock(service09, "service09"); @@ -157,6 +168,7 @@ class MockitoBeansByTypeIntegrationTests implements MockTestInterface01 { assertThat(service04.greeting()).isEqualTo("mock 04"); assertThat(service05.greeting()).isEqualTo("mock 05"); assertThat(service06.greeting()).isEqualTo("mock 06"); + assertThat(constructorService01.greeting()).isEqualTo("mock constructor 01"); assertThat(service07.greeting()).isEqualTo("mock 07"); assertThat(service08.greeting()).isEqualTo("mock 08"); assertThat(service09.greeting()).isEqualTo("mock 09"); diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java index b7714e3e512..6eaa8e0123c 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/typelevel/MockitoBeansTests.java @@ -44,7 +44,7 @@ class MockitoBeansTests { Stream> mockedServices = getRegisteredMockTypes(MockitoBeansByTypeIntegrationTests.class); assertThat(mockedServices).containsExactly( Service01.class, Service02.class, Service03.class, Service04.class, - Service05.class, Service06.class, Service07.class); + Service05.class, Service06.class, ConstructorService01.class, Service07.class); } @Test @@ -52,8 +52,8 @@ class MockitoBeansTests { Stream> mockedServices = getRegisteredMockTypes(MockitoBeansByTypeIntegrationTests.NestedTests.class); assertThat(mockedServices).containsExactly( Service01.class, Service02.class, Service03.class, Service04.class, - Service05.class, Service06.class, Service07.class, Service08.class, - Service09.class, Service10.class, Service11.class, Service12.class, + Service05.class, Service06.class, ConstructorService01.class, Service07.class, + Service08.class, Service09.class, Service10.class, Service11.class, Service12.class, Service13.class); } diff --git a/spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinDataClassTests.kt b/spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinDataClassTests.kt new file mode 100644 index 00000000000..537c0c11d2b --- /dev/null +++ b/spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinDataClassTests.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2002-present 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.mockito + +import org.junit.jupiter.api.Test + +import org.springframework.test.context.bean.override.example.ExampleService +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.mockito.MockitoAssertions.assertIsMock + +/** + * Integration tests for [@MockitoBean][MockitoBean] that use by-type lookup + * on constructor parameters in a Kotlin data class. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + * @see MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinTests + */ +@SpringJUnitConfig +data class MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinDataClassTests( + @MockitoBean val exampleService: ExampleService) { + + @Test + fun test() { + assertIsMock(exampleService) + } + +} diff --git a/spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinTests.kt b/spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinTests.kt new file mode 100644 index 00000000000..b1b9c5f9391 --- /dev/null +++ b/spring-test/src/test/kotlin/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinTests.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2002-present 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.mockito + +import org.junit.jupiter.api.Test + +import org.springframework.test.context.bean.override.example.ExampleService +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.mockito.MockitoAssertions.assertIsMock + +/** + * Integration tests for [@MockitoBean][MockitoBean] that use by-type lookup + * on constructor parameters in Kotlin. + * + * @author Sam Brannen + * @since 7.1 + * @see gh-36096 + * @see org.springframework.test.context.bean.override.mockito.MockitoBeanByNameLookupTestMethodScopedExtensionContextIntegrationTests + */ +@SpringJUnitConfig +class MockitoBeanByTypeLookupForConstructorParametersIntegrationKotlinTests( + @MockitoBean val exampleService: ExampleService) { + + @Test + fun test() { + assertIsMock(exampleService) + } + +}