vlambda博客
学习文章列表

优化 Spring 集成测试的执行时间

简介

文章大体分为四部分

  1. 第一部分会简单介绍一些真实项目的数据,其中包含优化前和优化后的耗时数据,让小伙伴们了解集成测试到底有多大的优化空间,来突出 为什么需要优化集成测试。

  2. 第二部分会通过一些例子 来介绍集成测试执行时间的构成,让小伙伴们都知道 时间都花在哪里了。

  3. 第三部分我会拨开 Spring 测试框架源码这层"云雾",带小伙伴们简单了解 Spring 集成测试的原理。

  4. 最后,我会给出一些有用的建议,用于优化 Spring 集成测试的执行时间。

集成测试

优化 Spring 集成测试的执行时间

这里简单唠两句,一直以来我们都在追求自动化测试,根本原因就是自动化测试比人靠谱,而且比人还廉价,其中集成测试就是作为自动化测试的一个组成部分。

做好集成测试在团队中可以给团队带来信心,例如

  • 集成测试覆盖的范围更大,可以覆盖到一些单元测试 cover 不到的地方

  • 可以更加放心大胆地重构

为了做好集成测试,我们也是需要付出一些代价,例如

  • 集成测试执行缓慢(相对于单元测试)

  • 构造测试数据到“哭泣”...

为什么需要降低测试执行时间?

由于工作原因,下面只贴出两个真实的代码仓库,这里用 Gradle 生成了每个 gradle task 的执行报告,执行报告按时间对每个 task 进行倒序。

数据都在图里面,小伙伴们可以先看看,并问一下自己,这个耗时是否能够接受。

001代码库:总测试数量 650+,包含单元测试和集成测试

002代码库:总测试数量 1250+,包含单元测试和集成测试

优化 Spring 集成测试的执行时间

优化 Spring 集成测试的执行时间

优化 Spring 集成测试的执行时间

可能有的小伙伴会觉得,就这?我倒杯水,上个洗手间,可能就这点耗时还不太够用...

不过作为一名不断探索极限的 Devloper 来说,这个耗时是不可接受的,如果还有优化空间,就应该去做。

接下来我对 001 代码库进行第一阶段的的优化,其中 P0 指的是优化前的耗时数据

优化 Spring 集成测试的执行时间

P1 对耗时的降幅比较一般

  • 测试耗时:从 6'34s(P0) 降到 5'31s(P1),一共少了 1'03s

  • 总耗时:从 8'16s(P0) 降到 6'48s(P1),一共少了 1'28s

但还是不够,接下来我又进行了第二阶段的优化

优化 Spring 集成测试的执行时间

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 代码库的测试报告:

优化 Spring 集成测试的执行时间

其中 Duration 指的是测试本身的耗时,它不包含准备、运行和销毁 spring 上下文的时间,因此测试总耗时看起来其实非常少(866 个测试包含了单元测试和集成测试)

我们进入一个集成测试文件,选择 Standard output,可能会看到两类日志

  1. 第一类日志很明显启动了 Spring 上下文,如下图

优化 Spring 集成测试的执行时间

  1. 第二类日志没有 Spring 上下文,如下图

优化 Spring 集成测试的执行时间

到这里,耗时都花在哪里这个问题已经大致有结论了,我在这里把耗时粗略归纳两部分:

  1. 测试本身(我们写的测试代码)

  2. 构建测试所需的环境(例如 准备、启动以及销毁 Spring 容器;又例如 启动和销毁内存数据库等)

其中测试本身耗时非常少(除非用了 sleep 或者超大的磁盘IO操作);而构建测试所需的环境就非常耗时,影响这个耗时跟代码库的规模和复杂程度有关,这个小伙伴们可以拿自己的项目 跑上几次集成测试 大概就能得出耗时数据,通常耗时在 10s以上。

想象一下,如果有很多集成测试都需要重新启动上下文,那得有多耗时啊。

Spring 集成测试的原理是什么?

现在我们知道集成测试的耗时都花到哪了,那我们应该如何优化呢?

其实我明着说怎么优化,还是会有小伙伴存在各种各样的疑惑,索性我就带大家来看看源码,了解一下 Spring 集成测试的原理,一旦搞清楚原理,自然就知道如何优化,授人以鱼不如授人以渔嘛。

由于篇幅有限,而且文章主要关注的是优化,因此第三部分我忽略了很多细节,只展示集成测试的主要脉络,至于颗粒度更细的原理,我会考虑再写一篇文章来讲,小伙伴们也可以自己下来看源码。

笔者用的是 JUnit5 和 Spring Boot 2.4.5,这里我就假设小伙伴们对 JUnit5 有一定的知识储备。

优化 Spring 集成测试的执行时间

从上图可以看到,JUnit5 Test Engine 负责执行测试,Spring Test Framework 负责构建集成测试所需的环境,而我们只需要关注 Spring Test Framework 在干什么就好。

构建 TestContext

我们在启动集成测试之后,Spring 框架会先去构建一个 Test Context,入口是 @SpringBootTest,对应的源码如下

优化 Spring 集成测试的执行时间

其中我们只关注 @BootstrapWith(SpringBootTestContextBootstrapper.class)就行,虽然 @ExtendWith(SpringExtension.class)对了解 Spring 集成测试的原理有一些帮助,不过不是本文的重点,暂且忽略。

SpringBootTestContextBootstrapper 提供了很多方法,其中我们最为关注的是 buildTestContext(),对应流程图中的 Build Test Context 步骤,该方法提供了一个测试上下文,为后面构建 Spring ApplicationContext 提供输入。

