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 b0ad7707ff3..c06c87bca01 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 @@ -68,12 +68,21 @@ The `@MockitoBean` annotation uses the `REPLACE_OR_CREATE` xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-strategy[strategy for bean overrides]. If a corresponding bean does not exist, a new bean will be created. However, you can switch to the `REPLACE` strategy by setting the `enforceOverride` attribute to `true` – -for example, `@MockitoBean(enforceOverride = true)`. +for example, `@MockitoBean(enforceOverride = true)`. Because this strategy replaces the +bean directly, bypassing the container's normal bean post-processing, the resulting mock +is a bare object: it is never wrapped in a Spring AOP proxy, even if the original bean +would have been — for example, due to `@Transactional`, `@Cacheable`, or `@Retryable`. See +xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-aop-proxies[Bean +Overrides and Spring AOP Proxies] for details. The `@MockitoSpyBean` annotation uses the `WRAP` xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-strategy[strategy], and the original instance is wrapped in a Mockito spy. This strategy requires that -exactly one candidate bean exists. +exactly one candidate bean exists. In contrast to `@MockitoBean`, if the original bean +would have been wrapped in a Spring AOP proxy, that proxy is still created around the spy. +See +<> +for the consequences this has for stubbing and verification. [TIP] ==== @@ -468,3 +477,284 @@ Kotlin:: TIP: The spies can also be injected into `@Configuration` classes or other test-related components in the `ApplicationContext` in order to configure them with Mockito's stubbing APIs. + + +[[spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies]] +== `@MockitoSpyBean` and Spring AOP Proxies + +As explained in +xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-aop-proxies[Bean +Overrides and Spring AOP Proxies], if the bean being spied on would normally be wrapped in +a Spring AOP proxy — for example, due to `@Transactional`, `@Cacheable`, or `@Retryable` +— that proxy is still created, with the spy as its target. The bean injected into the +test class and into other beans in the `ApplicationContext` is therefore the proxy, not +the spy itself. + +Verification via Mockito's `verify()` API is unaffected by this and works transparently, +regardless of whether it is invoked on the proxy or on the underlying spy. + +[[spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies-stubbing]] +=== Stubbing Through the Proxy + +Stubbing requires more care than verification, since `Mockito.doReturn(...).when(...)`, +`Mockito.doThrow(...).when(...)`, and similar methods behave differently depending on the +nature of the AOP advice involved when invoked on the proxy. + +NOTE: Since `when` is a reserved keyword in Kotlin, the Kotlin examples below use the +`given(...)`, `willReturn(...)`, and `willThrow(...)` methods from `BDDMockito` instead +of `Mockito.doReturn(...).when(...)` and `Mockito.doThrow(...).when(...)`. + +Advice that does not retain state between invocations — such as +xref:core/resilience.adoc#resilience-annotations-retryable[`@Retryable`] — has no adverse +effect on stubbing. The following stubbing sequence, invoked on the proxy, behaves exactly +as it would on the underlying spy directly, including triggering a retry when the thrown +exception is encountered. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + doReturn("ok") + .doThrow(new RuntimeException("Message delivery failed")) + .doReturn("ok again") + .when(clientService).sendMessage(any()); // <1> +---- +<1> `clientService` is the injected proxy. Since `@Retryable` advice is a stateless + pass-through, each call — including the one that throws — reaches the spy directly. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + willReturn("ok") + .willThrow(RuntimeException("Message delivery failed")) + .willReturn("ok again") + .given(clientService).sendMessage(any()) // <1> +---- +<1> `clientService` is the injected proxy. Since `@Retryable` advice is a stateless + pass-through, each call — including the one that throws — reaches the spy directly. +====== + +Advice that caches or otherwise memoizes the outcome of an invocation — such as +`@Cacheable` — does not behave the same way. While a `doReturn(...)`, `doThrow(...)`, or +similar declaration is being recorded, Mockito does not invoke the spy's real or +previously stubbed behavior; instead, the invocation used to declare the stubbing returns +an empty value (for example, `null`). If that invocation is made on the proxy, the caching +advice caches this empty value, which then permanently shadows the spy for that +combination of arguments — including for the very invocation that was supposed to +configure the stubbing. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + doReturn(1L).when(dateService).getDate(false); // <1> + dateService.getDate(false); // <2> +---- +<1> `dateService` is the injected proxy. This invocation is intercepted by Mockito's + stubbing infrastructure before it reaches the spy, so the caching advice ends up + caching an empty value for argument `false`. +<2> Returns the empty value cached by the previous invocation — not `1L` — because the + cache was already populated. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + willReturn(1L).given(dateService).getDate(false) // <1> + dateService.getDate(false) // <2> +---- +<1> `dateService` is the injected proxy. This invocation is intercepted by Mockito's + stubbing infrastructure before it reaches the spy, so the caching advice ends up + caching an empty value for argument `false`. +<2> Returns the empty value cached by the previous invocation — not `1L` — because the + cache was already populated. +====== + +To avoid this, stub directly on the spy instead of on the proxy, by unwrapping the proxy +with +{spring-framework-api}/test/util/AopTestUtils.html#getUltimateTargetObject(java.lang.Object)[`AopTestUtils.getUltimateTargetObject(...)`]. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + DateService spy = AopTestUtils.getUltimateTargetObject(dateService); + doReturn(1L).when(spy).getDate(false); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + val spy = AopTestUtils.getUltimateTargetObject(dateService) + willReturn(1L).given(spy).getDate(false) +---- +====== + +[[spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies-disabling]] +=== Disabling AOP Advice for Tests + +Rather than working around the proxy as shown above, you may instead prefer to disable +the underlying AOP advice for the duration of the test, while keeping `@Retryable`, +`@Cacheable`, or similar annotations in place in production code. Common reasons include +avoiding retry delays that slow down the test suite, or avoiding caching altogether so +that every invocation reaches the spy directly — which also sidesteps the stubbing +pitfall described above, without having to unwrap the proxy at all. + +The general technique is to externalize whatever controls the advice's effective behavior +— for example, the number of retry attempts or the `CacheManager` backing `@Cacheable` +— and override that configuration for tests only, typically by using a bean override or a +test-specific property. The proxy and its advice are still created, but their behavior is +simply made a no-op or pure pass-through for the test. + +For `@Retryable`, bind the `maxRetriesString` attribute to a property placeholder with a +sensible default (so that production configuration is unaffected if the property is not +set), and override that property in the test with +xref:testing/annotations/integration-spring/annotation-testpropertysource.adoc[`@TestPropertySource`] +so that no retries are attempted. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Retryable(maxRetriesString = "${sendMessage.maxRetries:3}", delay = 10) + public String sendMessage(String request) { + // ... + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Retryable(maxRetriesString = "\${sendMessage.maxRetries:3}", delay = 10) + fun sendMessage(request: String): String { + // ... + } +---- +====== + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig + @TestPropertySource(properties = "sendMessage.maxRetries = 0") // <1> + class ClientServiceTests { + + @MockitoSpyBean + ClientService clientService; + + // test case body... + } +---- +<1> With no retries permitted, the first (and only) attempt is made, and a thrown + exception propagates immediately, so the spy's stubbing chain behaves exactly as + declared, including for `doThrow(...)` answers. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig + @TestPropertySource(properties = ["sendMessage.maxRetries = 0"]) // <1> + class ClientServiceTests { + + @MockitoSpyBean + lateinit var clientService: ClientService + + // test case body... + } +---- +<1> With no retries permitted, the first (and only) attempt is made, and a thrown + exception propagates immediately, so the spy's stubbing chain behaves exactly as + declared, including for `doThrow(...)` answers. +====== + +For `@Cacheable`, Spring provides +{spring-framework-api}/cache/support/NoOpCacheManager.html[`NoOpCacheManager`] — a +`CacheManager` that accepts cache entries but never actually stores them, so every +invocation results in a cache miss and therefore an invocation of the target method. +Overriding the `CacheManager` bean with a `NoOpCacheManager` — for example, with +xref:testing/annotations/integration-spring/annotation-testbean.adoc[`@TestBean`] — +effectively disables caching for the test without touching the `@Cacheable` annotation in +production code. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig + class DateServiceTests { + + @MockitoSpyBean + DateService dateService; + + @TestBean // <1> + CacheManager cacheManager; + + static CacheManager cacheManager() { // <2> + return new NoOpCacheManager(); + } + + @Test + void test() { + doReturn(1L).when(dateService).getDate(false); + assertThat(dateService.getDate(false)).isEqualTo(1L); + + doReturn(2L).when(dateService).getDate(false); + assertThat(dateService.getDate(false)).isEqualTo(2L); // <3> + } + } +---- +<1> Override the `CacheManager` bean for this test. +<2> Replace it with a `NoOpCacheManager`, so `@Cacheable` never actually caches anything. +<3> No longer masked by a stale cache entry, since every call reaches the spy. + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig + class DateServiceTests { + + @MockitoSpyBean + lateinit var dateService: DateService + + @TestBean // <1> + lateinit var cacheManager: CacheManager + + companion object { + @JvmStatic + fun cacheManager(): CacheManager { // <2> + return NoOpCacheManager() + } + } + + @Test + fun test() { + willReturn(1L).given(dateService).getDate(false) + assertThat(dateService.getDate(false)).isEqualTo(1L) + + willReturn(2L).given(dateService).getDate(false) + assertThat(dateService.getDate(false)).isEqualTo(2L) // <3> + } + } +---- +<1> Override the `CacheManager` bean for this test. +<2> Replace it with a `NoOpCacheManager`, so `@Cacheable` never actually caches anything. +<3> No longer masked by a stale cache entry, since every call reaches the spy. +====== 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 e7192604ef2..b42548ce6da 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 @@ -171,3 +171,15 @@ Similarly, when overriding a bean created by a `FactoryBean`, the `FactoryBean` replaced with a singleton bean corresponding to the value returned from the `@TestBean` factory method. ==== + +[NOTE] +==== +`@TestBean` uses the `REPLACE` or `REPLACE_OR_CREATE` +xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-strategy[strategy +for bean overrides], which registers the value returned from the factory method directly +as the bean, bypassing the container's normal bean post-processing. Consequently, none of +the Spring AOP advice that would otherwise apply to the original bean (for example, +`@Transactional`, `@Cacheable`, or `@Retryable`) is present on the override instance. See +xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-aop-proxies[Bean +Overrides and Spring AOP Proxies] for details. +==== 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 055b718feaa..2dfba927542 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 @@ -90,3 +90,31 @@ Alternatively, the user can directly provide the bean name in the custom annotat `BeanOverrideProcessor` implementations may also internally compute a bean name based on a convention or some other method. ==== + +[[testcontext-bean-overriding-aop-proxies]] +== Bean Overrides and Spring AOP Proxies + +Beans in a Spring `ApplicationContext` are frequently wrapped in an AOP proxy — for +example, to support `@Transactional`, `@Cacheable`, or `@Retryable` semantics. Whether an +overridden bean retains such a proxy depends on the `BeanOverrideStrategy` used to create +the override. + +* Overrides that use the `REPLACE` or `REPLACE_OR_CREATE` strategy (such as `@TestBean` + and `@MockitoBean`) register their override instance directly as a manual singleton, + which bypasses the container's normal bean post-processing. Consequently, the override + instance is a bare object: none of the AOP advice that would otherwise apply to the + original bean (`@Transactional`, `@Cacheable`, `@Retryable`, method security, and so + on) is present. +* Overrides that use the `WRAP` strategy (such as `@MockitoSpyBean`) capture an early + reference to the original bean and wrap it before the rest of the container's + post-processors — including the one responsible for creating AOP proxies — have run. + Consequently, if the original bean would have been proxied, that proxy is still + created, but with the override instance as its target rather than the original bean. + The bean that ends up in the `ApplicationContext`, and that is injected into + collaborating beans and test classes, is therefore a proxy wrapping the override + instance, not the bare override instance itself. + +This distinction has practical consequences when combining bean overrides with Mockito's +stubbing and verification APIs. See +xref:testing/annotations/integration-spring/annotation-mockitobean.adoc#spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies[`@MockitoSpyBean` +and Spring AOP Proxies] for details. diff --git a/framework-docs/modules/ROOT/pages/testing/unit.adoc b/framework-docs/modules/ROOT/pages/testing/unit.adoc index 5645587d64e..6e96deb6da2 100644 --- a/framework-docs/modules/ROOT/pages/testing/unit.adoc +++ b/framework-docs/modules/ROOT/pages/testing/unit.adoc @@ -99,6 +99,11 @@ mock to configure expectations on it and perform verifications. For Spring's cor utilities, see {spring-framework-api}/aop/support/AopUtils.html[`AopUtils`] and {spring-framework-api}/aop/framework/AopProxyUtils.html[`AopProxyUtils`]. +TIP: For guidance on using `AopTestUtils` together with `@MockitoSpyBean` when the spied +bean is wrapped in a Spring AOP proxy, see +xref:testing/annotations/integration-spring/annotation-mockitobean.adoc#spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies[`@MockitoSpyBean` +and Spring AOP Proxies]. + {spring-framework-api}/test/util/ReflectionTestUtils.html[`ReflectionTestUtils`] is a collection of reflection-based utility methods. You can use these methods in testing scenarios where you need to change the value of a constant, set a non-`public` field, 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 7ad92818a36..90ce2bc01fa 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 @@ -100,6 +100,18 @@ import org.springframework.test.context.bean.override.BeanOverride; * @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)}. Any attempt to do so will * fail with an exception. * + *

WARNING: If the original bean would be wrapped in a Spring AOP + * proxy — for example, due to {@code @Transactional}, {@code @Cacheable}, or + * {@code @Retryable} — that proxy is still created around the spy, so the bean + * injected into the {@code ApplicationContext} is the proxy rather than the spy. + * Verification via Mockito's {@code verify()} API is unaffected, but stubbing via + * the proxy is only safe for AOP advice that does not retain state between + * invocations; whereas, advice that caches or otherwise memoizes the outcome of an + * invocation can permanently mask the spy's configured answers. See the + * {@code @MockitoSpyBean} and Spring AOP Proxies section of the Spring Framework + * reference documentation for details. + * *

There are no restrictions on the visibility of a {@code @MockitoSpyBean} field. * Such fields can therefore be {@code public}, {@code protected}, package-private * (default visibility), or {@code private} depending on the needs or coding