讯息:一次单元测试优化的过程总结
2022-09-15 10:02:16来源:大淘宝技术
淘宝原用户增长团队(现用户运营平台团队)是比较早践行单测增量覆盖率的团队,坚持了近两年下来,我们积累了数千个test case,在开发新功能、修改原功能的过程中帮助我们发现了许多问题,显著地提升了代码质量、减少线上故障。在这里郑重地向大家推荐,单测是值得认真做的,开头是痛苦的,但是积累一段时间后,量变就会带来质变。
(资料图)
言归正传,接下来谈一谈最近在实践单测过程中遇到的一个问题。在研发协同平台aone(下文简称aone)的发布流水线中,我们针对单元测试设置了增量代码覆盖率85%和test case 100%通过的流程卡点,在每次发布前,要保证test case完全通过才能提交工单。我们遇到了因并发导致的test case失败,调整并发度导致的单测时间过长,但又影响研发效能的问题。最终在并发度和成功率之间找到了一个平衡点,解决了单测流程降低研发效率的问题。
单侧流水线配置在单测流程中呢,我们主要用到了JUnit、JaCoCo和Surefire三套工具,通过aone提供的容器自动化运行单元测试,搜集测试报告。下面简单介绍一下这三个工具。
▐ JUnitjava界最大名鼎鼎的单元测试框架,无须多言,会java的应该都知道。
▐ JaCoCoEclEmma团队开发的开源代码覆盖率统计工具,也是java业内最主流的代码覆盖率统计工具。增量代码覆盖率就是通过该工具进行统计的,全量、增量、按类、包统计都支持,非常灵活。
▐ Maven Surefire Pluginsurefire是maven的一个插件,在maven生命周期的test阶段执行单元测试用例。运行完成后还会生成测试报告,方便用户查看单测情况。
我们利用三种工具,加上aone提供的容器和流水线配置能力,完成了自动化单测的流程和发布卡点校验。
单元实践过程▐ 两个阶段积累test case时期在刚刚开始单测时,大家新增的代码都相对比较独立,随着业务的发展、工作职责的调整,单测会不断变复杂,不同的service之间互相交织、单测的维护、运行成本都会增加。我们在这个阶段遇到了一个比较棘手的问题。日常开发过程中,单测都是以类为粒度在本地跑的,都能通过后再去流水线验证,一旦提交到流水线,就会遇到个别case失败的问题,一开始排查起来完全没有思路,test case的失败可以说是随机的,任何一个类的任何一个用例都有可能失败。
经过分析和排查,得出结论是并发导致的,于是我们限制了并发,做了如下配置,确实解决了这个问题。
org.apache.maven.plugins maven-surefire-plugin 2.16 false 1
大家可以留意一下reuseForks和forkCount参数,这时候我们还没有深究两个配置的含义,只是简单的限制了并发,这也为后续的故事埋下了伏笔。
test case达到一定规模时期在完成了test case的初始积累以后,新的问题又随之而来。因为没有并发,test case又很多,所以每次单测运行时长长达50分钟。也严重影响了大家的研发效率。在分秒必争的发布窗口期,经常会出现大家等着单测跑完提交发布单的情况。
▐ 问题看了上述两个不同阶段反映的问题,本质上就是成功率和实效性的trade off问题,如何能提高并发、提升运行速度的同时保障成功率,这就是我们需要解决的最终命题。
▐ 原理和解决方案上文提到了reuseForks和forkCount参数,这些都是maven-surefire-plugin提供的配置项,把surefire插件研究清楚了,应该就能解决如何兼顾速度和实效性的问题。
Surefire配置详解parallel
jvm内并行执行
通过parallel参数开启,可选为methods,classes,both,suites等
其他参数
useUnlimitedThreads,不限制线程数threadCount,线程数perCoreThreadCount,每核(默认true,和threadCount组合使用)parallelTestsTimeoutInSeconds,timeout时间注
设置了parallel后,useUnlimitedThreads或者threadCount必须设置一个,不然会报错parallel级别还有suitesAndClasses等更复杂的配置项,本文不多探讨参数示例如下,代表methods级别并发,10条线程执行。
org.apache.maven.plugins maven-surefire-plugin 3.0.0-M7 methods 10
fork
多jvm并行执行forkCount 最多同时生成的JVM个数,特殊语法是nC,代表n倍的CPU核数,2.5C在4核机器上就是10的意思。reuseForks 是否重复使用fork出的JVM,true代表一个测试类运行完后,进程继续处理下一个,false代表一个类运行完了JVM销毁,重新生成新的JVM默认配置 forkCount=1/reuseFork=true,forkCount设置为0会被自动替换为1
parallel和fork
parallel和fork组合后,就可以有更好的并发效率,也会带来更大的冲突可能。
并发导致case失败原因surefire的文档原文如下,
简单说来,就是因为JUnit的实现机制,对于JVM内的线程并发,会出现一些race condition或者其他难以复现的问题;对于forkCount大于1且开启复用的情况,因为测试类是在复用的JVM内,也会因为相同的原因产生并发问题导致测试失败。
结果和建议在彻底搞清楚surefire的配置原理后,我们回到问题来。经过各种排列组合的尝试,我们得出了比较合适的配置,reuseForks=true/ forkCount=2C,最终效果是每次运行时间在10分钟左右,出错概率较低,通过重跑也能解决。
小tip
mvn默认是按模块串行的,可开启并行提高整体速度(例:mvn -T 1C clean test),但是在我们的场景下,2000多个test case有1800个都在一个模块里,所以开启并行的效果不大。
其实这个问题没有最优组合,只有最合适的组合。在优化了这个单测耗时最久的应用后,我们又分析了其他几个应用,有的应用test case不多,单测运行时长不长,就没有必要开启并发,优先保证成功率即可;有的应用test case直接相互干扰较小,并发度可以调整得更高……
总的来说,在弄明白了原理之后,还需要具体情况具体分析,“纸上得来终觉浅,绝知此事要躬行”,大家可以分析一下自己应用的情况,结合surefire的并发机制进行实践,相信测过几次以后就能找到最合适的配置组合。
单元实践过程在整个过程中,笔者还留有两个想法:
有没有办法通过提高单测代码质量来避免或者降低因为并发引起的失败?一些思路是通过suite分组,将可能冲突的类分开跑,这样的做法可能会极大的提高单测开发成本,投入产出比不高。test case通过率可以不用严格卡100%,设定到99.5%都能显著的提升效率,因为每次失败的test case是不固定的,所以偶发的个别问题不影响整体的回归。在实践卓越工程的过程中,笔者深切的感受到纵观整个软件研发的生命周期,有很多值得研究和切入的点,一些微小的改动,都能有效地提升研发效能和交付质量。在当前的环境下,业务竞争日趋激烈,所谓开源节流,“开源”难,重心就会偏向“节流”,降本增效一定会是下一个阶段的重点。而且对于技术人来说,效率一定是永远的追求。其实提升性能、效率往往不是特别高大上的事情,希望大家能在日常繁重的工作之余,有点时间做些有趣的研究,享受技术带来的快乐!