Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@MockitoSpyBean requires existing bean instance to spy on #33935

Open
quaff opened this issue Nov 22, 2024 · 4 comments
Open

@MockitoSpyBean requires existing bean instance to spy on #33935

quaff opened this issue Nov 22, 2024 · 4 comments
Assignees
Labels
in: test Issues in the test module status: waiting-for-triage An issue we've not yet triaged or decided on

Comments

@quaff
Copy link
Contributor

quaff commented Nov 22, 2024

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.

@quaff quaff changed the title @MockitoSpyBean is not equivalent to boot‘s @SpyBean @MockitoSpyBean is not equivalent to boot's @SpyBean Nov 22, 2024
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Nov 22, 2024
@sbrannen sbrannen self-assigned this Nov 23, 2024
@sbrannen sbrannen added the in: test Issues in the test module label Nov 23, 2024
@sbrannen sbrannen changed the title @MockitoSpyBean is not equivalent to boot's @SpyBean @MockitoSpyBean cannot spy concrete runtime bean type Nov 25, 2024
@sbrannen sbrannen changed the title @MockitoSpyBean cannot spy concrete runtime bean type @MockitoSpyBean cannot create spy for concrete runtime bean type Nov 25, 2024
@sbrannen
Copy link
Member

sbrannen commented Nov 25, 2024

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.

@sbrannen sbrannen changed the title @MockitoSpyBean cannot create spy for concrete runtime bean type @MockitoSpyBean requires existing bean instance to spy on Nov 25, 2024
@sbrannen
Copy link
Member

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.

@tobias-lippert
Copy link

@quaff related issue that I opened in the Spring Boot project and that was closed as it's desired behavior: spring-projects/spring-boot#43245

@quaff
Copy link
Contributor Author

quaff commented Nov 26, 2024

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: test Issues in the test module status: waiting-for-triage An issue we've not yet triaged or decided on
Projects
None yet
Development

No branches or pull requests

4 participants