diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanAndAopProxyTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanAndAopProxyTests.java new file mode 100644 index 0000000000..73822cbc1a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoBeanAndAopProxyTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.bean.override.mockito; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +import org.springframework.aop.support.AopUtils; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheResolver; +import org.springframework.cache.interceptor.SimpleCacheResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link MockitoBean @MockitoBean} used in combination with Spring AOP. + * + * @author Phillip Webb + * @author Sam Brannen + * @since 6.2 + * @see 5837 + * @see MockitoSpyBeanAndAopProxyTests + */ +@ExtendWith(SpringExtension.class) +class MockitoBeanAndAopProxyTests { + + @MockitoBean + DateService dateService; + + + /** + * Since we always register a manual singleton for {@code @MockitoBean} mocks, + * Spring AOP should never be applied to the mock. + * + *
For this concrete use case, the mocked {@link DateService} will not + * have {@link Cacheable @Cacheable} applied to it. + */ + @RepeatedTest(2) + void mockShouldNotBeAnAopProxy() { + assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isFalse(); + assertThat(Mockito.mockingDetails(dateService).isMock()).as("is Mockito mock").isTrue(); + + given(dateService.getDate(false)).willReturn(1L); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + given(dateService.getDate(false)).willReturn(2L); + date = dateService.getDate(false); + assertThat(date).isEqualTo(2L); + + verify(dateService, times(2)).getDate(false); + verify(dateService, times(2)).getDate(eq(false)); + verify(dateService, times(2)).getDate(anyBoolean()); + } + + + @Configuration(proxyBeanMethods = false) + @EnableCaching(proxyTargetClass = true) + @Import(DateService.class) + static class Config { + + @Bean + CacheResolver cacheResolver(CacheManager cacheManager) { + return new SimpleCacheResolver(cacheManager); + } + + @Bean + ConcurrentMapCacheManager cacheManager() { + return new ConcurrentMapCacheManager("test"); + } + } + + @Service + static class DateService { + + @Cacheable("test") + Long getDate(boolean argument) { + return System.nanoTime(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndAopProxyTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndAopProxyTests.java new file mode 100644 index 0000000000..e8fd30798e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndAopProxyTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.bean.override.mockito; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +import org.springframework.aop.support.AopUtils; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheResolver; +import org.springframework.cache.interceptor.SimpleCacheResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.AopTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link MockitoSpyBean @MockitoSpyBean} used in combination with Spring AOP. + * + * @author Sam Brannen + * @author Phillip Webb + * @since 6.2 + * @see 5837 + * @see MockitoBeanAndAopProxyTests + */ +@ExtendWith(SpringExtension.class) +class MockitoSpyBeanAndAopProxyTests { + + @MockitoSpyBean + DateService dateService; + + + /** + * Stubbing and verification for a Mockito spy that is wrapped in a Spring AOP + * proxy should always work when performed via the ultimate target of the Spring + * AOP proxy (i.e., the actual spy instance). + */ + @Test + void stubAndVerifyOnUltimateTargetOfSpringAopProxy() { + assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isTrue(); + DateService spy = AopTestUtils.getUltimateTargetObject(dateService); + assertThat(Mockito.mockingDetails(spy).isSpy()).as("ultimate target is Mockito spy").isTrue(); + + given(spy.getDate(false)).willReturn(1L); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + given(spy.getDate(false)).willReturn(2L); + date = dateService.getDate(false); + assertThat(date).isEqualTo(1L); // 1L instead of 2L, because the AOP proxy caches the original value. + + // Each of the following verifies times(1), because the AOP proxy caches the + // original value and does not delegate to the spy on subsequent invocations. + verify(spy, times(1)).getDate(false); + verify(spy, times(1)).getDate(eq(false)); + verify(spy, times(1)).getDate(anyBoolean()); + } + + /** + * Verification for a Mockito spy that is wrapped in a Spring AOP proxy should + * always work when performed via the Spring AOP proxy. However, stubbing + * does not currently work via the Spring AOP proxy. + * + *
Consequently, this test method supplies the ultimate target of the Spring + * AOP proxy to stubbing calls, while supplying the Spring AOP proxy to verification + * calls. + */ + @Disabled("Disabled until transparent verification for @MockitoSpyBean is implemented") + @Test + void stubOnUltimateTargetAndVerifyOnSpringAopProxy() { + assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isTrue(); + DateService spy = AopTestUtils.getUltimateTargetObject(dateService); + assertThat(Mockito.mockingDetails(spy).isSpy()).as("ultimate target is Mockito spy").isTrue(); + + given(spy.getDate(false)).willReturn(1L); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + given(spy.getDate(false)).willReturn(2L); + date = dateService.getDate(false); + assertThat(date).isEqualTo(1L); // 1L instead of 2L, because the AOP proxy caches the original value. + + // Each of the following verifies times(1), because the AOP proxy caches the + // original value and does not delegate to the spy on subsequent invocations. + verify(dateService, times(1)).getDate(false); + verify(dateService, times(1)).getDate(eq(false)); + verify(dateService, times(1)).getDate(anyBoolean()); + } + + /** + * Ideally, both stubbing and verification should work transparently when a Mockito + * spy is wrapped in a Spring AOP proxy. However, Mockito currently does not provide + * support for transparent stubbing of a proxied spy. For example, implementing a + * custom {@link org.mockito.plugins.MockResolver} will not result in successful + * stubbing for a proxied mock. + */ + @Disabled("Disabled until Mockito provides support for transparent stubbing of a proxied spy") + @Test + void stubAndVerifyDirectlyOnSpringAopProxy() throws Exception { + assertThat(AopUtils.isCglibProxy(dateService)).as("is Spring AOP CGLIB proxy").isTrue(); + assertThat(Mockito.mockingDetails(dateService).isSpy()).as("is Mockito spy").isTrue(); + + doReturn(1L).when(dateService).getDate(false); + Long date = dateService.getDate(false); + assertThat(date).isOne(); + + doReturn(2L).when(dateService).getDate(false); + date = dateService.getDate(false); + assertThat(date).isEqualTo(1L); // 1L instead of 2L, because the AOP proxy caches the original value. + + // Each of the following verifies times(1), because the AOP proxy caches the + // original value and does not delegate to the spy on subsequent invocations. + verify(dateService, times(1)).getDate(false); + verify(dateService, times(1)).getDate(eq(false)); + verify(dateService, times(1)).getDate(anyBoolean()); + } + + + @Configuration(proxyBeanMethods = false) + @EnableCaching(proxyTargetClass = true) + @Import(DateService.class) + static class Config { + + @Bean + CacheResolver cacheResolver(CacheManager cacheManager) { + return new SimpleCacheResolver(cacheManager); + } + + @Bean + ConcurrentMapCacheManager cacheManager() { + return new ConcurrentMapCacheManager("test"); + } + } + + @Service + static class DateService { + + @Cacheable("test") + Long getDate(boolean argument) { + return System.nanoTime(); + } + } + +}