简介
文章大体分为四部分
- 第一部分会简单介绍一些真实项目的数据,其中包含优化前和优化后的耗时数据,让小伙伴们了解集成测试到底有多大的优化空间,来突出 为什么需要优化集成测试。
- 第二部分会通过一些例子 来介绍集成测试执行时间的构成,让小伙伴们都知道 时间都花在哪里了。
- 第三部分我会拨开 Spring 测试框架源码这层"云雾",带小伙伴们简单了解 Spring 集成测试的原理。
- 最后,我会给出一些有用的建议,用于优化 Spring 集成测试的执行时间。
集成测试
这里简单唠两句,一直以来我们都在追求自动化测试,根本原因就是自动化测试比人靠谱,而且比人还廉价,其中集成测试就是作为自动化测试的一个组成部分。
做好集成测试在团队中可以给团队带来信心,例如
- 集成测试覆盖的范围更大,可以覆盖到一些单元测试 cover 不到的地方
- 可以更加放心大胆地重构
为了做好集成测试,我们也是需要付出一些代价,例如
- 集成测试执行缓慢(相对于单元测试)
- 构造测试数据到“哭泣”...
为什么需要降低测试执行时间?
由于工作原因,下面只贴出两个真实的代码仓库,这里用 Gradle 生成了每个 gradle task 的执行报告,执行报告按时间对每个 task 进行倒序。
数据都在图里面,小伙伴们可以先看看,并问一下自己,这个耗时是否能够接受。
001代码库:总测试数量 650+,包含单元测试和集成测试
002代码库:总测试数量 1250+,包含单元测试和集成测试
可能有的小伙伴会觉得,就这?我倒杯水,上个洗手间回来刚刚好,可能就这点耗时还不太够用...
不过作为一名不断探索极限的 Devloper 来说,这个耗时是不可接受的,如果还有优化空间,就应该去做。
接下来我对 001 代码库进行第一阶段的的优化,其中 P0 指的是优化前的耗时数据
P1 对耗时的降幅比较一般
- 测试耗时:从 6'34s(P0) 降到 5'31s(P1),一共少了 1'03s
- 总耗时:从 8'16s(P0) 降到 6'48s(P1),一共少了 1'28s
但还是不够,接下来我又进行了第二阶段的优化
P2 对耗时的降幅比较明显
- 测试耗时:从 6'34s(P0) 降到 2'53s(P2),一共少了 3'41s
- 总耗时:从 8'16s(P0) 降到 4'3s(P2),一共少了 4'13s
其实我用了很少的精力去优化,然而却得到了很好的效果,现在小伙伴们还能接受 P0 阶段的耗时吗?
实际项目中存在很多集成测试写得不规范,耗时可比这里 P0 阶段的耗时要久得多,这会导致测试的反馈不及时,CI 效率缓慢。
到这里小伙伴们应该了解到 集成测试到底有多大的优化空间了,这就是我们为什么需要去优化集成测试的原因。
集成测试的耗时都花在哪里?
接下来我们进入第二部分 —— 那测试阶段的耗时都花到哪里了?
这个问题其实非常简单,我们只需要拿到 gradle build 输出的 test report 就可以了,这份测试报告已经把每个测试文件的耗时都统计出来了,例如我截取了部分 001 代码库的测试报告:
其中 Duration 指的是测试本身的耗时,它不包含准备、运行和销毁 spring 上下文的时间,因此测试总耗时看起来其实非常少(866 个测试包含了单元测试和集成测试)
我们进入一个集成测试文件,选择 Standard output,可能会看到两类日志
- 第一类日志很明显启动了 Spring 上下文,如下图
- 第二类日志没有 Spring 上下文,如下图
到这里,耗时都花在哪里这个问题已经大致有结论了,我在这里把耗时粗略归纳两部分:
- 测试本身(我们写的测试代码)
- 构建测试所需的环境(例如 准备、启动以及销毁 Spring 容器;又例如 启动和销毁内存数据库等)
其中测试本身耗时非常少(除非用了 sleep 或者超大的磁盘IO操作);而构建测试所需的环境就非常耗时,影响这个耗时跟代码库的规模和复杂程度有关,这个小伙伴们可以拿自己的项目 跑上几次集成测试 大概就能得出耗时数据,通常耗时在 10s以上。
想象一下,如果有很多集成测试都需要重新启动上下文,那得有多耗时啊。
Spring 集成测试的原理是什么?
现在我们知道集成测试的耗时都花到哪了,那我们应该如何优化呢?
其实我明着说怎么优化,还是会有小伙伴存在各种各样的疑惑,索性我就带大家来看看源码,了解一下 Spring 集成测试的原理,一旦搞清楚原理,自然就知道如何优化,授人以鱼不如授人以渔嘛。
由于篇幅有限,而且文章主要关注的是优化,因此第三部分我忽略了很多细节,只展示集成测试的主要脉络,至于颗粒度更细的原理,我会考虑再写一篇文章来讲,小伙伴们也可以自己下来看源码。
笔者用的是 JUnit5 和 Spring Boot 2.4.5,这里我就假设小伙伴们对 JUnit5 有一定的知识储备。
从上图可以看到,JUnit5 Test Engine 负责执行测试,Spring Test Framework 负责构建集成测试所需的环境,而我们只需要关注 Spring Test Framework 在干什么就好。
构建 TestContext
我们在启动集成测试之后,Spring 框架会先去构建一个 Test Context,入口是 @SpringBootTest
,对应的源码如下
其中我们只关注 @BootstrapWith(SpringBootTestContextBootstrapper.class)
就行,虽然@ExtendWith(SpringExtension.class)
对了解 Spring 集成测试的原理有一些帮助,不过不是本文的重点,暂且忽略。
SpringBootTestContextBootstrapper
提供了很多方法,其中我们最为关注的是 buildTestContext()
,对应流程图中的 Build Test Context 步骤,该方法提供了一个测试上下文,为后面构建 Spring ApplicationContext
提供输入。
查找/构建 ApplicationContext
这里是最为核心的代码!
这里是最为核心的代码!
这里是最为核心的代码!
准备好 Spring TestContext
,接下来就会基于 Spring TestContext
来构建 Spring ApplicationContext
,为后续启动 Spring Application 做好准备,这个操作在 DefaultTestContext.getApplicationContext()
,源码如下
从代码中可以看出,这里会优先从缓存取 ApplicationContext
,然后我们进入到cacheAwareContextLoaderDelegate.loadContext(this.mergedContextConfiguration);
然后我们看看缓存的实现逻辑,源码对应在 DefaultCacheAwareContextLoaderDelegate.loadContext(mergedContextConfiguration)
其中对 contextCache 做了同步锁,目的是避免 loadContext(mergedContextConfiguration)
方法执行多次,导致重复启动 Spring Application
看下来我们可以暂时得出两个结论:
- Spring 会优先从缓存中取
ApplicationContext
- 缓存找不到,会构建新的
ApplicationContext
,并把它放进缓存
然后我们又会产生两个疑惑:
MergedContextConfiguration
是什么?- 有哪些因素会影响从
ContextCache
中取ApplicationContext
?
对于第一个问题,我们可以打开 MergedContextConfiguration.java
看看描述
从描述中我们可以看到,当我们在执行一个测试类的时候,Spring 会把集成测试用到的配置都合并起来,放到 MergedContextConfiguration
中管理,然后把它作为 key 将 ApplicationContext
缓存在 ContextCahce
。
具体有哪些配置会被合并 小伙伴们可以看看描述和这个类的实现代码,实现代码在 AbstractTestContextBootstrapper.buildMergedContextConfiguration()
对于第二个问题,我们可以看看 MergedContextConfiguration.hashCode()
可以看到影响从 ContextCache
中取 ApplicationContext
的因素还挺多,大概有下面这些:
- 集成测试类之间 使用 @ContextConfiguration 自定义不同配置
- 集成测试类之间 使用不同的 @ActiveProfiles 配置
- 集成测试类之间 使用不同的 @TestPropertySource 配置
- 集成测试类 是否有继承父类
- 集成测试类之间,是否使用了不同的定制上下文,主要维护在
Set<ContextCustomizer>
,小伙伴可以自己去看
我重点讲第 5 点,ContextCustomizer
的实现类之一 MockitoContextCustomizer
,它指的是 @MockBean
和 @SpyBean
这两,也是集成测试中经常用到的两个 Annotation。
如果两个测试类使用了不同的 @Mockbean
和 @SpyBean
,就会导致 ApplicationContext
不能在这两个测试类中复用,从而导致重新构建 ApplicationContext,因为 hashCode() 已经不同!!!
因此不要乱用 @Mockbean
和 @SpyBean
!!!
因此不要乱用 @Mockbean
和 @SpyBean
!!!
因此不要乱用 @Mockbean
和 @SpyBean
!!!
运行 Spring Application
上面构建好 ApplicationContext
,接下来就要开始启动 Spring 应用了,为构建集成测试所需的环境做好最后的准备。
代码在 SpringBootContextLoader.loadContext(mergedContextConfiguration)
和 SpringApplication.run(args)
。
由于不是本文的重点,我就不费口舌了,感兴趣的小伙伴去看看就好
存储 ApplicationContext
缓存 Spring ApplicationContext
这个上面 查找/构建 Spring Application Context 已经提到了,这里也就不重复了。
啰嗦了这么多,第三部分也讲完了,大致可以总结为三点:
- 启动每个集成测试类时,考虑到执行效率,Spring 都会优先从
ContextCache
中取ApplicationContext
- 如果从
ContextCache
中取不到ApplicationContext
,则会构建一个新的ApplicationContext
,然后启动 Spring Application,并且将ApplicationContext
缓存到ContextCache
- 启动每个集成测试类时,Spring 都会把集成测试用到的配置都合并起来,放到
MergedContextConfiguration
中管理,然后把它作为 key 将ApplicationContext
缓存在ContextCahce
,一旦各个集成测试类的配置有区别,就无法复用ApplicationContext
,导致需要重新加载整个 Spring 上下文
如何降低集成测试的耗时?
理清楚 Spring 集成测试大致的原理,相信小伙伴都有一个基本的优化策略了(核心目标就是复用 ApplicationContext,才能有效降低执行时间)。
建立规范
我们首先需要在团队内达成一些共识 —— 集成测试的范围有哪些?
这个各个团队可能不太一样,我先按个人经验主义梳理一下,分解下来大致有四个共识:
- 哪些需要进行集成测试? 不涉及外部依赖的代码,都应该考虑在集成测试的范畴。
例如 Controller、Service、DB(可以使用内存数据库替代)
- 哪些不需要进行集成测试? 那些会让测试不稳定的外部依赖,都不应该纳入集成测试的范畴。
例如:MQ、FeignClient
- 如何隔离不需要集成测试的代码?
使用 @MockBean
- 如何组织集成测试的自定义配置?
优先考虑全局复用,尽量少为测试类搞特殊,然后统一到通用的基类里面管理(包含 @MockBean
),写集成测试的时候,直接继承基类。
例如:
重构 @MockBean
慎用 @MockBean!!!
@MockBean
在集成测试经常被乱用,这是主要导致集成测试缓慢的原因之一。很多人为了方便,大量使用 @MockBean
,最后写了一堆没意义的测试,而且还让集成测试变得非常慢。
除非外部依赖,最好的做法就是别偷懒,该造的数据还是造吧,集成测试的大部分时间 都花在造数据上,如果不好造,再考虑其他办法,例如可以声明为 @SpyBean
,把它挪到基类,然后建个技术债卡,安排时间去重构代码。
下沉单元测试
可以考虑将一些集成测试下沉到单元测试。
单元测试不需要像集成测试那样准备那么多上下文,因此执行时间非常短。
实践起来
实践起来,然后在实践中得出结论,小伙伴们有什么疑问可以留言。
总结
在这篇文章中,一开始通过一些数据重点来突出优化集成测试的重要性,紧接着分析了耗时的组成部分,然后带着小伙伴们大致梳理了 Spring 集成测试的原理,深入理解导致 Spring 集成测试缓慢的根本原因,最后给出了一些优化集成测试的建议,希望能够受用。
欢迎关注我的微信订阅号,我将会持续输出更多技术文章,希望我们可以互相学习。