Spring Boot测试@SpyBean存根失败:Aggregate instance must not be null
我正在编写Spring Boot集成测试(版本2.7.10),原本用@Autowired注入SomeRepository,配合@MockBean模拟外部RestTemplate时一切正常,测试类配置如下:
@SpringBootTest @DirtiesContext @ExtendWith(ConsoleReporterExtension.class) @ActiveProfiles("it") @SpringJUnitConfig(Application.class) @AutoConfigureMockMvc @AutoConfigureWireMock(port = 0) @ContextConfiguration(initializers = {TestcontainersInitializer.class}) // 用于Docker数据库 // ... @Autowired private SomeRepository someRepository;
后来需要模拟持久化时的异常,改用@SpyBean监听该Repository:
//@Autowired @SpyBean private SomeRepository someRepository;
未配置存根时测试正常,说明SpyBean已正确生效。但当我尝试存根save方法,让它首次调用抛出异常、后续调用执行真实方法时:
when(someRepository.save(any())) .thenThrow(new OptimisticLockingFailureException("some concurrency issue")) .thenCallRealMethod();
在存根定义阶段就抛出IllegalArgumentException,提示"Aggregate instance must not be null!",但此时并未实际调用save方法传入null值。SomeRepository是继承自CrudRepository的普通接口:
@Repository public interface SomeRepository extends CrudRepository<SomeEntity, Long> { // <S extends SomeEntity> S save(S entity); 曾尝试显式重写,无效果 }
java.lang.IllegalArgumentException: Aggregate instance must not be null! at org.springframework.util.Assert.notNull(Assert.java:201) at org.springframework.data.jdbc.core.JdbcAggregateTemplate.save(JdbcAggregateTemplate.java:153) at org.springframework.data.jdbc.repository.support.SimpleJdbcRepository.save(SimpleJdbcRepository.java:78) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.base/java.lang.reflect.Method.invoke(Method.java:568) at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:289) at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:137) at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:121) at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:530) at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:286) at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:640) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:164) at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:139) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:81) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388) at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:137) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215) at jdk.proxy2/jdk.proxy2.$Proxy223.save(Unknown Source) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.base/java.lang.reflect.Method.invoke(Method.java:568) at org.mockito.internal.util.reflection.ReflectionMemberAccessor.invoke(ReflectionMemberAccessor.java:48) at org.mockito.internal.stubbing.defaultanswers.ForwardsInvocations.answer(ForwardsInvocations.java:49) at org.mockito.internal.handler.MockHandlerImpl.handle(MockHandlerImpl.java:110) at org.mockito.internal.handler.NullResultGuardian.handle(NullResultGuardian.java:29) at org.mockito.internal.handler.InvocationNotifierHandler.handle(InvocationNotifierHandler.java:34) at org.mockito.internal.creation.bytebuddy.MockMethodInterceptor.doIntercept(MockMethodInterceptor.java:82) at org.mockito.internal.creation.bytebuddy.MockMethodInterceptor.doIntercept(MockMethodInterceptor.java:56) at org.mockito.internal.creation.bytebuddy.MockMethodInterceptor$DispatcherDefaultingToRealMethod.interceptAbstract(MockMethodInterceptor.java:161) at SomeRepository$MockitoMock$cs2Dymza.save(Unknown Source) at SomeRepository$MockitoMock$cs2Dymza$$FastClassBySpringCGLIB$$b0e4e380.invoke(<generated>) at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:793) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:137) at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708) at SomeRepository$MockitoMock$cs2Dymza$$EnhancerBySpringCGLIB$$6546aaba.save(<generated>)
问题原因
使用when(someRepository.save(any())).thenThrow(...).thenCallRealMethod()这种链式语法时,Mockito会提前触发一次真实的save方法调用——因为when()语法需要先执行目标方法来获取返回值,用于后续的存根逻辑。而此时传入的参数是any()匹配器对应的null值,导致Spring Data JDBC内部的JdbcAggregateTemplate.save()方法触发Assert.notNull()断言失败。
解决办法
改用Mockito的doThrow().doCallRealMethod()语法,这种方式不会提前触发真实方法调用,只是直接配置方法的行为:
doThrow(new OptimisticLockingFailureException("some concurrency issue")) .doCallRealMethod() .when(someRepository) .save(any());
补充说明
Spring Data的Repository是通过动态代理生成的Bean,和普通Java类的Spy行为存在差异。when()语法依赖方法调用的返回值,对于Repository代理来说会触发完整的方法执行链路,而do...系列方法是直接Stub行为,绕过了提前调用真实方法的逻辑,因此可以避免传入null导致的断言错误。
内容的提问来源于stack exchange,提问作者radio

