0%

解决因错误 Mock 导致单测慢的问题

前言

偶然发现单元测试运行的比较慢,比较有意思,所以就深究了一下源码,解决了一下。

解决前后对比

解决前,单测本地运行 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 作用原理

整体流程分为注册与使用两部份。

注册方面

  1. Junit 通过 Extension 形式提供单元测试各执行流程的自定义逻辑扩展能力。
  2. Spring 自定义 Extension 子类 SpringExtension,并通过执行不同 TestExecutionListener 执行 TestContextManager 自定义内容。
  3. Spring 通过 ContextCustomizerFactory 形式提供动态修改上下文的能力,SpringBoot test 实现子类用于 Mock 相关逻辑实现。
  4. 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