【得物技术】代码覆盖率原理与得物app实践
一、前言
随着项目迭代的不断深入,工程逻辑与用户场景日益复杂,传统的白盒测试体系已经无法适应苛刻的工程质量要求,质量评估也不再单纯的依赖bug率和性能指标,而需要精准的数据来量化代码质量,代码覆盖率就是其中的一项重要标准。
二、代码覆盖率简述
2.1 什么是代码覆盖率
代码覆盖率测试技术是一种常见的白盒测试技术,是衡量软件测试工作充分性和完整性的重要指标之一。
简单来说,代码覆盖率就是测试过程中已经被执行过的代码占准备测试总代码量的比例和程度,它关注的是在执行用例时,有哪些代码被执行到了,有哪些代码没有被执行到。
2.2 代码覆盖率的价值
代码覆盖率的分析在一定程度上能够评判代码质量,一般覆盖率高的代码出错的几率会相对低一些。但是高覆盖率的代码只能表示执行了很多的代码,并意味着这些代码被很好的执行了。
首先,对于测试来说,代码覆盖率最主要的意义是帮助我们了解测试情况,可以通过分析未覆盖部分的代码,从而反推在前期测试设计是否充分,没有覆盖到的代码是否是测试设计的盲点,之前为什么没有考虑到?或许是需求/设计不够清晰,测试设计的理解有误,工程方法应用后的造成的策略性放弃等等,之后进行补充测试用例设计。
其次,其有助于发现多个测试用例都覆盖不到的代码,可以逆向反推在代码设计中思维混乱点,提醒设计/开发人员理清代码逻辑,提升代码质量;同时为废弃代码提供依据。
此外,代码覆盖率可以度量单元/自动化测试用例,提供覆盖率统计情况,可以通过分析覆盖率报告,完善用例。
最后,代码覆盖率利于精准回归,通过构建代码调用关系,精准的确定回归测试范围,避免了全量回归造成的测试资源浪费。
2.3 常用指标
语句覆盖:
又称行覆盖(LineCoverage),指已经被执行到的语句占总可执行语句(不包含类似C++的头文件声明、代码注释、空行等等)的百分比,这是最常用的也是要求最低的覆盖率指标,实际中通常会结合判定覆盖率或者条件覆盖率一起使用。
判定覆盖:
又称分支覆盖(BranchCoverage),用以度量程序中每一个判定的分支是否都被测试到了,即代码中每个判定的"真"和"假"至少执行一次。
这句话是需要进一步理解的,应该非常容易和下面说到的条件覆盖混淆。因此我们直接介绍第三种覆盖方式,做个对比,就明白两者是怎么回事了。
条件覆盖:
它度量判定中的每个子表达式结果true和false是否都被测试到了。
路径覆盖率、函数覆盖率、类覆盖率、指令覆盖率等指标
为了说明判定覆盖和条件覆盖的区别,我们来举一个例子,假如我们的被测代码如下:
int foo(int a, int b)
{
if (a < 10 || b < 10) // 判定
{
return 0; // 分支一
}
else
{
return 1; // 分支二
}
}
在设计判定覆盖的测试用例时,我们只需要考虑到判定结果为true和false两种情况,因此我们只需要设计如下的case,就能达到判定覆盖率100%:
a = 5, b = 任意数字
a = 15, b = 15
设计条件覆盖案例时,我们需要考虑到判定中每个表达式的结果,为了达到覆盖率100%,设计了如下案例:
a=5 (条件a<10的值为“真”)
a=15 (条件a<10的值为“假”)
b=5 (条件b<10的值为“真”)
b=15 (条件b<10的值为“假”)
通过上面的例子,应该很清楚的了解了判定覆盖和条件覆盖的区别。
需要注意的是:条件覆盖不是将判定中的个条件表达式的结果进行排列组合,而是只要每个条件表达式的结果true和false测试到就可以了。
同时,我们可以这样推论:完全的条件覆盖并不能保证完全的判定覆盖。
2.4 JAVA覆盖率工具介绍
目前Java常用的覆盖率工具有JaCoCo、Emma和Cobertura、Clover(商用),详细介绍请看下表:

