diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java index d61a1a796d1..4c98bdaa280 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanOverrideHandler.java @@ -30,7 +30,6 @@ import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; import org.springframework.test.context.bean.override.BeanOverrideHandler; import org.springframework.test.context.bean.override.BeanOverrideStrategy; -import org.springframework.test.util.AopTestUtils; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -103,7 +102,7 @@ class MockitoSpyBeanOverrideHandler extends AbstractMockitoBeanOverrideHandler { @Override public void onVerificationStarted(VerificationStartedEvent event) { - event.setMock(AopTestUtils.getUltimateTargetObject(event.getMock())); + event.setMock(SpringMockResolver.getUltimateTargetObject(event.getMock())); } } diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/SpringMockResolver.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/SpringMockResolver.java new file mode 100644 index 00000000000..36cf0094ecb --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/SpringMockResolver.java @@ -0,0 +1,76 @@ +/* + * 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.mockito.plugins.MockResolver; + +import org.springframework.aop.TargetSource; +import org.springframework.aop.framework.Advised; +import org.springframework.aop.support.AopUtils; +import org.springframework.util.Assert; + +/** + * A {@link MockResolver} for testing Spring applications with Mockito. + * + *

Resolves mocks by walking the Spring AOP proxy chain until the target or a + * non-static proxy is found. + * + * @author Sam Brannen + * @author Andy Wilkinson + * @since 6.2 + */ +public class SpringMockResolver implements MockResolver { + + @Override + public Object resolve(Object instance) { + return getUltimateTargetObject(instance); + } + + /** + * This is a modified version of + * {@link org.springframework.test.util.AopTestUtils#getUltimateTargetObject(Object) + * AopTestUtils#getUltimateTargetObject()} which only checks static target + * sources. + * @param the type of the target object + * @param candidate the instance to check (potentially a Spring AOP proxy; + * never {@code null}) + * @return the target object or the {@code candidate} (never {@code null}) + * @throws IllegalStateException if an error occurs while unwrapping a proxy + * @see Advised#getTargetSource() + * @see TargetSource#isStatic() + */ + @SuppressWarnings("unchecked") + static T getUltimateTargetObject(Object candidate) { + Assert.notNull(candidate, "Candidate must not be null"); + try { + if (AopUtils.isAopProxy(candidate) && candidate instanceof Advised advised) { + TargetSource targetSource = advised.getTargetSource(); + if (targetSource.isStatic()) { + Object target = targetSource.getTarget(); + if (target != null) { + return getUltimateTargetObject(target); + } + } + } + } + catch (Throwable ex) { + throw new IllegalStateException("Failed to unwrap proxied object", ex); + } + return (T) candidate; + } + +} diff --git a/spring-test/src/main/resources/mockito-extensions/org.mockito.plugins.MockResolver b/spring-test/src/main/resources/mockito-extensions/org.mockito.plugins.MockResolver new file mode 100644 index 00000000000..d7625958da2 --- /dev/null +++ b/spring-test/src/main/resources/mockito-extensions/org.mockito.plugins.MockResolver @@ -0,0 +1 @@ +org.springframework.test.context.bean.override.mockito.SpringMockResolver diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndSpringAopProxyTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndSpringAopProxyTests.java index 02ecdb541c6..1a880075ddd 100644 --- a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndSpringAopProxyTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBeanAndSpringAopProxyTests.java @@ -16,13 +16,15 @@ package org.springframework.test.context.bean.override.mockito; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; +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.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.concurrent.ConcurrentMapCacheManager; @@ -58,12 +60,21 @@ class MockitoSpyBeanAndSpringAopProxyTests { DateService dateService; + @BeforeEach + void resetCache() { + // We have to clear the "test" cache before each test. Otherwise, method + // invocations on the Spring AOP proxy will never make it to the Mockito spy. + dateService.clearCache(); + } + /** * 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 + // We need to run this test at least twice to ensure the Mockito spy can be reused + // across test method invocations without using @DirtestContext. + @RepeatedTest(2) void stubAndVerifyOnUltimateTargetOfSpringAopProxy() { assertThat(AopUtils.isAopProxy(dateService)).as("is Spring AOP proxy").isTrue(); DateService spy = AopTestUtils.getUltimateTargetObject(dateService); @@ -93,13 +104,14 @@ class MockitoSpyBeanAndSpringAopProxyTests { * AOP proxy to stubbing calls, while supplying the Spring AOP proxy to verification * calls. */ - @Disabled("Disabled until transparent verification for @MockitoSpyBean is implemented") - @Test + // We need to run this test at least twice to ensure the Mockito spy can be reused + // across test method invocations without using @DirtestContext. + @RepeatedTest(2) 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(); + assertThat(Mockito.mockingDetails(dateService).isSpy()).as("Spring AOP proxy is Mockito spy").isTrue(); + DateService spy = AopTestUtils.getUltimateTargetObject(dateService); given(spy.getDate(false)).willReturn(1L); Long date = dateService.getDate(false); assertThat(date).isOne(); @@ -123,7 +135,9 @@ class MockitoSpyBeanAndSpringAopProxyTests { * stubbing for a proxied mock. */ @Disabled("Disabled until Mockito provides support for transparent stubbing of a proxied spy") - @Test + // We need to run this test at least twice to ensure the Mockito spy can be reused + // across test method invocations without using @DirtestContext. + @RepeatedTest(2) 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(); @@ -166,6 +180,11 @@ class MockitoSpyBeanAndSpringAopProxyTests { Long getDate(boolean argument) { return System.nanoTime(); } + + @CacheEvict(cacheNames = "test", allEntries = true) + void clearCache() { + } + } } diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverIntegrationTests.java new file mode 100644 index 00000000000..5ebb2bfc8aa --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverIntegrationTests.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-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.Test; +import org.mockito.internal.configuration.plugins.Plugins; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link SpringMockResolver}. + * + * @author Andy Wilkinson + * @since 6.2 + * @see SpringMockResolverTests + */ +class SpringMockResolverIntegrationTests { + + @Test + void customMockResolverIsRegisteredWithMockito() { + assertThat(Plugins.getMockResolvers()).hasOnlyElementsOfType(SpringMockResolver.class); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverTests.java b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverTests.java new file mode 100644 index 00000000000..131d74d4312 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/bean/override/mockito/SpringMockResolverTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-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.Test; + +import org.springframework.aop.SpringProxy; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.target.HotSwappableTargetSource; +import org.springframework.aop.target.SingletonTargetSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SpringMockResolver}. + * + * @author Moritz Halbritter + * @author Sam Brannen + * @since 6.2 + * @see SpringMockResolverIntegrationTests + */ +class SpringMockResolverTests { + + @Test + void staticTarget() { + MyServiceImpl myService = new MyServiceImpl(); + MyService proxy = ProxyFactory.getProxy(MyService.class, new SingletonTargetSource(myService)); + Object target = new SpringMockResolver().resolve(proxy); + assertThat(target).isInstanceOf(MyServiceImpl.class); + } + + @Test + void nonStaticTarget() { + MyServiceImpl myService = new MyServiceImpl(); + MyService proxy = ProxyFactory.getProxy(MyService.class, new HotSwappableTargetSource(myService)); + Object target = new SpringMockResolver().resolve(proxy); + assertThat(target).isInstanceOf(SpringProxy.class); + } + + + private interface MyService { + } + + private static final class MyServiceImpl implements MyService { + } + +}