优化 Spring 集成测试的执行时间
简介
文章大体分为四部分
第一部分会简单介绍一些真实项目的数据,其中包含优化前和优化后的耗时数据,让小伙伴们了解集成测试到底有多大的优化空间,来突出 为什么需要优化集成测试。
第二部分会通过一些例子 来介绍集成测试执行时间的构成,让小伙伴们都知道 时间都花在哪里了。
第三部分我会拨开 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 集成测试缓慢的根本原因,最后给出了一些优化集成测试的建议,希望能够受用。