测试覆盖率
测试覆盖率是对测试完全程度的评测。测试覆盖率是由测试需求和测试用例的覆盖或已执行代码的覆盖的表示结果。一方面可以衡量测试工作本身的有效性,提升测试效率,一方面可以提升代码质量,减少bug,提升产品的可靠性,稳定性。
代码覆盖率的意义
- 分析未覆盖部分的代码,从而反推在前期测试设计是否充分,没有覆盖到的代码是否是测试设计的盲点,为什么没有考虑到?需求/设计不够清晰,测试设计的理解有误,工程方法应用后的造成的策略性放弃等等,之后进行补充测试用例设计。
- 检测出程序中的废代码,可以逆向反推在代码设计中思维混乱点,提醒设计/开发人员理清代码逻辑关系,提升代码质量。
- 代码覆盖率高不能说明代码质量高,但是反过来看,代码覆盖率低,代码质量不会高到哪里去,可以作为测试自我审视的重要工具之一。
技术框架选型
jacoco
接入
引入依赖
#project build.gradle
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
#module build.gradle
dependencies {
compile 'com.github.Jay-Goo:JacocoTestHelper:v0.0.2'
}
引入脚本配置,jacoco.gradle这个脚本在文章末尾的参考可以找到
apply from: 'jacoco.gradle'
android {
buildTypes {
release {
...
}
debug {
/**打开覆盖率统计开关*/
testCoverageEnabled = true
}
}
}
初始化与测试报告的生成
//PROJECT_PATH '项目路径' + '/app/build/outputs/code-coverage/'
//初始化,可以在程序入口处初始化
JacocoHelper.init(PROJECT_PATH,true);
//生成代码覆盖率ec文件
//可以手动触发,也可以在程序退出时触发
JacocoHelper.generateEcFile(true);
使用流程
- 1.执行./gradlew jacocoInit 初始化工程
- 2.正常使用测试,生成测试报告(demo中是通过按钮生成测试报告,根据场景自行设置)
- 3.测试结束后通过adb命令拉出sdcard目录中的ec文件
- 4.放入到/app/build/outputs/code-coverage/目录下(目录可以自定义)
- 5.执行./gradlew jacocoTestReport
- 6.然后到 /app/build/reports里查看相关报告
测试效果
增量测试覆盖率
对于增量代码的定义
工程中,由于我们的开发方式是每个人新建自己的开发分支进行开发,最后合并到主分支上,所以我们对增量代码的定义是当前分支与指定分支的diff结果
增量代码的实现方式
jacoco本身不支持输出增量测试覆盖率报告,想实现增量代码的技术方案基本有两种
- 修改jacoco源代码
- 脚本工具diff-cover与jacoco配合使用,来完成增量测试覆盖率的实现
方案1短期成本比较高,可以使用方案2先做一个过度,并且提前验证jacoco的准确性,满足业务需要。并且根据使用过程中产生问题或者测试的建议,之后开始修改jacoco的源代码,简化流程,提高效率
diff-cover的引入
环境变量配置参考
https://github.com/Bachmann1234/diff_cover
不支持window系统
使用
与前边使用过程一样,只不过./gradlew jacocoTestReport命令需要替换为如下命令
diff-cover jacoco报告生成的位置xml格式 --html-report 你的报告生成的位置—compare-branch origin/release-8.24.0 --src-roots /源代码路径
测试效果
测试报告解读
JaCoCo包含了多种维度的覆盖率计数器,具体如下
- 行覆盖率:度量被测程序的每行代码是否被执行,判断标准行中是否至少有一个指令被执行。
- 类覆盖率:度量计算class类文件是否被执行。
- 分支覆盖率:度量if和switch语句的分支覆盖情况,计算一个方法里面的总分支数,确定执行和不执行的 分支数量。
- 方法覆盖率:度量被测程序的方法执行情况,是否执行取决于方法中是否有至少一个指令被执行。
- 指令覆盖:计数单元是单个java二进制代码指令,指令覆盖率提供了代码是否被执行的信息,度量完全 独立源码格式。
- 圈复杂度:在线性组合中,计算在一个方法里面所有可能路径的最小数目,缺失的复杂度同样表示测 试案例没有完全覆盖到这个模块。
如图,覆盖率计数器与测试报告一一对应
代码视图报告
- 绿色:表示行覆盖充分。
- 红色:表示未覆盖的行。
- 黄色棱形:表示分支覆盖不全。
- 绿色棱形:表示分支覆盖完全。
jacoco原理分析
JaCoCo主要通过代码注入的方式来实现上面覆盖率的功能
包含了几种不同的收集覆盖率信息的方法,每个方法的实现都不太一样,这里主要关心字节码注入这种方式(Byte Code)。Byte Code包含Offline和On-The-Fly两种注入方式:
- Offline:在生成最终的目标文件之前,对Class文件进行插桩,生成最终的目标文件,执行目标文件以后得到覆盖执行结果,最终生成覆盖率报告。
- On-The-Fly:JVM通过-javaagent指定特定的Jar来启动Instrumentation代理程序,代理程序在ClassLoader装载一个class前先判断是否需要对class进行注入,对于需要注入的class进行注入。覆盖率结果可以在JVM执行代码的过程中完成。
On-The-Fly因为要修改JVM参数,所以对环境的要求比较高,为了屏蔽工具对虚拟机环境的依赖,我们的代码注入主要选择Offline这种方
Offline
Offline的工作流程:
- 在生成最终目标文件之前对字节码进行插桩。
- 运行测试代码,得到运行时数据。
- 根据运行时数据、生成的class文件、源码生成覆盖率报告。
JaCoCo通过ASM在字节码中插入Probe指针(探测指针),每个探测指针都是一个BOOL变量(true表示执行、false表示没有执行),程序运行时通过改变指针的结果来检测代码的执行情况(不会改变原代码的行为)
插入前源码
插入后源码
插桩策略
对于代码
public static void example() {
a();
if (cond()) {
b();
} else {
c();
}
d();
}
编译后转换为字节码:方法内有自己的插入,主要在条件语句中插入
public static example()V
INVOKESTATIC a()V
INVOKESTATIC cond()Z
IFEQ L1
INVOKESTATIC b()V
GOTO L2
L1: INVOKESTATIC c()V
L2: INVOKESTATIC d()V
RETURN
由Java字节码定义的控制流图有不同的类型,每个类型连接一个源指令和一个目标指令,当然有时候源指令和目标指令并不存在,或者无法被明确(异常)。不同类型的插入策略也是不一样的
JaCoCo是用一个布尔数组来实现探针,每个探针对应于该数组中的项。当以下四个字节码指令触发时探针进行输入设置为true
JaCoCo对行探针是这样处理的,添加两行指令之间的一个额外的探针时,后续行至少包含一个方法调用。
源码分析
在插桩中,程序入口是org.jacoco.ant.InstrumentTask,向其传入了两个参数destdir和fileset,分别是存放插入后的字节码文件位置以及字节码文件。
在InstrumentTask类中,由于是自定义gradle Task,所以执行函数是excute(),在instrument()函数中调用Instrumenter类,在instrument(final ClassReader reader)函数中,有以下代码:
final ClassWriter writer = new ClassWriter(reader, 0);
final IProbeArrayStrategy strategy = ProbeArrayStrategyFactory
.createFor(reader, accessorGenerator);
final ClassVisitor visitor = new ClassProbesAdapter(
new ClassInstrumenter(strategy, writer), true);
reader.accept(visitor, ClassReader.EXPAND_FRAMES);
可以看出来,ClassProbesAdapter应该是ASM框架中的适配器(即继承自ClassVisitor,自定义对字节码文件过滤的类),同时在ClassInstrumenter中,发现其visitMethod()函数返回了MethodInstrumenter对象,在该类中,找到了具体的插桩方法。
首先在MethodProbesAdapter中,定义了插桩策略
public void visitInsn(final int opcode) {
switch (opcode) {
case Opcodes.IRETURN:
case Opcodes.LRETURN:
case Opcodes.FRETURN:
case Opcodes.DRETURN:
case Opcodes.ARETURN:
case Opcodes.RETURN:
case Opcodes.ATHROW:
probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
break;
default:
probesVisitor.visitInsn(opcode);
break;
}
}
然后在MethodInstrumenter中具体实现了各个策略。示例:
public void visitJumpInsnWithProbe(final int opcode, final Label label,
final int probeId, final IFrame frame) {
if (opcode == Opcodes.GOTO) {
probeInserter.insertProbe(probeId);
mv.visitJumpInsn(Opcodes.GOTO, label);
} else {
final Label intermediate = new Label();
mv.visitJumpInsn(getInverted(opcode), intermediate);
probeInserter.insertProbe(probeId);
mv.visitJumpInsn(Opcodes.GOTO, label);
mv.visitLabel(intermediate);
frame.accept(mv);
}
}
具体插入是probeInserter.insertProbe(probeId);,它在ProbeInster中被实现
生成报告
生成报告在程序入口在ReportTask中,传入了executionData(多个executionData文件可以合并),sourcefiles和classfiles。其中executionData是运行时被插桩的字节码探针执行情况。
- classfiles:它指定了Java class文件的文件夹。
- sourcefiles:可选的容器元素,指定了相关的源文件。如果源代码被指定,报告将会包含高亮代码。源文件可以被指定为独立文件或者文件目录
根据运行时数据,运行时数据就是classname,id,以及16进制的表示的指针数组文件,具体使用应该会转换成二进制,表示覆盖率执行的结果,加class文件路径解析出类覆盖,方法覆盖等结果,再结合源代码路径生成高亮,可视化强的代码报告,就是我们上图看到html测试覆盖率文件
编译调试信息
java编译成class可以生成调试信息,这样就可以从类文件中拿到对应class的信息,计算行的覆盖率并让源码高亮显示
-g:source Class文件中固定长度的SourceFile属性,编译时如果选择了生成此属性,则会将它写到Class文件中。它用来提供产生Class文件的源文件名称
-g:lines 将源文件中的行号信息写到Class文件中,此属性用于在Class文件中生成方法字节码流偏移量和源代码行号之间的映射关系。就像我们引子中所看到的,打印出的异常栈中看不到行号,就是因为生成Class中没有此属性导致,而远程调试时打不上断点也是这个原因
-g:vars属性建立了方法的栈帧中局部变量部分内容与源代码中局部变量名称和描述符之间的映射关系。简单一点说,如果没有此属性的信息,那我们在调试时将无法看到变量值
接入过程的问题
diff-cover的报错
No lines with coverage information in this diff
一般出现该问题,主要是路径原因,可尝试使用绝对路径,与jacoco搭配使用,需要使用--src-roots命令引入源代码
diff-cover的环境
diff-cover目前只支持mac环境
混淆问题
jacoco不能混淆,jenkins打的release包,反射调用代码找不到
需要配置
-keep class org.jacoco.** { *; }
-keep interface org.jacoco.** { *; }
支持kotlin
1.增加java源码路径
def coverageSourceDirs = [
'../xxx/src',
'../xxx/kotlin'
]
2.编译后class插桩路径
def classTree = fileTree( dir: 'xxxxxx')
def kotlinTree = fileTree(dir: './build/tmp/kotlin-classes/debug')
classDirectories = files([classTree], [kotlinTree])
release与debug
本质上我们只需要打debug包就行,release不需要配置,但是由于某种原因jenkins平台打的是release包,为了不影响原有的工作流程,所以release也支持了测试覆盖率
但是在执行脚本的过程中,release包需要classDirectories为build下release的路径
debug包需要classDirectories为build下debug的路径
即使jenkin打包本地生成,也需要走release的路径,本地需要打release包,构造出相应的build路径,没有签名怎么办?使用debug签名即可
参考
diff-cover https://github.com/Bachmann1234/diff_cover
jacoco接入 https://github.com/Jay-Goo/JacocoTestHelper
https://testerhome.com/topics/16376
https://tech.meituan.com/2017/06/16/android-jacoco-practace.html
https://www.eclemma.org/jacoco/trunk/doc/counters.html
https://mp.weixin.qq.com/s?__biz=MzIxNzEyMzIzOA==&mid=2652314564&idx=1&sn=a93e6154c92acaef9204b8440e66a852&scene=21#wechat_redirect
https://blog.csdn.net/ohcezzz/article/details/78416125
https://www.jianshu.com/p/639e51c76544
https://www.iteye.com/blog/daimojingdeyu-679030