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) + } + +}