SpyBean的void方法存根失败问题排查
问题描述
组件Foo中定义了带@Async注解的void方法bar,代码如下:
@Component @EnableAsync @AllArgsConstructor public class Foo { private final SomeOtherService someOtherService; private final KafkaListenerEndpointRegistry registry; private final FooConfig fooConfig; @Async("myExecutor") public void bar(A a, B b, ConcurrentSkipListMap<Long, String> c, int d, int e) { someOtherService.doSomething(a, b, c, d, e); } }
该组件被MyService调用:
@Service @AllArgsConstructor public class MyService { private final Foo foo; public void execute() { // call foo.bar(); } }
编写单元测试模拟foo.bar()抛出RuntimeException的场景,测试代码如下:
import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; import java.util.concurrent.ConcurrentSkipListMap; @RunWith(SpringRunner.class) @ContextConfiguration(classes = {SpringBootMain.class}, initializers = {ConfigDataApplicationContextInitializer.class}) @TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"}) public class MyServiceTest { @MockBean private SomeOtherService someOtherService; @SpyBean @Autowired private Foo foo; @Test public void test() { MyService myService = new MyService(foo); Mockito.doThrow(new RuntimeException("failed")).when(foo).bar(any(A.class), any(B.class), any(ConcurrentSkipListMap.class), anyInt(), anyInt()); myService.execute(); // some assertions } }
运行测试时出现以下错误:
WARN org.springframework.test.context.TestContextManager -- Caught exception while invoking 'afterTestMethod' callback on TestExecutionListener [org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener] for test method [public void MyServiceTest.test() throws com.fasterxml.jackson.core.JsonProcessingException] and test instance [MyServiceTest@60fc4450] org.mockito.exceptions.misusing.UnfinishedStubbingException: Unfinished stubbing detected E.g. thenReturn() may be missing. Examples of correct stubbing: when(mock.isOk()).thenReturn(true); when(mock.isOk()).thenThrow(exception); doThrow(exception).when(mock).someVoidMethod(); Hints: 1. missing thenReturn() 2. you are trying to stub a final method, which is not supported 3. you are stubbing the behaviour of another mock inside before 'thenReturn' instruction is completed org.mockito.exceptions.misusing.InvalidUseOfMatchersException: Invalid use of argument matchers! 1 matchers expected, 5 recorded: This exception may occur if matchers are combined with raw values: //incorrect: someMethod(any(), "raw String"); When using matchers, all arguments have to be provided by matchers. For example: //correct: someMethod(any(), eq("String by matcher")); For more info see javadoc for Matchers class.
尝试过以下写法,但都无法解决问题:
Mockito.doThrow(new RuntimeException("failed")).when(foo).bar(any(A.class), any(B.class), Mockito.<ConcurrentSkipListMap<Long, String>>any(), anyInt(), anyInt());
Mockito.doAnswer(inv -> { throw new RuntimeException("failed"); }).when(foo).bar(Mockito.any(A.class), Mockito.any(B.class), Mockito.any(ConcurrentSkipListMap.class), Mockito.anyInt(), Mockito.anyInt());
解决方案
问题根源
- 异步代理干扰:
@Async注解会让Spring为Foo生成动态代理对象,直接对代理对象进行Stub操作时,Mockito无法正确识别原始方法的调用逻辑,导致Stub失败。 - 参数匹配器导入问题:如果没有静态导入
ArgumentMatchers的方法,可能会引发参数匹配器的使用错误。
方案一:Stub底层依赖方法(推荐)
既然bar方法内部实际调用的是someOtherService.doSomething(),直接Stub这个底层方法抛出异常即可,无需处理异步代理的问题,更贴近业务逻辑:
import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import static org.mockito.ArgumentMatchers.*; @RunWith(SpringRunner.class) @ContextConfiguration(classes = {SpringBootMain.class}, initializers = {ConfigDataApplicationContextInitializer.class}) @TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"}) public class MyServiceTest { @MockBean private SomeOtherService someOtherService; @SpyBean private Foo foo; @Test public void testBarThrowsException() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); // Stub底层方法抛出异常,同时用CountDownLatch等待异步任务完成 Mockito.doAnswer(invocation -> { try { throw new RuntimeException("failed"); } finally { latch.countDown(); } }).when(someOtherService) .doSomething(any(A.class), any(B.class), any(ConcurrentSkipListMap.class), anyInt(), anyInt()); MyService myService = new MyService(foo); myService.execute(); // 等待异步任务执行完成,超时时间5秒 latch.await(5, TimeUnit.SECONDS); // 验证底层方法是否被调用 Mockito.verify(someOtherService, Mockito.times(1)) .doSomething(any(A.class), any(B.class), any(ConcurrentSkipListMap.class), anyInt(), anyInt()); } }
方案二:绕过异步代理Stub原始对象
如果必须直接StubFoo的bar方法,可以通过AopProxyUtils获取Foo的原始对象,再对其进行Stub操作:
import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; import org.springframework.aop.framework.AopProxyUtils; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import static org.mockito.ArgumentMatchers.*; @RunWith(SpringRunner.class) @ContextConfiguration(classes = {SpringBootMain.class}, initializers = {ConfigDataApplicationContextInitializer.class}) @TestPropertySource(properties = {"spring.config.location=classpath:application-test.yml"}) public class MyServiceTest { @MockBean private SomeOtherService someOtherService; @SpyBean private Foo foo; @Test public void testBarThrowsException() throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); // 获取Foo的原始对象,绕过Spring异步代理 Foo originalFoo = (Foo) AopProxyUtils.getSingletonTarget(foo); // Stub原始对象的bar方法 Mockito.doAnswer(invocation -> { try { throw new RuntimeException("failed"); } finally { latch.countDown(); } }).when(originalFoo) .bar(any(A.class), any(B.class), any(ConcurrentSkipListMap.class), anyInt(), anyInt()); MyService myService = new MyService(foo); myService.execute(); // 等待异步任务执行完成 latch.await(5, TimeUnit.SECONDS); // 验证bar方法是否被调用 Mockito.verify(originalFoo, Mockito.times(1)) .bar(any(A.class), any(B.class), any(ConcurrentSkipListMap.class), anyInt(), anyInt()); } }
注意事项
- 确保静态导入
ArgumentMatchers的所有方法:import static org.mockito.ArgumentMatchers.*;,避免参数匹配器的使用错误。 - 由于
@Async方法是异步执行的,测试中需要使用CountDownLatch或其他方式等待异步任务完成,否则测试可能在任务执行前就结束,导致断言失效。
内容的提问来源于stack exchange,提问作者zeeshan
相关产品推荐
相关产品推荐