其中Emma和Cobertura已经停止维护了,只有JaCoCo还在不断的更新,JaCoCo社区也比较活跃,所以现在使用的最为广泛的就是JaCoCo了。
目前我司选用的也是JaCoCo。下面让我们一起来看下JaCoCo的原理、使用和在公司的实践吧。
三、关于JaCoCo
3.1 JaCoCo简述
JaCoCo是一个开源的覆盖率工具
(官方文档地址:
https://www.jacoco.org/jacoco/trunk/doc/index.html),
它针对的开发语言是java,其使用方法很灵活,可以嵌入到Ant、Maven中;可以作为Eclipse插件;也可以使用JavaAgent技术监控Java程序等等。
很多第三方的工具提供了对JaCoCo的集成,如sonar、Jenkins等。
3.2 JaCoCo原理简介
JaCoCo到底是怎么手机覆盖率信息的呢?
插桩
何谓插桩?
用通俗的话来讲,插桩是将一段代码通过某种策略插入到另一段代码,或者替换另一段代码,来收集程序运行时的动态上下文信息。
这里的代码既可以是字节码也可以是源码。
JaCoCo就是字节码插桩方式。
其中字节码插桩又分为on-the-fly和offline的两种模式。
3.2.1 on-the-fly模式
在JVM中通过添加-java agent参数指定特定的jar文件启动Instrumentation的代理程序,代理程序会在ClassLoader装载一个class前判断是否已经转换修改了该文件,如果没有则将探针插入class文件中,探针不改变原有方法的行为,只是记录是否已经执行。
3.2.2 offline模式
在测试之前先对文件进行插桩,生成插过桩的class或jar包,测试插过桩的class和jar包,生成覆盖率信息到文件,最后再统一处理,生成报告。
3.2.3 on-the-fly和offline对比
on-the-fly更方便简单,无需提前插桩,无需考虑classpath设置问题。
以下情况不适合使用on-the-fly模式:
如下图,包含了几种不同的覆盖率信息的收集方法,其中带颜色的是JaCoCo比较有特色的部分:

图片来源:官网*
关于JaCoCo具体的注入原理,在官方网站上写的非常详细了,网上翻译修改的资料也非常多,这里不做过多赘述。
经过对比,我们在统计功能测试覆盖率以及集成测试覆盖率时,选择的是On-the-fly模式。
原因是On-the-fly方式无须入侵应用启动脚本,只需在 JVM 中通过 -javaagent 参数指定JaCoCo的代理程序。
四、 JaCoCo使用方式
4.1 Apache Ant方式
参考:JaCoCo Ant方式使用
https://www.eclemma.org/jacoco/trunk/doc/ant.html
4.2 Apache Maven方式
参考:JaCoCo Maven方式使用
这种方式适合maven项目。
https://www.eclemma.org/jacoco/trunk/doc/maven.html
4.3 Eclipse EclDmma Plugin方式
参考:JaCoCo Eclipse使用
这种方式主要和eclipse集成,用户可以直观的看到覆盖率的情况。
https://www.eclemma.org/
4.4 命令行方式
官方文档上详细介绍了用到的参数和用法,其主要使用如下JVM参数来激活Java agent代理:
-javaagent:[yourpath/]jacocoagent.jar=[option1]=[value1],[option2]=[value2]
但是它也接受一些其他的参数,详情可查看官方文档。
目前我司是使用此方式来统计代码覆盖率的。下面一起来看下具体是如何使用的吧!
4.4.1 更改server 的启动脚本,使用jacocoagent.jar启动服务;
4.4.2 生成覆盖率报告;
理论上不用杀server进程就可以直接copy到最新的exec文件,但是如果遇到报告结果是空的情况,可以考虑先kill server进程,再拷贝exec文件。
参考官方demo的有具体的示例:ReportGenerator.java
https://www.eclemma.org/jacoco/trunk/doc/examples/java/ReportGenerator.java
部分代码如下:
public testjacoco(final File projectDirectory ) {
this.title = projectDirectory.getName();
this.executionDataFile = new File(projectDirectory, "scfzzpostjacoco.exec");
//目录下必须包含源码编译过的class文件,用来统计覆盖率。所以这里用server打出的jar包地址即可
this.classesDirectory = new File(projectDirectory, "/");
// this.sourceDirectory =null;
//源码的/src/main/java,只有写了源码地址覆盖率报告才能打开到代码层。使用jar只有数据结果
this.sourceDirectory = new File("/opt/RD_Code/server/zhuanzhuan_scf_zzpost_4-0-38_BRANCH/service/", "src/main/java");
//coveragereport为要保存报告的地址
this.reportDirectory = new File(projectDirectory, "coveragereport");
}
这里主要是调用testjacoco()方法来做入口生成报告。
其中,
this.title是报告的标题;
this.executionDataFile是第一步生成的exec的文件;
this.classesDirectory是源码的class文件,只要传递class所在目录就可以(或者用编译过的jar包也可以),不传递会报错,用来统计覆盖率 ;
this.sourceDirectory是源码所在目录,可以不赋值使用null,但这种覆盖率结果只有看到方法名级别,不能直接看到方法中具体的覆盖结果 。
这一步完成之后,我们就可以在通过在浏览器查看html报告,来具体的分析代码覆盖率。
五、得物app实践
目前我们基于公司的业务模式,对JaCoCo做定制化改造,搭建了覆盖率平台,支持产出增量覆盖率和全量覆盖率报告,也支持手动更新全量覆盖率数据。
由上文我们可以了解到,我们使用JaCoCo最终目的是需要生成覆盖率报告,可以拆分为以下几个步骤:
测试完成之后生成的exec统计文件;
插桩后的classes文件;
本次部署的服务在gitlab上的代码;
利用JaCoCo的api生成报告。
目前覆盖率平台一期只支持全量覆盖率,是通过定时执行接口自动化任务来获取数据的。
那具体实现流程是怎样的呢?实践过程中遇到了哪些问题,又是如何解决的呢?
一起来了解一下吧!
5.1 获取生成的exec统计文件
首先我们要修改服务的启动脚本,带上JaCoCo用以插桩并监听启动参数。
put=tcpserver,port=33511,address=10.11.22.19,append=true
其中,
/home/ops/testjacoco/lib/jacocoagent.jar是JaCoCo jar包的目录;
includes=com.sz.* 是对包进行过滤;
output=tcpserver表示以tcpserver方式启动应用并进行插桩;
port=33511是jacoco开启的tcpserver的端口,请注意这个端口不能被占用;
address=*.*.*.* 是对外开放的的tcpserver的访问地址,可以配置127.0.0.1,也可以配置为实际访问ip:
配置为127.0.0.1的时候,dump数据只能在这台服务器上进行dump,就不能通过远程方式dump数据;
配置为实际的ip地址的时候,就可以在任意一台机器上(前提是ip要通,不通都白瞎),通过ant xml或者api方式dump数据。
在实践的时候发现脚本里不能写死监听端口,因为服务器上启动了多个应用服务,且端口都是随机的,一旦端口被占用会导致应用启动失败。
为了解决这个问题,我们在启动服务的时候先执行shell脚本获取未占用的端口,给JaCoCo使用。
其次,覆盖率平台服务需要获取每个服务的端口信息。JaCoCo自身是通过Jsch在应用服务器上执行命令,获取被统计应用服务所使用的JaCoCo端口。但是Jsch在只能执行单条指令,对shell有限制,所以我们自己编写了一个脚本,将服务信息和端口信息先输出到文件中(如下图),再将文件传输到覆盖率平台所在服务器。

使用JaCoCo启动了服务器,拿到了端口号,第三步就是需要执行接口自动化,获取exec统计文件,我们通过启动定时任务,定时执行脚本,获取覆盖率数据。
为了获取纯净的覆盖率数据,执行脚本的时间,既不能影响测试流程又需要在自动化case运行完成的时候统计,单纯使用定时任务并不能很好的满足目前需求。因为接口自动化case运行时长不定、服务部署时长也不固定。在这里我们通过记录两个运行状态,来决定统计的时间。
在使用scheduled任务的时候遇到了一个问题,在定时任务类中使用@Autowired注解时,会报空指针异常,因为mapper实例化时为null,不能调用mapper实现中的方法,因为Spring的Schedule是通过Quartz实现的,但默认时, 并不直接支持ApplicationContext 。在实际项目中,我们用一个类实现了ApplicationContextAware接口。这样,这个类可以直接获取 Spring 配置文件中,所有有引用到的Bean对象。
实现代码如下:
@Component
public class WorkUtil implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
context = applicationContext;
}
public static ApplicationContext getApplicationContext() {
return context;
}
public static Object getBeans(String name) {
return getApplicationContext().getBean(name);
}
}
我们在使用时,直接调用getBeans()方法即可获取bean对象。
5.2 获取classes文件
得物服务的代码,为多moudle模式,打jar包时,一些实现业务逻辑moudle被打成jar包跟一些第三方依赖包混淆在一起。而我们统计覆盖率的时候,只需要统计放真正代码逻辑的jar包,如果包含一些第三方依赖包含的东西太多会显得很杂乱。
通过修改JaCoCo源码,在字节码处理的时做判断,将公司的业务代码从整个project中拆出来,然后在生成报告这步中进行聚合,生成一个较为简洁的报告。
在最后做统计的时候,JaCoCo需要插桩,
if (!(Object instanceof ColomboComponent)){
continue;
}
5.3 获取git上的源码
这一步主要是通过JGit实现的,此过程主要是将git上的源码同步覆盖率平台上,在平台所在服务器对所需要的的分支进行切换更新等操作。1)此过程中主要碰到过git pull 代码 Auth fail,经检查代码账号信息没有问题,主要是连接超时会导致该问题,文件过大也会导致这个问题通过设置git配置解决此问题,配置如下:
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999
git config http.postBuffer 524288000
5.4 生成覆盖率报告
前面的准备工作都做好了,再生成覆盖率报告的时候发现,由于版权保护问题,修改了JaCoCo源码会导致服务编译不通过。梳理了代码逻辑,确认代码各个方法都没问题,最后在代码中添加了如下注释,编译成功!
具体注释如下:
/**
* Encapsulates the tasks to create reports for Maven projects. Instances are
* supposed to be used in the following sequence:
*
*
* - Create an instance
* - Load one or multiple exec files with
*
loadExecutionData()
* - Add one or multiple formatters with
addXXX()
methods
* - Create the root visitor with
initRootVisitor()
* - Process one or multiple projects with
processProject()
*
*/
在平台化的过程中,大部分的时间都放在了修改JaCoCo源码上。
因为JaCoCo源码中,用到的全局变量比较多,如果不从头开始读,无法发现这个变量是什么时候被赋值的,代码中也会包含以下类似于s1、s2这中变量名,读起来比较费劲,只能通读整个代码,同时也有助于我们更深入的了解了JaCoCo的实现。
我们也在不断的对平台的功能进行完善。除了二期实现增量覆盖率功能以外,后续也会将覆盖率的统计功能做的完善一些。在积累数个版本的全量覆盖率之后,可以建立预警机制,输出每日开发自测、测试人员手动测试、自动测试覆盖率,分析合理的增长趋势。如果偏离该趋势,则及时进行预警。也可以和用例平台打通,获取到每条用例覆盖到的函数和影响到的接口,通过更小的维度更精准地度量测试质量。
六、总结
通过引入代码覆盖率分析体系,我们可以精确把控增量代码质量,持续改善优化存量代码。同时也可以将测试用例的影响范围细化到代码层面,从而实现精准化测试。
但是,盲目的追求代码覆盖率是没有意义的,即使已经达到了100%的代码覆盖率,软件的质量也不可能做到万无一失,因为代码覆盖率的计算是基于现有代码的,并不能发现那些「未考虑某些输入」以及「未处理某些情况」形成的缺陷。
并且在追求更高的代码覆盖率时,我们需要补充更多的case,去覆盖更多的代码,这样测试成本也会以指数级的方式迅速增加,花费的时间成本会也会更高。
所以,我们在实际工作中,需要正确恰当地应用代码覆盖率,使其能够帮助我们更精准地定位和分析问题,保证产品质量,为精准测试添砖加瓦,发挥它的最大价值。
文/Snowdreams
关注得物技术,带你走向技术的云端