作者|vivo互联网服务器团队-沉旭本文主要介绍了在vivo内部研发平台使用JaCoCo实现测试覆盖率的实践,包括JaCoCo原理的介绍和存在的问题实践过程中遇到的新增代码覆盖率统计问题以及频繁发布导致覆盖率下降问题的解决方案。一、为什么需要测试覆盖率1.1在日常的研发过程中,经常会发现一些问题。测试用例的设计基于经验。开发新功能时,往往会低估测试场景,上线后才发现bug;开发中经常会把一些要求改成其他代码(小范围的代码重构或者开发过程中发现的小缺陷),会导致测试任务无法测试相应的场景,导致上线问题;测试结果无法量化评估,导致测试工作质量无法进一步提升。1.2.有没有什么技术手段可以尽量避免上述问题?代码覆盖率在业界被广泛用于提高测试质量,那么什么是代码覆盖率呢?代码覆盖率是软件测试中的一种度量,它描述了源代码在被测程序中所占的比例和程度,所得到的比例称为代码覆盖率。代码覆盖率指标通常包括以下几类:函数/方法覆盖率:分支调用了多少函数/方法覆盖率:控制结构(如if语句)执行了多少分支条件覆盖率:布尔子类有多少表达式被测试为true和false行覆盖:测试了多少行源代码1.3在使用测试覆盖的过程中,经常发现在if/else语句中,if{}里面的代码被覆盖,else{}里面的代码没有覆盖,可以断定部分分支场景没有测试;在try/catch语句中,try{}中的代码被覆盖,catch{}中的代码没有被覆盖,可以断定异常场景没有被测试;在if(条件1||条件2||条件3)语句中,条件1被覆盖,条件2和条件3没有被覆盖,可以断定有些条件场景没有测试到;测试人员正确使用代码覆盖率指标,可以有效提高测试质量,进而提高版本发布的质量。2.JaCoCo在测试覆盖场景中的使用2.1JaCoCo介绍目前主流的代码覆盖工具:C/C++→Gcov、Java→JaCoCo、JavaScript→Istanbul。考虑到服务端主要是Java语言,CICD平台优先使用JaCoCo来支持Java语言的代码覆盖率统计能力。通过JaCoCo的官网,我们可以看到JaCoCo的使命是提供JavaVM环境下代码覆盖率分析的标准技术。重点是提供一个轻量级、灵活且文档齐全的库,用于与各种构建和开发工具集成。2.2JaCoCo的优势JaCoCo支持指令(C0)、分支(C1)、行、方法、类、圈复杂度的多维覆盖分析;基于Java字节码,无需源文件也能运行;性能好,运行时开销很小,特别适合大型项目;API比较完善,很容易与其他工具集成;远程协议和JMX控制可以在任何时间点执行代理请求的数据下载。2.3JaCoCo的原理主要来源于JaCoCo官网。JaCoCo支持多种不同的方法来收集覆盖率信息。对于每种方法,它由不同的技术实现。下图中橙色路径是JaCoCo推荐的方法,即通过On-The-Fly的方式收集覆盖信息:从上图我们知道JaCoCo通过在Java字节码(ByteCode)中插入探针来收集覆盖信息.探测是可以插入现有指令之间的附加指令。.它们不会改变方法的行为,但会记录它们已被执行的事实。我们以一个简单的程序为例:这段代码经过Java编译后转换成如下字节码:由于Java字节码指令的线性顺序,控制流是通过条件指令或无条件指令跳转实现的,跳转目标在技术上是相对于目标指令的偏移量。这类似于大学里学过的汇编指令的跳转方法。为了更好的可读性,使用符号标签(L1,L2)代替实际的指令地址。上图中橙色部分为插入的探头。理论上,我们可以在控制流图的每条边上插入一个探针。由于探针实现本身需要一些字节码指令,这将增加类文件数倍的大小;幸运的是,这不是必需的,事实上我们只需要根据方法的控制流程为每个方法插入几个探针。例如,一个没有任何分支的方法只需要一个探针。如果执行了探测,我们就知道相应的边已经被访问过。从这条边我们可以得出其他前面的节点和边的结论:如果一条边被访问过,我们就知道这条边的源节点已经被执行过;如果一个节点已经被执行过,并且只有一条边的目标,我们就知道这条边已经被访问过了。如果我们在正确的地方有探针,递归地应用这些规则就可以确定一个方法的所有指令的执行状态,探针只是一小部分额外的指令,需要插入到控制流的边缘。3.CICD平台针对测试覆盖的解决方案通过上面JaCoCo原理的介绍,结合我司内部研发流程,CICD平台的代码覆盖功能设计如下:从上述CICD平台的设计来看测试覆盖率从图中可以看出,整个流程包括三个阶段3.1测试前,测试人员(开发/运维人员)在测试前对流水线开启测试覆盖率功能,发布时在pipeline,会在测试环境下载JaCoCoAgent包,并在Java进程启动时配置JavaAgent参数;进程启动期间或之后,加载一个class文件时,被Agent拦截,将class文件插入到stub中,在必要的路径中插入probe(inserttheprobe)针的原理上一节已经介绍过)。3.2测试期间测试期间,测试人员在测试环境中执行测试用例(手动执行或自动化脚本),调用的代码会被探针记录下来,探针数据存储在Java进程的内存中。3.3测试结束后,测试人员可以多次发布测试环境。对于同一个分支的代码,可以将多次测试的结果数据合并,形成全量的覆盖数据;测试结束后,CICD平台可以手动/自动下载(dump)覆盖率数据,合并(merge)历史覆盖率数据,生成测试覆盖率报告;测试人员根据测试覆盖率报告的结果检查遗漏的场景,进行补充测试,事后总结遗漏的原因,提高测试效率。4.实践过程中遇到的问题及解决方案测试覆盖率运行了一段时间后,在实践过程中发现了一些问题,总结如下:4.1编译造成的classid不一致的问题不同的机器在实践中,经常会遇到这样的问题。用户反馈确认案例已正常执行,但生成的报告显示未覆盖。经排查,发现测试环境中的类与生成报告时的类不一致。在JaCoCo内部,覆盖率数据以classid作为键存储。classid是根据类的字节码哈希算法得到的。参见JaCoCo源码中classid的算法如下:不一致的地方包括:发布时编译有机器和生成报告的机器环境有差异,比如操作系统版本,JDK版本等,导致编译类中的不一致;发布时编译的代码版本与报表生成时的代码版本存在差异,导致编译类不一致。要解决上述环境问题,需要在测试覆盖过程中保持编译机环境一致,或者只编译一次,使用同一个class文件。考虑到存储空间的问题,vivo采用了保持环境一致的方式。解决。对于第二种情况,在采用敏捷开发的团队中很常见。在一个版本中,测试是根据功能点转移的,往往会导致测试失败。测试过程中修改了源代码。报表生成时的代码版本和发布时的代码版本已经不一致,这种情况比较复杂,我们在下面介绍。4.2更加注重研发过程中增量代码的覆盖率。在我们日常的研发活动中,更多的是使用自动化脚本来返回全量代码,而新开发的功能主要针对增量代码,对于增量代码的覆盖率情况比较关心,JaCoCo本身并不支持增量代码报道。这个问题网上也有很多解决方法,基本都是根据git的版本差异来解决的。生成报告时,过滤掉没有差异的类,形成两种覆盖率报告,一种是全量代码覆盖率报告,另一种是增量代码覆盖率报告。但我们更愿意在一份覆盖率报告中呈现增量代码和全量代码的覆盖情况,在全量报告中结合代码的覆盖路径分析缺失的场景,并在报告中标注增量代码和增量代码的预期效果代码覆盖率如下图所示:为了达到上述效果,需要进行几个改造步骤:计算当前代码分支的变化,需要将JaCoCo计算逻辑准确修改到代码行,对于单独代码的增量统计覆盖率指标值改革JaCoCo报告格式,兼容报告中的全代码和增量代码覆盖率对于代码分支变化的计算,摒弃了GitLab提供的获取不同版本之前差异信息的代码对比功能,如果版本差异太大,GitLab的API接口调用经常会超时;而GitLab的比较功能不能满足定制化的场景,比如某行代码仅仅因为格式化就被识别为改变的代码等,利用Linux自带的diff命令,能够实现代码差异比较:对于改造JaCoCo计算逻辑,增加增量代码的覆盖率指标统计,在CoverageNodeImpl类中新增Counter,用于统计新类、方法、行、指令的覆盖率指标;在SourceNodeImple类的increment方法中添加新代码行的统计逻辑。4.3重温classid的问题。上面已经讨论了classid的问题。如果是环境问题,还不如解决。但是现在互联网团队基本上都是用敏捷模式。必然会导致最新的覆盖率报告,会出现以类为单位的覆盖率数据丢失。测试人员需要反复来回执行测试用例,否则测试覆盖率数据就不好看了。现在我们知道问题是什么了,有没有办法解决呢?是否可以直接找到之前的classid,将之前classid对应的探测数据复制到当前classid?当然不可能,因为源代码发生变化,导致探针数量发生变化,会出现以下情况:如果探针是一致的,探针的位置可能会因为修改代码而改变:那么这个问题是不是无解呢?这是一个总体思路。当前覆盖率数据以类为单位存储。我们可以修改存储的粒度,细化到方法级别,这样可以保留一个类的大部分探测数据。如果只有一个方法,那么其他方法的测试数据可以继续保留,只需要重新测试这个方法,可以有效减少测试人员重复测试整个类的所有解的情况。五、总结至于测试覆盖率功能,是否提高了测试质量,答案是显而易见的。当然,因为上面提到的问题,也给测试人员带来了一些困扰。为了提高测试覆盖率数据,测试人员对同一个功能进行了多次测试;同时也给测试人员带来了收益。面对测试覆盖率指标的严格要求,我不得不去查看代码的实现逻辑,提高了自己的业务水平和代码阅读水平。甚至出现了测试人员和开发人员就代码逻辑是否合理进行对峙的场景。最后,测试覆盖率并不是衡量测试质量的唯一标准,应该合理利用测试覆盖率来提高测试质量。
