From 30db2e4fb5e993ba6ada58d2d55ca4e82297e615 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:25:47 +0200 Subject: [PATCH] Support Bean Overrides for non-singletons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, the BeanOverrideBeanFactoryPostProcessor rejected any attempt to override a non-singleton bean; however, due to interest from the community, we have decided to provide support for overriding non-singleton beans via the Bean Override mechanism — for example, when using @⁠MockitoBean, @⁠MockitoSpyBean, and @⁠TestBean. With this commit, we now support Bean Overrides for non-singletons: for standard JVM runtimes as well as AOT processing and AOT runtimes. This commit also documents that non-singletons will effectively be converted to singletons when overridden and logs a warning similar to the following. WARN: BeanOverrideBeanFactoryPostProcessor - Converting 'prototype' scoped bean definition 'myBean' to a singleton. See gh-33602 See gh-32933 See gh-33800 Closes gh-35574 --- .../annotation-mockitobean.adoc | 14 +++++-- .../annotation-testbean.adoc | 15 +++++--- .../bean-overriding.adoc | 7 +++- .../BeanOverrideBeanFactoryPostProcessor.java | 30 +++++++++------ .../bean/override/BeanOverrideHandler.java | 20 +++++++++- .../bean/override/convention/TestBean.java | 13 ++++--- .../bean/override/mockito/MockitoBean.java | 12 +++--- .../bean/override/mockito/MockitoSpyBean.java | 12 +++--- ...OverrideBeanFactoryPostProcessorTests.java | 24 ++++++------ .../TestBeanByNameLookupIntegrationTests.java | 24 ++++++++++++ .../TestBeanByTypeLookupIntegrationTests.java | 21 +++++++++++ ...ckitoBeanByNameLookupIntegrationTests.java | 22 +++++++++++ ...ckitoBeanByTypeLookupIntegrationTests.java | 31 ++++++++++++++++ ...toSpyBeanByNameLookupIntegrationTests.java | 21 +++++++++++ ...toSpyBeanByTypeLookupIntegrationTests.java | 37 +++++++++++++++++++ 15 files changed, 250 insertions(+), 53 deletions(-) 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 bb00980aa2d..9b4a0b4cf30 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 @@ -88,14 +88,20 @@ To avoid such undesired side effects, consider using [NOTE] ==== -Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean -will result in an exception. +When using `@MockitoBean` to mock a non-singleton bean, the non-singleton bean will be +replaced with a singleton mock, and the corresponding bean definition will be converted +to a `singleton`. Consequently, if you mock a `prototype` or scoped bean, the mock will +be treated as a `singleton`. + +Similarly, when using `@MockitoSpyBean` to create a spy for a non-singleton bean, the +corresponding bean definition will be converted to a `singleton`. Consequently, if you +create a spy for a `prototype` or scoped bean, the spy will be treated as a `singleton`. When using `@MockitoBean` to mock a bean created by a `FactoryBean`, the `FactoryBean` will be replaced with a singleton mock of the type of object created by the `FactoryBean`. -When using `@MockitoSpyBean` to create a spy for a `FactoryBean`, a spy will be created -for the object created by the `FactoryBean`, not for the `FactoryBean` itself. +Similarly, when using `@MockitoSpyBean` to create a spy for a `FactoryBean`, a spy will +be created for the object created by the `FactoryBean`, not for the `FactoryBean` itself. ==== [NOTE] diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc index 4ec33c0c154..b752becaaa9 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-testbean.adoc @@ -116,12 +116,15 @@ fully-qualified method name following the syntax `#< – for example, `methodName = "org.example.TestUtils#createCustomService"`. ==== -[TIP] +[NOTE] ==== -Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean -will result in an exception. +When overriding a non-singleton bean, the non-singleton bean will be replaced with a +singleton bean corresponding to the value returned from the `@TestBean` factory method, +and the corresponding bean definition will be converted to a `singleton`. Consequently, +if `@TestBean` is used to override a `prototype` or scoped bean, the overridden bean will +be treated as a `singleton`. -When overriding a bean created by a `FactoryBean`, the `FactoryBean` will be replaced -with a singleton bean corresponding to the value returned from the `@TestBean` factory -method. +Similarly, when overriding a bean created by a `FactoryBean`, the `FactoryBean` will be +replaced with a singleton bean corresponding to the value returned from the `@TestBean` +factory method. ==== 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 a709dd96e43..055b718feaa 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 @@ -62,8 +62,11 @@ defined by the corresponding `BeanOverrideStrategy`: [TIP] ==== -Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean -will result in an exception. +When replacing a non-singleton bean, the non-singleton bean will be replaced with a +singleton bean corresponding to bean override instance created by the applicable +`BeanOverrideHandler`, and the corresponding bean definition will be converted to a +`singleton`. Consequently, if a handler overrides a `prototype` or scoped bean, the +overridden bean will be treated as a `singleton`. When replacing a bean created by a `FactoryBean`, the `FactoryBean` itself will be replaced with a singleton bean corresponding to bean override instance created by the 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 b5084b3b51c..2a609b9ac6b 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 @@ -22,6 +22,8 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; import org.springframework.aop.scope.ScopedProxyUtils; @@ -67,6 +69,8 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, private static final String PSEUDO_BEAN_NAME_PLACEHOLDER = "<<< PSEUDO BEAN NAME PLACEHOLDER >>>"; + private static final Log logger = LogFactory.getLog(BeanOverrideBeanFactoryPostProcessor.class); + private static final BeanNameGenerator beanNameGenerator = DefaultBeanNameGenerator.INSTANCE; private final Set beanOverrideHandlers; @@ -182,10 +186,10 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, } if (existingBeanDefinition != null) { - // Validate the existing bean definition. + // Process the existing bean definition. // // Applies during "JVM runtime", "AOT processing", and "AOT runtime". - validateBeanDefinition(beanFactory, beanName); + convertToSingletonIfNecessary(existingBeanDefinition, beanName); } else if (Boolean.getBoolean(AbstractAotProcessor.AOT_PROCESSING)) { // There was no existing bean definition, but during "AOT processing" we @@ -289,7 +293,8 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, } } - validateBeanDefinition(beanFactory, beanName); + BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); + convertToSingletonIfNecessary(beanDefinition, beanName); this.beanOverrideRegistry.registerBeanOverrideHandler(handler, beanName); } @@ -470,19 +475,20 @@ class BeanOverrideBeanFactoryPostProcessor implements BeanFactoryPostProcessor, } /** - * Validate that the {@link BeanDefinition} for the supplied bean name is suitable - * for being replaced by a bean override. - *

If there is no registered {@code BeanDefinition} for the supplied bean name, - * no validation is performed. + * Convert the supplied {@link BeanDefinition} for the supplied bean name to + * a singleton, if necessary. + * @since 7.0 */ - private static void validateBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) { + private static void convertToSingletonIfNecessary(BeanDefinition beanDefinition, String beanName) { // Due to https://github.com/spring-projects/spring-framework/issues/33800, we do NOT invoke // beanFactory.isSingleton(beanName), since doing so can result in a BeanCreationException for // certain beans -- for example, a Spring Data FactoryBean for a JpaRepository. - if (beanFactory.containsBeanDefinition(beanName)) { - BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); - Assert.state(beanDefinition.isSingleton(), - () -> "Unable to override bean '" + beanName + "': only singleton beans can be overridden."); + if (!beanDefinition.isSingleton()) { + if (logger.isWarnEnabled()) { + logger.warn("Converting '%s' scoped bean definition '%s' to a singleton." + .formatted(beanDefinition.getScope(), beanName)); + } + beanDefinition.setScope(BeanDefinition.SCOPE_SINGLETON); } } 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 80d34cb6f6c..3b92f19bc1f 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 @@ -64,13 +64,29 @@ import static org.springframework.core.annotation.MergedAnnotations.SearchStrate * creation} — for example, based on further processing of the annotation, * the annotated field, or the annotated class. * - *

NOTE: Only singleton beans can be overridden. - * Any attempt to override a non-singleton bean will result in an exception. + *

Singleton Semantics

+ * + *

When replacing a non-singleton bean, the non-singleton bean will be replaced + * with a singleton bean corresponding to bean override instance created by the + * handler, and the corresponding bean definition will be converted to a singleton. + * Consequently, if a handler overrides a prototype or custom scoped bean, the + * overridden bean will be treated as a singleton. + * + *

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. + * + *

When wrapping a bean created by a + * {@link org.springframework.beans.factory.FactoryBean FactoryBean}, the object + * created by the {@code FactoryBean} will be wrapped, not the {@code FactoryBean} + * itself. * * @author Simon Baslé * @author Stephane Nicoll * @author Sam Brannen * @since 6.2 + * @see BeanOverrideStrategy */ public abstract class BeanOverrideHandler { diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java index 68cc389f9ab..af707bdd63f 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/convention/TestBean.java @@ -109,11 +109,14 @@ import org.springframework.test.context.bean.override.BeanOverride; * See the Javadoc for {@link org.springframework.test.context.ContextHierarchy @ContextHierarchy} * for further details and examples. * - *

NOTE: Only singleton beans can be overridden. - * Any attempt to override a non-singleton bean will result in an exception. When - * overriding a bean created by a {@link org.springframework.beans.factory.FactoryBean - * FactoryBean}, the {@code FactoryBean} will be replaced with a singleton bean - * corresponding to the value returned from the {@code @TestBean} factory method. + *

NOTE: When overriding a non-singleton bean, the non-singleton + * bean will be replaced with a singleton bean corresponding to the value returned + * from the {@code @TestBean} factory method, and the corresponding bean definition + * will be converted to a singleton. Consequently, if you override a prototype or + * scoped bean, it will be treated as a singleton. Similarly, when overriding a bean + * created by a {@link org.springframework.beans.factory.FactoryBean FactoryBean}, + * the {@code FactoryBean} will be replaced with a singleton bean corresponding to + * the value returned from the {@code @TestBean} factory method. * *

There are no restrictions on the visibility of {@code @TestBean} fields or * factory methods. Such fields and methods can therefore be {@code public}, 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 301c0ba69e3..b7563a439ce 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 @@ -84,11 +84,13 @@ import org.springframework.test.context.bean.override.BeanOverride; * See the Javadoc for {@link org.springframework.test.context.ContextHierarchy @ContextHierarchy} * for further details and examples. * - *

NOTE: Only singleton beans can be mocked. - * Any attempt to mock a non-singleton bean will result in an exception. When - * mocking a bean created by a {@link org.springframework.beans.factory.FactoryBean - * FactoryBean}, the {@code FactoryBean} will be replaced with a singleton mock - * of the type of object created by the {@code FactoryBean}. + *

NOTE: When mocking a non-singleton bean, the non-singleton + * bean will be replaced with a singleton mock, and the corresponding bean definition + * will be converted to a singleton. Consequently, if you mock a prototype or scoped + * bean, the mock will be treated as a singleton. Similarly, when mocking a bean + * created by a {@link org.springframework.beans.factory.FactoryBean FactoryBean}, + * the {@code FactoryBean} will be replaced with a singleton mock of the type of + * object created by the {@code FactoryBean}. * *

There are no restrictions on the visibility of a {@code @MockitoBean} field. * Such fields can therefore be {@code public}, {@code protected}, package-private 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 6f4e3276afd..db885b12591 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 @@ -86,11 +86,13 @@ import org.springframework.test.context.bean.override.BeanOverride; * See the Javadoc for {@link org.springframework.test.context.ContextHierarchy @ContextHierarchy} * for further details and examples. * - *

NOTE: Only singleton beans can be spied. Any attempt - * to create a spy for a non-singleton bean will result in an exception. When - * creating a spy for a {@link org.springframework.beans.factory.FactoryBean FactoryBean}, - * a spy will be created for the object created by the {@code FactoryBean}, not - * for the {@code FactoryBean} itself. + *

NOTE: When creating a spy for a non-singleton bean, the + * corresponding bean definition will be converted to a singleton. Consequently, + * if you create a spy for a prototype or scoped bean, the spy will be treated as + * a singleton. Similarly, when creating a spy for a + * {@link org.springframework.beans.factory.FactoryBean FactoryBean}, a spy will + * be created for the object created by the {@code FactoryBean}, not for the + * {@code FactoryBean} itself. * *

There are no restrictions on the visibility of a {@code @MockitoSpyBean} field. * Such fields can therefore be {@code public}, {@code protected}, package-private diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java index 2a1a95de3a2..4a1dd92774f 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/BeanOverrideBeanFactoryPostProcessorTests.java @@ -321,7 +321,7 @@ class BeanOverrideBeanFactoryPostProcessorTests { } @Test - void replaceBeanByNameWithMatchingBeanDefinitionWithPrototypeScopeFails() { + void replaceBeanByNameWithMatchingBeanDefinitionWithPrototypeScope() { String beanName = "descriptionBean"; AnnotationConfigApplicationContext context = createContext(ByNameTestCase.class); @@ -329,13 +329,13 @@ class BeanOverrideBeanFactoryPostProcessorTests { definition.setScope(BeanDefinition.SCOPE_PROTOTYPE); context.registerBeanDefinition(beanName, definition); - assertThatIllegalStateException() - .isThrownBy(context::refresh) - .withMessage("Unable to override bean 'descriptionBean': only singleton beans can be overridden."); + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName, String.class)).isEqualTo("overridden"); } @Test - void replaceBeanByNameWithMatchingBeanDefinitionWithCustomScopeFails() { + void replaceBeanByNameWithMatchingBeanDefinitionWithCustomScope() { String beanName = "descriptionBean"; String scope = "customScope"; @@ -346,22 +346,22 @@ class BeanOverrideBeanFactoryPostProcessorTests { definition.setScope(scope); context.registerBeanDefinition(beanName, definition); - assertThatIllegalStateException() - .isThrownBy(context::refresh) - .withMessage("Unable to override bean 'descriptionBean': only singleton beans can be overridden."); + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName, String.class)).isEqualTo("overridden"); } @Test - void replaceBeanByNameWithMatchingBeanDefinitionForPrototypeScopedFactoryBeanFails() { + void replaceBeanByNameWithMatchingBeanDefinitionForPrototypeScopedFactoryBean() { String beanName = "messageServiceBean"; AnnotationConfigApplicationContext context = createContext(MessageServiceTestCase.class); RootBeanDefinition factoryBeanDefinition = new RootBeanDefinition(SingletonMessageServiceFactoryBean.class); factoryBeanDefinition.setScope(BeanDefinition.SCOPE_PROTOTYPE); context.registerBeanDefinition(beanName, factoryBeanDefinition); - assertThatIllegalStateException() - .isThrownBy(context::refresh) - .withMessage("Unable to override bean 'messageServiceBean': only singleton beans can be overridden."); + assertThatNoException().isThrownBy(context::refresh); + assertThat(context.isSingleton(beanName)).as("isSingleton").isTrue(); + assertThat(context.getBean(beanName, MessageService.class).getMessage()).isEqualTo("overridden"); } @Test diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByNameLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByNameLookupIntegrationTests.java index 99192e9fcdd..0a0699d0581 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByNameLookupIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByNameLookupIntegrationTests.java @@ -21,8 +21,10 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; @@ -43,6 +45,10 @@ public class TestBeanByNameLookupIntegrationTests { @TestBean(name = "methodRenamed1", methodName = "field") String methodRenamed1; + @TestBean("prototypeScoped") + String prototypeScoped; + + static String field() { return "fieldOverride"; } @@ -51,6 +57,11 @@ public class TestBeanByNameLookupIntegrationTests { return "nestedFieldOverride"; } + static String prototypeScoped() { + return "prototypeScopedOverride"; + } + + @Test void fieldHasOverride(ApplicationContext ctx) { assertThat(ctx.getBean("field")).as("applicationContext").isEqualTo("fieldOverride"); @@ -63,6 +74,13 @@ public class TestBeanByNameLookupIntegrationTests { assertThat(methodRenamed1).as("injection point").isEqualTo("fieldOverride"); } + @Test + void fieldForPrototypeHasOverride(ConfigurableApplicationContext ctx) { + assertThat(ctx.getBeanFactory().getBeanDefinition("prototypeScoped").isSingleton()).as("isSingleton").isTrue(); + assertThat(ctx.getBean("prototypeScoped")).as("applicationContext").isEqualTo("prototypeScopedOverride"); + assertThat(prototypeScoped).as("injection point").isEqualTo("prototypeScopedOverride"); + } + @Nested @DisplayName("With @TestBean in enclosing class and in @Nested class") @@ -180,6 +198,12 @@ public class TestBeanByNameLookupIntegrationTests { String bean4() { return "NestedProd"; } + + @Bean("prototypeScoped") + @Scope("prototype") + String bean5() { + return "PrototypeProd"; + } } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeLookupIntegrationTests.java index 790ec27eea1..6a0af4705f3 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeLookupIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/convention/TestBeanByTypeLookupIntegrationTests.java @@ -20,8 +20,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; 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; @@ -53,6 +55,9 @@ public class TestBeanByTypeLookupIntegrationTests { @CustomQualifier StringBuilder anyNameForStringBuilder2; + @TestBean + Number prototypeNumber; + static MessageService messageService() { return () -> "mocked nonexistent bean definition"; @@ -70,6 +75,9 @@ public class TestBeanByTypeLookupIntegrationTests { return new StringBuilder("CustomQualifier TestBean String"); } + static Number prototypeNumber() { + return 42; + } @Test void overrideIsFoundByTypeForNonexistentBeanDefinition(ApplicationContext ctx) { @@ -101,6 +109,13 @@ public class TestBeanByTypeLookupIntegrationTests { assertThat(ctx.getBean("one")).as("no qualifier needed").hasToString("Prod One"); } + @Test + void overrideIsFoundByTypeForPrototypeBeanDefinition(ConfigurableApplicationContext ctx) { + assertThat(ctx.getBeanFactory().getBeanDefinition("prototypeNumber").isSingleton()).as("isSingleton").isTrue(); + assertThat(this.prototypeNumber).isSameAs(ctx.getBean(Number.class)); + assertThat(this.prototypeNumber).isEqualTo(42); + } + @Configuration(proxyBeanMethods = false) static class Config { @@ -126,6 +141,12 @@ public class TestBeanByTypeLookupIntegrationTests { StringBuilder beanString3() { return new StringBuilder("Prod Three"); } + + @Bean + @Scope("prototype") + Number prototypeNumber() { + return -999; + } } @FunctionalInterface diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByNameLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByNameLookupIntegrationTests.java index 2fa39d4bbec..277e7f1ea5a 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByNameLookupIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByNameLookupIntegrationTests.java @@ -23,8 +23,10 @@ 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.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.test.context.bean.override.example.ExampleService; import org.springframework.test.context.bean.override.example.RealExampleService; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -48,6 +50,9 @@ public class MockitoBeanByNameLookupIntegrationTests { @MockitoBean("nonExistingBean") ExampleService nonExisting; + @MockitoBean("prototypeScoped") + ExampleService prototypeScoped; + @Test void fieldAndRenamedFieldHaveSameOverride(ApplicationContext ctx) { @@ -69,6 +74,17 @@ public class MockitoBeanByNameLookupIntegrationTests { assertThat(nonExisting.greeting()).as("mocked greeting").isNull(); } + @Test + void fieldForPrototypeHasOverride(ConfigurableApplicationContext ctx) { + assertThat(ctx.getBean("prototypeScoped")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(prototypeScoped); + assertThat(ctx.getBeanFactory().getBeanDefinition("prototypeScoped").isSingleton()).as("isSingleton").isTrue(); + + assertThat(prototypeScoped.greeting()).as("mocked greeting").isNull(); + } + @Nested @DisplayName("With @MockitoBean in enclosing class and in @Nested class") @@ -139,6 +155,12 @@ public class MockitoBeanByNameLookupIntegrationTests { ExampleService bean2() { return new RealExampleService("Hello Nested Field"); } + + @Bean("prototypeScoped") + @Scope("prototype") + ExampleService bean3() { + return new RealExampleService("Hello Prototype Field"); + } } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupIntegrationTests.java index 1f3be03c2a4..1dcb04c3d25 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanByTypeLookupIntegrationTests.java @@ -21,8 +21,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; import org.springframework.test.context.bean.override.example.CustomQualifier; import org.springframework.test.context.bean.override.example.ExampleService; @@ -63,6 +65,10 @@ public class MockitoBeanByTypeLookupIntegrationTests { @CustomQualifier StringBuilder ambiguousMeta; + @MockitoBean + YetAnotherService yetAnotherService; + + @Test void mockIsCreatedWhenNoCandidateIsFound() { assertIsMock(this.serviceIsNotABean); @@ -122,11 +128,30 @@ public class MockitoBeanByTypeLookupIntegrationTests { verifyNoMoreInteractions(this.ambiguousMeta); } + @Test + void overrideIsFoundByTypeForPrototype(ConfigurableApplicationContext ctx) { + assertThat(this.yetAnotherService) + .satisfies(MockitoAssertions::assertIsMock) + .isSameAs(ctx.getBean("YAS")) + .isSameAs(ctx.getBean(YetAnotherService.class)); + assertThat(ctx.getBeanFactory().getBeanDefinition("YAS").isSingleton()).as("isSingleton").isTrue(); + + when(this.yetAnotherService.hello()).thenReturn("Mocked greeting"); + + assertThat(this.yetAnotherService.hello()).isEqualTo("Mocked greeting"); + verify(this.yetAnotherService, times(1)).hello(); + verifyNoMoreInteractions(this.yetAnotherService); + } + public interface AnotherService { String hello(); + } + public interface YetAnotherService { + + String hello(); } @Configuration(proxyBeanMethods = false) @@ -150,6 +175,12 @@ public class MockitoBeanByTypeLookupIntegrationTests { StringBuilder bean3() { return new StringBuilder("bean3"); } + + @Bean("YAS") + @Scope("prototype") + YetAnotherService bean4() { + return () -> "Production Hello"; + } } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByNameLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByNameLookupIntegrationTests.java index 9c79e593318..68ce463434b 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByNameLookupIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByNameLookupIntegrationTests.java @@ -23,8 +23,10 @@ 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.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; 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.MockitoSpyBeanByNameLookupIntegrationTests.Config; @@ -46,6 +48,9 @@ public class MockitoSpyBeanByNameLookupIntegrationTests { @MockitoSpyBean("field1") ExampleService field; + @MockitoSpyBean("field3") + ExampleService prototypeScoped; + @Test void fieldHasOverride(ApplicationContext ctx) { @@ -57,6 +62,16 @@ public class MockitoSpyBeanByNameLookupIntegrationTests { assertThat(field.greeting()).isEqualTo("bean1"); } + @Test + void fieldForPrototypeHasOverride(ConfigurableApplicationContext ctx) { + assertThat(ctx.getBean("field3")) + .isInstanceOf(ExampleService.class) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(prototypeScoped); + assertThat(ctx.getBeanFactory().getBeanDefinition("field3").isSingleton()).as("isSingleton").isTrue(); + + assertThat(prototypeScoped.greeting()).isEqualTo("bean3"); + } @Nested @DisplayName("With @MockitoSpyBean in enclosing class and in @Nested class") @@ -102,6 +117,12 @@ public class MockitoSpyBeanByNameLookupIntegrationTests { ExampleService bean2() { return new RealExampleService("bean2"); } + + @Bean("field3") + @Scope("prototype") + ExampleService bean3() { + return new RealExampleService("bean3"); + } } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByTypeLookupIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByTypeLookupIntegrationTests.java index 8da2f3ba737..7353d3f0fbc 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByTypeLookupIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanByTypeLookupIntegrationTests.java @@ -20,8 +20,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; import org.springframework.test.context.bean.override.example.CustomQualifier; import org.springframework.test.context.bean.override.example.ExampleService; @@ -55,6 +57,9 @@ public class MockitoSpyBeanByTypeLookupIntegrationTests { @CustomQualifier StringHolder ambiguousMeta; + @MockitoSpyBean + AnotherService prototypeService; + @Test void overrideIsFoundByType(ApplicationContext ctx) { @@ -102,6 +107,19 @@ public class MockitoSpyBeanByTypeLookupIntegrationTests { verifyNoMoreInteractions(this.ambiguousMeta); } + @Test + void overrideIsFoundByTypeForPrototype(ConfigurableApplicationContext ctx) { + assertThat(this.prototypeService) + .satisfies(MockitoAssertions::assertIsSpy) + .isSameAs(ctx.getBean("anotherService")) + .isSameAs(ctx.getBean(AnotherService.class)); + assertThat(ctx.getBeanFactory().getBeanDefinition("anotherService").isSingleton()).as("isSingleton").isTrue(); + + assertThat(this.prototypeService.hello()).isEqualTo("Production Hello"); + verify(this.prototypeService).hello(); + verifyNoMoreInteractions(this.prototypeService); + } + @Configuration(proxyBeanMethods = false) static class Config { @@ -124,6 +142,12 @@ public class MockitoSpyBeanByTypeLookupIntegrationTests { StringHolder bean3() { return new StringHolder("bean3"); } + + @Bean("anotherService") + @Scope("prototype") + AnotherService bean4() { + return new DefaultAnotherService("Production Hello"); + } } static class StringHolder { @@ -143,4 +167,17 @@ public class MockitoSpyBeanByTypeLookupIntegrationTests { } } + public interface AnotherService { + + String hello(); + } + + record DefaultAnotherService(String message) implements AnotherService { + + @Override + public String hello() { + return this.message; + } + } + }