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