前言
偶然发现单元测试运行的比较慢,比较有意思,所以就深究了一下源码,解决了一下。
解决前后对比
解决前,单测本地运行 10+ 分钟。
解决后,单测本地运行 1 分钟。
项目概况
SpringBoot 版本:2.3.x,junit 版本:5.x。
项目结构很简单:接收上游请求查询数据库做业务逻辑。
单测同时使用 @InjectMocks + @Spy/@Mock 以及 @SpyBean/@MockBean 对下游依赖进行 mock。
运行时表现
现象
单元测试逻辑运行很快,运行前Spring 容器不断被启动,数据库连接 / Bean 被重复加载。
而在普通业务项目中,运行时数据库 / Bean 的信息是确定的,重复加载并没有意义。
重复加载的 demo
所有单测都继承 BaseTest 基类,使用集成测试的方式运行所有单测。
业务代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Service public class MockOuterService { @Resource private MockService mockService; public String mockString(){ return mockService.mockString();} }
public interface MockService { String mockString(); }
@Service public class MockServiceImpl implements MockService{
@Override public String mockString() {return "business return";} }
|
单元测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| //测试基类 @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = SpringServiceTestApplication.class) @ExtendWith(MockitoExtension.class) public class BaseTest {}
//集成测试 @RunWith(JUnitPlatform.class) @SelectPackages("org.spring.demo.demo") public class TestAll extends BaseTest {}
//业务测试 1 class Test1 extends BaseTest { @InjectMocks @Resource private MockOuterService mockOuterService;
@Spy private MockService mockService;
@Test void test(){ Mockito.when(mockService.mockString()).thenReturn("test1 mock"); System.out.println(mockOuterService.mockString()); } }
//业务测试 2 class Test2 extends BaseTest {
@SpyBean private MockService mockService;
@Resource private MockOuterService mockOuterService;
@Test void test(){ Mockito.when(mockService.mockString()).thenReturn("test2 mock"); System.out.println(mockOuterService.mockString()); } }
|
运行截图
Spring Boot Banner 出现两次,容器被启动两次。

