Document AOP proxy semantics for Bean Overrides in tests

This commit documents how the Bean Override support in the TestContext
framework interacts with Spring AOP proxies created for annotations
such as @⁠Transactional, @⁠Cacheable, and @⁠Retryable.

The new "Bean Overrides and Spring AOP Proxies" section in the general
Bean Overriding documentation explains that overrides using the WRAP
strategy (such as @⁠MockitoSpyBean) end up as the target of any AOP
proxy subsequently created for the original bean; whereas, overrides
using the REPLACE or REPLACE_OR_CREATE strategy (such as @⁠TestBean,
@⁠MockitoBean) bypass the container's bean post-processing entirely and
therefore carry no AOP advice at all.

The new "@⁠MockitoSpyBean and Spring AOP Proxies" section documents the
resulting stubbing and verification semantics. Verification via
Mockito's verify() API works transparently regardless of whether it is
invoked on the proxy or on the spy. Stubbing via doReturn(...)/doThrow(...)
is safe for stateless advice such as @⁠Retryable, but can silently
corrupt the spy's configured answers for stateful or memoizing advice
such as @⁠Cacheable, since the invocation used to declare a stub is
intercepted by Mockito before it reaches the spy and returns an empty
value that such advice may then cache.
AopTestUtils.getUltimateTargetObject(...) is documented as the way to
stub directly against the spy in that case.

The same section also documents how to disable the AOP advice for a
test altogether while leaving it in place in production code – for
example, binding a @⁠Retryable attribute to a property placeholder
overridden via @⁠TestPropertySource, or replacing the CacheManager with
a NoOpCacheManager via @⁠TestBean.

Brief cross-referencing notes have also been added to the @⁠TestBean
documentation and to the existing AopTestUtils description in the
"General Testing Utilities" section, to avoid duplicating the
explanation across pages.

In addition, the Javadoc for @⁠MockitoSpyBean now contains a concise
WARNING summarizing these AOP proxy implications and linking to the new
reference documentation section for details.

Closes gh-37121
This commit is contained in:
Sam Brannen
2026-08-09 17:46:28 +03:00
parent 957df686c4
commit 8df51ad6cc
5 changed files with 349 additions and 2 deletions
@@ -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]. 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 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` 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` The `@MockitoSpyBean` annotation uses the `WRAP`
xref:testing/testcontext-framework/bean-overriding.adoc#testcontext-bean-overriding-strategy[strategy], 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 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
<<spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies,`@MockitoSpyBean` and Spring AOP Proxies>>
for the consequences this has for stubbing and verification.
[TIP] [TIP]
==== ====
@@ -468,3 +477,284 @@ Kotlin::
TIP: The spies can also be injected into `@Configuration` classes or other test-related 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 components in the `ApplicationContext` in order to configure them with Mockito's stubbing
APIs. 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>(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.
======
@@ -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` replaced with a singleton bean corresponding to the value returned from the `@TestBean`
factory method. 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.
====
@@ -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 `BeanOverrideProcessor` implementations may also internally compute a bean name based on
a convention or some other method. 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.
@@ -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 utilities, see {spring-framework-api}/aop/support/AopUtils.html[`AopUtils`] and
{spring-framework-api}/aop/framework/AopProxyUtils.html[`AopProxyUtils`]. {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 {spring-framework-api}/test/util/ReflectionTestUtils.html[`ReflectionTestUtils`] is a
collection of reflection-based utility methods. You can use these methods in testing 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, scenarios where you need to change the value of a constant, set a non-`public` field,
@@ -100,6 +100,18 @@ import org.springframework.test.context.bean.override.BeanOverride;
* &#64;Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)}. Any attempt to do so will * &#64;Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)}. Any attempt to do so will
* fail with an exception. * fail with an exception.
* *
* <p><strong>WARNING</strong>: If the original bean would be wrapped in a Spring AOP
* proxy &mdash; for example, due to {@code @Transactional}, {@code @Cacheable}, or
* {@code @Retryable} &mdash; 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
* <a href="https://docs.spring.io/spring-framework/reference/testing/annotations/integration-spring/annotation-mockitobean.html#spring-testing-annotation-beanoverriding-mockitospybean-aop-proxies"
* >{@code @MockitoSpyBean} and Spring AOP Proxies</a> section of the Spring Framework
* reference documentation for details.
*
* <p>There are no restrictions on the visibility of a {@code @MockitoSpyBean} field. * <p>There are no restrictions on the visibility of a {@code @MockitoSpyBean} field.
* Such fields can therefore be {@code public}, {@code protected}, package-private * Such fields can therefore be {@code public}, {@code protected}, package-private
* (default visibility), or {@code private} depending on the needs or coding * (default visibility), or {@code private} depending on the needs or coding