优化 Spring 集成测试的执行时间

查找/构建 ApplicationContext

这里是最为核心的代码!

这里是最为核心的代码!

这里是最为核心的代码!

准备好 Spring TestContext,接下来就会基于 Spring TestContext 来构建 Spring ApplicationContext,为后续启动 Spring Application 做好准备,这个操作在 DefaultTestContext.getApplicationContext(),源码如下

优化 Spring 集成测试的执行时间

从代码中可以看出,这里会优先从缓存取 ApplicationContext,然后我们进入到 cacheAwareContextLoaderDelegate.loadContext(this.mergedContextConfiguration);

然后我们看看缓存的实现逻辑,源码对应在 DefaultCacheAwareContextLoaderDelegate.loadContext(mergedContextConfiguration)

优化 Spring 集成测试的执行时间

其中对 contextCache 做了同步锁,目的是避免 loadContext(mergedContextConfiguration) 方法执行多次,导致重复启动 Spring Application

看下来我们可以暂时得出两个结论:

  1. Spring 会优先从缓存中取 ApplicationContext

  2. 缓存找不到,会构建新的 ApplicationContext,并把它放进缓存

然后我们又会产生两个疑惑:

  1. MergedContextConfiguration 是什么?

  2. 有哪些因素会影响从 ContextCache 中取 ApplicationContext

对于第一个问题,我们可以打开 MergedContextConfiguration.java 看看描述

优化 Spring 集成测试的执行时间

从描述中我们可以看到,当我们在执行一个测试类的时候,Spring 会把集成测试用到的配置都合并起来,放到 MergedContextConfiguration 中管理,然后把它作为 key 将 ApplicationContext 缓存在 ContextCahce

具体有哪些配置会被合并 小伙伴们可以看看描述和这个类的实现代码,实现代码在 AbstractTestContextBootstrapper.buildMergedContextConfiguration()

对于第二个问题,我们可以看看 MergedContextConfiguration.hashCode()

可以看到影响从 ContextCache 中取 ApplicationContext 的因素还挺多,大概有下面这些:

  1. 集成测试类之间 使用 @ContextConfiguration 自定义不同配置

  2. 集成测试类之间 使用不同的 @ActiveProfiles 配置

  3. 集成测试类之间 使用不同的 @TestPropertySource 配置

  4. 集成测试类 是否有继承父类

  5. 集成测试类之间,是否使用了不同的定制上下文,主要维护在 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 已经提到了,这里也就不重复了。


啰嗦了这么多,第三部分也讲完了,大致可以总结为三点:

  1. 启动每个集成测试类时,考虑到执行效率,Spring 都会优先从 ContextCache 中取 ApplicationContext

  2. 如果从 ContextCache 中取不到 ApplicationContext,则会构建一个新的 ApplicationContext,然后启动 Spring Application,并且将 ApplicationContext 缓存到 ContextCache

  3. 启动每个集成测试类时,Spring 都会把集成测试用到的配置都合并起来,放到 MergedContextConfiguration 中管理,然后把它作为 key 将 ApplicationContext 缓存在 ContextCahce,一旦各个集成测试类的配置有区别,就无法复用 ApplicationContext,导致需要重新加载整个 Spring 上下文。

如何降低集成测试的耗时?

理清楚 Spring 集成测试大致的原理,相信小伙伴都有一个基本的优化策略了(核心目标就是复用 ApplicationContext,才能有效降低执行时间)。

建立规范

我们首先需要在团队内达成一些共识。

这个各个团队可能不太一样,我先按个人经验主义梳理一下,分解下来大致有四个共识:

  • 哪些需要进行集成测试?

不涉及外部依赖的代码,都应该考虑在集成测试的范畴。

例如 Controller、Service、DB(可以使用内存数据库替代)

  • 哪些不需要进行集成测试?

那些会让测试不稳定的外部依赖,都不应该纳入集成测试的范畴。

例如:MQ、FeignClient

  • 如何隔离不需要集成测试的代码?

使用 @MockBean

  • 如何组织集成测试的自定义配置?

优先考虑全局复用,尽量少为测试类搞特殊,然后统一到通用的基类里面管理(包含 @MockBean),写集成测试的时候,直接继承基类。

例如:

重构 @MockBean

慎用 @MockBean!!!

@MockBean 在集成测试经常被乱用,这是主要导致集成测试缓慢的原因之一。很多人为了方便,大量使用 @MockBean,最后写了一堆没意义的测试,而且还让集成测试变得非常慢。

除非外部依赖,最好的做法就是别偷懒,该造的数据还是造吧,集成测试的大部分时间 都花在造数据上,如果不好造,再考虑其他办法,例如可以声明为 @SpyBean,把它挪到基类,然后建个技术债卡,安排时间去重构代码。

下沉单元测试

可以考虑将一些集成测试下沉到单元测试。

单元测试不需要像集成测试那样准备那么多上下文,因此执行时间非常短。

实践起来

实践起来,然后在实践中得出结论,小伙伴们有什么疑问可以留言。

总结

在这篇文章中,一开始通过一些数据重点来突出优化集成测试的重要性,紧接着分析了耗时的组成部分,然后带着小伙伴们大致梳理了 Spring 集成测试的原理,深入理解导致 Spring 集成测试缓慢的根本原因,最后给出了一些优化集成测试的建议,希望能够受用。