Overview
When an existing bean of the required type is not discovered in the bean factory, Spring Boot's @SpyBean
creates a new instance to spy on by instantiating the required type using its default constructor.
Whereas, @MockitoSpyBean
requires that a bean of the required type exists and throws an IllegalStateException
if an appropriate bean cannot be found.
Consequently, @MockitoSpyBean
cannot be used to spy on a bean that does not exist.
Original Description
@MockitoSpyBean
cannot spy concrete runtime bean type, but Spring Boot's @SpyBean
can.
Example
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version 'latest.release'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
package com.example;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@ContextConfiguration
@SpringJUnitConfig
public class SpyBeanTests {
@MockitoSpyBean
// @SpyBean works
// @Autowired works
SubTestService testService;
@Test
void test() {
assertThat(testService.echo("test")).isEqualTo("test");
}
@Configuration
static class Config {
@Bean
TestService testService() {
return new SubTestService();
}
}
static class SubTestService extends TestService {
}
static class TestService {
String echo(String str) {
return str;
}
}
}
Test fails with:
Caused by: java.lang.IllegalStateException: Unable to select a bean to override by wrapping: found 0 bean instances of type com.example.SpyBeanTests$SubTestService (as required by annotated field 'SpyBeanTests.testService')
at org.springframework.test.context.bean.override.BeanOverrideBeanFactoryPostProcessor.wrapBean(BeanOverrideBeanFactoryPostProcessor.java:242)
at org.springframework.test.context.bean.override.BeanOverrideBeanFactoryPostProcessor.registerBeanOverride(BeanOverrideBeanFactoryPostProcessor.java:113)
at org.springframework.test.context.bean.override.BeanOverrideBeanFactoryPostProcessor.postProcessBeanFactory(BeanOverrideBeanFactoryPostProcessor.java:98)
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:363)
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:197)
at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:791)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:609)
at org.springframework.test.context.support.AbstractGenericContextLoader.loadContext(AbstractGenericContextLoader.java:221)
at org.springframework.test.context.support.AbstractGenericContextLoader.loadContext(AbstractGenericContextLoader.java:110)
at org.springframework.test.context.support.AbstractDelegatingSmartContextLoader.loadContext(AbstractDelegatingSmartContextLoader.java:212)
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:225)
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:152)
... 19 more
The workaround is changing the @Bean
method to return the most specific type, SubTestService
in this example.
Comment From: sbrannen
Sooooo, this turned out to be an interesting one! 😎
Your original example is not doing what you think it's doing.
The following modified version of your example fails with @SpyBean
.
@ExtendWith(SpringExtension.class)
class SpyBeanTests {
// @MockitoSpyBean
@SpyBean
SubTestService testService;
@Test
void test() {
MockingDetails mockingDetails = Mockito.mockingDetails(testService);
MockName mockName = mockingDetails.getMockCreationSettings().getMockName();
assertSoftly(softly -> {
softly.assertThat(mockingDetails.isSpy()).as("is spy").isTrue();
softly.assertThat(mockName).as("mock name").hasToString("testService");
softly.assertThat(SubTestService.counter).as("instantiation count").isEqualTo(1);
softly.assertThat(testService.echo("test")).as("message").isEqualTo("@Bean :: test");
});
}
@Configuration
static class Config {
@Bean
TestService testService() {
return new SubTestService("@Bean");
}
}
static class TestService {
private final String prefix;
TestService(String prefix) {
this.prefix = prefix;
}
String echo(String str) {
return prefix + " :: " + str;
}
}
static class SubTestService extends TestService {
static int counter;
SubTestService() {
this("Default constructor");
}
SubTestService(String prefix) {
super(prefix);
counter++;
}
}
}
If you run it, you'll see that there are multiple failures.
Multiple Failures (3 failures)
-- failure 1 --
[mock name]
Expecting actual's toString() to return:
"testService"
but was:
"example.SpyBeanTests$SubTestService#0"
at SpyBeanTests.lambda$0(SpyBeanTests.java:31)
-- failure 2 --
[instantiation count]
expected: 1
but was: 2
at SpyBeanTests.lambda$0(SpyBeanTests.java:32)
-- failure 3 --
[message]
expected: "@Bean :: test"
but was: "Default constructor :: test"
What's happening here is that Spring Boot's @SpyBean
support does not consider the testService
(TestService
) bean to be a match for SubTestService
, and consequently Spring Boot creates a BeanDefinition
for SubTestService
which ends up instantiating a new SubTestService
using the default constructor.
So, you effectively end up with two beans of type SubTestService
, and the Mockito spy that is injected into your test class is not a spy for the SubTestService
created via the @Bean
method.
In other words, neither @SpyBean
nor @MockitoSpyBean
can spy on the testService
bean of type TestService
. But both @SpyBean
and @MockitoSpyBean
will properly spy on the service returned from the @Bean
method if you declare the return type to be SubTestService
.
Please note that returning the most specific type from a @Bean
method is actually a best practice with Spring.
Comment From: sbrannen
In light of the above findings, I have reworded the title of this issue and added a new Overview
section to this issue's description.
Comment From: tobias-lippert
@quaff related issue that I opened in the Spring Boot project and that was closed as it's desired behavior: https://github.com/spring-projects/spring-boot/issues/43245
Comment From: quaff
@sbrannen Thanks for your elaboration, I think @MockitoSpyBean
behaves more correctly, we could improve the exception message to remind developers check this:
Please note that returning the most specific type from a
@Bean
method is actually a best practice with Spring.
EDIT: I created #33965 to improve exception message.
Comment From: sbrannen
In light of the above, I am closing this as:
- Superseded by #33965