重复加载原因
常见原因
Spring 是一个依赖上下文的框架,若上下文缓存失效/配置变更则会重新刷新上下文,从而引发容器重启。
推测原因
业务项目启动过程中,Spring Boot Banner 出现一次,且所有资源(数据库连接池/动态线程池)只初始化一遍。
而在单测过程中启动多次,有可能是单测改变了 Spring 上下文,或者单测与业务代码结合有问题。
最小可行性分析
在对单测删减到最后两个类,仅使用 @MockBean 或者 @InjectMocks/@Mock 时,容器启动一次。同时使用,容器启动两次。
所以原因应该是 SpringBoot 封装的与 Mockito 原生的同时作用情况下,改变了 Spring 的上下文。
根本原因
@MockBean/@SpyBean 作用原理
整体流程分为注册与使用两部份。
注册方面
- Junit 通过 Extension 形式提供单元测试各执行流程的自定义逻辑扩展能力。
- Spring 自定义 Extension 子类 SpringExtension,并通过执行不同 TestExecutionListener 执行 TestContextManager 自定义内容。
- Spring 通过 ContextCustomizerFactory 形式提供动态修改上下文的能力,SpringBoot test 实现子类用于 Mock 相关逻辑实现。
- SpringBoot test自定义 ContextCustomizerFactory 子类 MockitoContextCustomizerFactory,用于 Mock 相关逻辑实现。
使用方面
Junit 通过执行不同 Extension 实现自定义逻辑,进而一层层触发到 SpringBoot test 实现的 Mock 相关逻辑。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @startuml group 注册扩展 group junit5 内部流程 "MethodBasedTestDescriptor" -> "ExtensionUtils": 扫描 @Test,调用 Extension 注册 "ExtensionUtils" -> "MutableExtensionRegistry": 调用注册 "MutableExtensionRegistry" -> "SpringExtension": 注册 Extension end group Spring Framework 逻辑 "SpringExtension" -> "TestContextManager": 新建 TestContextManager "TestContextManager" -> "BootstrapUtils": 触发 @BootstrapWith 扫描逻辑 "BootstrapUtils" -> "TestContextManager": 返回启动类 "TestContextManager" -> "SpringBootTestContextBootstrapper": 触发启动类 buildTestContext end group SpringBoot 逻辑 "SpringBootTestContextBootstrapper" -> "SpringBootTestContextBootstrapper": 扫描执行 ContextCustomizerFactory 逻辑 "SpringBootTestContextBootstrapper" -> "MockitoContextCustomizerFactory": Mockito 自定义上下文类 "MockitoContextCustomizerFactory" -> "DefinitionsParser": 注册注解扫描器 "DefinitionsParser" -> "DefinitionsParser": 扫描 @SpyBean/@MockBean 添加到 Definition 集合中 "DefinitionsParser" -> "MockitoContextCustomizerFactory": 返回 Definition 集合 "MockitoContextCustomizerFactory" -> "MockitoContextCustomizerFactory": 以 Definition 集合构建 ContextCustomizer "MockitoContextCustomizerFactory" -> "SpringBootTestContextBootstrapper": 返回 ContextCustomizer "SpringBootTestContextBootstrapper" -> "SpringBootTestContextBootstrapper": 构建 MergedContextConfiguration "SpringBootTestContextBootstrapper" -> "SpringBootTestContextBootstrapper": 构建 TestContext end "SpringBootTestContextBootstrapper" -> "TestContextManager": 返回 TestContext "TestContextManager" -> "TestContextManager": 存储上下文 "TestContextManager" -> "SpringExtension": 返回 TestContextManager 实例 "SpringExtension" -> "MutableExtensionRegistry": 返回 SpringExtension 实例 "MutableExtensionRegistry" -> "MutableExtensionRegistry": 存储各 Extension 实例 end group 使用扩展 "NodeTestTask" -> "ClassBasedTestDescriptor": prepare "ClassBasedTestDescriptor" -> "SpringExtension": TestInstancePostProcessor#postProcessTestInstance "SpringExtension" -> "TestContextManager": prepareTestInstance "TestContextManager" -> "DependencyInjectionTestExecutionListener": 传入 SpringBootTestContextBootstrapper 构建的 TestContext 到 prepareTestInstance "DependencyInjectionTestExecutionListener" -> "DependencyInjectionTestExcutionListener": 创建完整 Spring Bean 依赖 end @enduml
|
@InjectMocks +@Spy/@Mock 作用原理
整体流程分为注册与使用两部份。
注册方面,只是将自定义扩展挂载到 Junit 钩子上。
使用方面,扫描当前单测类相关注释,反射注入新增的 mock 对象。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @startuml group 注册扩展 group junit5 内部流程 "MethodBasedTestDescriptor" -> "ExtensionUtils": 扫描 @Test,调用 Extension 注册 "ExtensionUtils" -> "MutableExtensionRegistry": 调用注册 "MutableExtensionRegistry" -> "MockitoExtension": 注册 Extension end end group 使用扩展 group junit5 内部流程 "NodeTestTask" -> "TestMethodTestDescriptor": execute "TestMethodTestDescriptor" -> "TestMethodTestDescriptor": TestMethodTestDescriptor#invokeBeforeEachCallbacks "TestMethodTestDescriptor" -> "MockitoExtension": beforeEach end group mockito 内部流程 "MockitoExtension" -> "DefaultMockitoSessionBuilder": startMocking "DefaultMockitoSessionBuilder" -> "DefaultMockitoSession": new DefaultMockitoSession Object "DefaultMockitoSession" -> "MockitoAnnotations": openMocks "MockitoAnnotations" -> "InjectingAnnotationEngine": process "InjectingAnnotationEngine" -> "MockScanner": processIndependentAnnotations/injectCloseableMocks 传入当前单测类 "MockScanner" -> "MockScanner": 扫描修饰 @Mock/@Spy 变量 "MockScanner" -> "InjectingAnnotationEngine": 返回扫描结果 "InjectingAnnotationEngine" -> "InjectingAnnotationEngine": 执行 mock 注入 "InjectingAnnotationEngine" -> "DefaultInjectionEngine": injectMocksOnFields 反射注入 end end @enduml
|
二者原理区别
|
SpringBoot |
Mockito 原生 |
执行时机 |
单测类初始化(ClassBasedTestDescriptor) |
单测方法(TestMethodTestDescriptor) |
注入对象 |
缓存的 Spring Bean |
调用 Java 原生反射构建 |
原因确定
根据二者的作用原理可知,Mockito 将 Spring Bean 依赖链改变,进而引发 Spring 容器的启动。
解决方案
根据原因可知,解决方案二选一:SpringBoot 的/Mockito 原生的。
|
SpringBoot |
Mockito 原生 |
针对对象 |
Spring Bean |
所有的对象 |
使用复杂度 |
低,不需管理依赖链 |
高,需要手动注入被依赖的对象 |
作用场景 |
需 Spring 环境的集成测试 |
纯单元测试 |
解决
因为项目中使用 SpringBoot ,并且单元测试大多以集成测试的方式运行,结合使用的复杂度,所以选择了 SpringBoot 提供的方案。
仍需提升的
目前 junit-platform.properties
配置的策略为测试类串行/测试方法并行,希望改成测试类并行/测试方法并行,进一步提升速度。
但修改后发现大部份单测都报错了,仍需明确原因,推测是各单测自定义 mock 行为互相影响导致。
结尾
通过表象猜测原因,通过源码证明猜想。
在猜测原因时,就在想 SpringBoot 不可能只为了 KPI 而集成 Mockito,肯定有结合 Mockito 做对应的集成,方便后续使用。
追查源码后印证了猜想,满足。
本文首发于cartoon的博客
转载请注明出处:https://cartoonyu.github.io