Support Bean Overrides for non-singletons

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
This commit is contained in:
Sam Brannen
2025-10-07 15:33:18 +02:00
parent ff9a349271
commit 30db2e4fb5
15 changed files with 250 additions and 53 deletions
@@ -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
@@ -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";
}
}
}
@@ -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
@@ -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");
}
}
}
@@ -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";
}
}
}
@@ -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");
}
}
}
@@ -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;
}
}
}