项目背景
在软件开发交付过程中,测试团队一般会按照编写好的测试用例进行应用质量和功能需求的验收,测试主要依靠需求及一些测试经验来主观保证质量,凡是涉及人为的动作,或多或少会出现一些漏洞,比如测试用例覆盖范围是否全面,测试人员交叉验收是否完整等,根据我们历史经验和教训,归类以下几种场景:
功能包含多种场景,比如代码中包含多个if-else if-else逻辑处理,测试人员少数场景或异常代码并未执行到;
功能提测后,研发同学做了某些技术层面的代码修改,未能及时同步到测试人员,出现漏侧情况;
某些自动化测试中,无法确定是否覆盖全所有的代码逻辑,其可靠性无法评估;
项目意义
1、能够分析出开发/测试过程中未覆盖部分的代码,从而倒推在前期开发过程研发同学自测是否充分,后期测试同学用例设计是否全面,没有覆盖到的代码是否是研发和测试的盲点?为什么自测/用测没有考虑到?是需求/设计不够清晰,还是开发/测试的理解偏差导致的?
2、检测出程序中是否存在废代码,可以反向推理代码开发设计中思维混乱点,提醒开发/设计同学及时梳理清楚代码逻辑关系,提升代码质量;
3、代码覆盖率并不能代表代码质量,但是从另一个角度来看,代码覆盖率低,说明开发人员自测、测试人员验收质量不高,暗藏质量风险,可以作为开发/测试自我审视的重要工具之一。
项目探索
目前业界比较认可的技术手段是通过分析增量代码的覆盖率来补充测试用例,我们调研了业界开源的Java代码覆盖率统计工具JaCoCo和EMMA,发现JaCoCo和EMMA都只支持收集全量代码覆盖率,这样生成的报告冗余,影响我们对报告的分析和查看,意味着每次都得在全量覆盖率报告中寻找自己修改点的覆盖率数据,结合大多数业务迭代每次新增功能的使用场景,显然并不能满足精准分析增量代码覆盖程度的诉求,因此我们需要对它进行自定义改造。
3.1 为什么选择JaCoCo
Java中比较流行的代码覆盖率工具有EMMA,Cobertura,JaCoCo 等,以现在情况来看,使用JaCoCo的团队是比较多的,有点大势所趋的感觉。本来以前用EMMA的人很多,但是开发这个工具的团队自从 2005年以后就再也没更新过了,可以理解为EMMA已经是一个dead project,所以这里选择了JaCoCo作为调研方向。不过其实所有代码覆盖率的原理都是差不多的,一通百通,也都能满足我们的需求。
3.2 JaCoCo注入原理
JaCoCo(Java Code Coverage)是一个开源的覆盖率工具(官网地址:https://www.eclemma.org/jacoco/)
JaCoCo支持的注入方式如下图所示:
包含了几种不同的收集覆盖率信息的方法,每个方法的实现都不太一样,这里主要关心字节码注入这种方式(Byte Code)。
工作步骤:
对Java字节码进行插桩,有On-The-Fly和Offline两种方式;
执行测试用例,收集程序执行轨迹信息,支持通过dump将操作记录从服务端传输到本地;
数据处理器结合程序执行轨迹信息和代码结构信息分析生成代码覆盖率报告;
结合源码和编译后的文件,可以将代码覆盖率报告图形化展示出来,如html,xml等文件格式;
下面来说说Offline和On-The-Fly两种注入方式的区别:
Offline:
在生成最终的目标文件之前,对Class文件进行插桩,生成最终的目标文件,执行目标文件以后得到覆盖执行结果,最终生成覆盖率报告。
On-The-Fly:
JVM通过-javaagent指定特定的Jar来启动Instrumentation代理程序,代理程序在ClassLoader装载一个class前先判断是否需要对class进行注入,对于需要注入的class进行注入。
覆盖率结果可以在JVM执行代码的过程中完成。
可以看到,On-The-Fly方式因为要修改JVM参数,所以对环境的要求比较高,为了屏蔽工具对虚拟机环境的依赖,所以我们在Android中的代码注入选择Offline这种方式。
那么它是如何实现注入的呢?根据官网提供的示例如下:
public static void example() {
a();
if (cond()) {
b();
} else {
c();
}
d();
}
Java 编译器将根据这个示例方法创建以下字节码,Java 字节码是一个线性指令序列。控制流是通过跳转指令(如条件 IFEQ或无条件GOTO操作码)实现的。跳转目标在技术上是目标指令的相对偏移量。为了更好的可读性,我们使用符号标签 ( L1, L2) 代替(ASM API 也使用这样的符号标签)
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
上面字节码中可能的控制流可以用图形表示。节点是字节码指令,图的边代表指令之间可能的控制流。该示例的控制流程显示在此图的左侧框中:
JaCoCo通过ASM在字节码中插入Probe指针(探测指针),JaCoCo 使用boolean[]每个类的数组实例实现探测器。每个探针对应于该数组中的一个条目。每当执行探测时,条目都被设置为true,程序运行时通过改变指针的结果来检测代码的执行情况(不会改变原代码的行为)。
3.3 增量代码覆盖率设计方案
JaCoCo增量代码覆盖率设计方案是基于JaCoCo做相应改造,重点需要解决的问题是如何获取增量代码,使用改造后的JaCoCo进行解析,生成我们所需要的覆盖率数据报告。
设计方案可以分为以下几个步骤:
通过git diff命令获取开发分支和目标分支(默认master)差异文件名称集合;
切换分支,分别拉取两分支class文件集到temp/分支名目录下;
根据第一步得到的差异文件名称,删掉temp目录下不在差异文件集中的相关文件;
读取两分支class文件,生成diff方法集;
对diff方法集插入探针;
改造JaCoCo,使它支持仅对差异代码生成覆盖率报告;
项目实践
4.1 编译期
作为对项目功能的一个补充扩展,我选择使用Plugin进行功能的开发,方便引用工程插拔。编译期工作是通过Gradle TransForm完成的,众所周知,TransForm在编译期可以对字节码文件进行修改,整个过程要做3件事情:
通过git管理编译生成的class文件;
通过git获取分支差异方法;
生成diff方法集并插入探针;
首先我们定义一个配置对象:
class JacocoExtension {
//jacoco开关,false时不会进行probe插桩
boolean jacocoEnable
//需要对比的分支名
String branchName
//exec文件路径,支持多个ec文件,自动合并
String execDir
//源码目录,支持多个源码
List<String> sourceDirectories
//class目录,支持多个class目录
List<String> classDirectories
//需要插桩的文件
List<String> includes
//生成报告的目录
String reportDirectory
//git 提交命令
String gitPushShell
//复制class 的shell
String copyClassShell
//git-bash的路径,插件会自动寻找路径,如果找不到,建议自行配置
private String gitBashPath
//下载ec 的服务器
String host
//类过滤器 返回 true 的将会被过滤
Closure excludeClass
//方法过滤器 返回true 的将会被过滤
Closure excludeMethod
}
4.1.1 git管理class
首先在我们的项目当中可能存在java和kotlin两种源码,而这两种语言经过编译后得到的都是.class文件,我们可以通过ASM来解析class,在JaCoCo中也需要用到ASM,所以我们最终应该保存和操作的应该就是源码编译后生成 的class文件,这里我们可以通过设置includes来指定我们需要保存的文件,比如以包名com.zhangyan.core、com.zhangyan.base开头的,这些是我们项目工程中真实需要关心的源码,而引用进来的第三方或者平台架构组提供的基础框架,我们可以认为他们是健康稳定的,我们不用再对其进行覆盖测试,所以我只把我们需要关心的class通过命令拷贝到app同目录下。接下来通过脚本自动化对这些classes进行git add、git commit、git push操作,这是为了我们下一步操作做铺垫。
4.1.2 获取两个分支差异文件名
正常情况下,当我们的项目开始迭代后,每期需求开发验收完后会merge到master分支,然后基于master分支拉出新的迭代分支进行下一迭代需求开发,所以master上是稳定的代码,当前开发分支是新增需求代码,配置类中的branchName我们不妨设置为master。我们这里需要获取两个分支间的差异文件,然后获取差异方法,这里差异方法的定义应该是任何的修改,包括新增方法、修改方法等都算是差异性的。
通过git命令我们可以获取两个分支间差异文件名称:
git diff origin/feature/jinggong-1.7.6 origin/master --name-only
部分截图示例如下:
这里可能包含大量的.java、.gradle之类的文件,我们可以过滤掉非.class和非指定包名的文件,仅仅保留两分支差异class文件名,将这些差异文件的名称写到build/outputs/diff/diffFiles.txt中备用。
4.1.3 拷贝分支差异文件到temp目录
在当前项目父工程目录下根据分支名称创建temp临时目录用来承载对应分支下所有的classes文件,根据上一步得到的diffFiles差异文件名,匹配文件名删除不在diffFiles集合中的文件,这样两个temp目录下保留的就是差异class文件。
pullDiffClass.sh如下所示:
#!/bin/sh
oriBran=$(git name-rev --name-only HEAD)
echo "当前分支:$oriBran" #remotes/origin/main_DEALER-2932
gitBran=$1 # 本地分支
echo "gitBran=$gitBran"
workDir=$2
outDir=$3
echo "workDir=$workDir"
echo "outDir=$outDir"
echo "start checkout--"
git checkout -b $gitBran origin/$gitBran
git checkout -f $gitBran
echo "start pull--"
git pull
echo "start copy: cp -r "${workDir}/app/classes" $outDir "
cp -r "${workDir}/app/classes" $outDir
echo "copy over --"
4.1.4 生成差异方法集
现在我们已经根据git diff得到的差异文件名得到了差异文件实体,接下来我们需要查找差异方法了,这一步需要借助ASM读取class文件,访问方法,收集方法信息,定义一个DiffClassVisitor继承自ClassVisitor,核心代码如下:
public class DiffClassVisitor extends ClassVisitor {
private String className;
private int type;
...
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature,
String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
final MethodInfo methodInfo = new MethodInfo();
methodInfo.className = className;
methodInfo.methodName = name;
methodInfo.desc = desc;
methodInfo.signature = signature;
methodInfo.exceptions = exceptions;
mv = new MethodVisitor(Opcodes.ASM5, mv) {
StringBuilder builder = new StringBuilder();
//访问方法一个参数
@Override
public void visitParameter(String name, int access) {
builder.append(name);
builder.append(access);
super.visitParameter(name, access);
}
//访问方法的一个注解
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
builder.append(desc);
builder.append(visible);
// System.out.println("visitAnnotation--desc:" + desc + " visible:" + visible);
return super.visitAnnotation(desc, visible);
}
//访问方法签名上的一个类型的注解
@Override
public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String desc,
boolean visible) {
builder.append(typeRef);
builder.append(typePath.toString());
builder.append(desc);
builder.append(visible);
return super.visitTypeAnnotation(typeRef, typePath, desc, visible);
}
//省略很多过程方法......
//方法访问结束
@Override
public void visitEnd() {
String md5 = "";
try {
md5 = Util.MD5(builder.toString());
} catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
e.printStackTrace();
}
methodInfo.md5 = md5;
DiffAnalyzer.getInstance().addMethodInfo(methodInfo, type);
super.visitEnd();
}
};
return mv;
}
}
代码中间省略掉了很多的vistxxx方法,都是相关方法的基本信息和一些指令,在方法visitEnd结束的时候,我们把通过上面遍历信息得到的StringBuilder存储的信息取出来,对其进行md5转化,然后添加到DiffAnalyzer对象中去。
接下来在DiffAnalyzer类中通过diff方法,根据className、methodName、desc来判断是否同一个方法名,接着比较他们md5信息是否一致,如果一致表示这个方法没有被修改过,如果md5不一致,表示这个方法被修改过了,需要记录下来,当所有的差异class文件遍历访问结束,最终得到我们的差异方法集。
diff方法核心代码如下所示:
public void diff() {
if (!currentList.isEmpty() && !branchList.isEmpty()) {
for (MethodInfo cMethodInfo : currentList) {
boolean findInBranch = false;
for (MethodInfo bMethodInfo : branchList) {
if (cMethodInfo.className.equals(bMethodInfo.className)
&& cMethodInfo.methodName.equals(bMethodInfo.methodName)
&& cMethodInfo.desc.equals(bMethodInfo.desc)) {
if (!cMethodInfo.md5.equals(bMethodInfo.md5)) {
diffList.add(cMethodInfo);
}
findInBranch = true;
break;
}
}
if (!findInBranch) {
diffList.add(cMethodInfo);
}
diffClass.add(cMethodInfo.className);
}
}
}
4.1.5 插入探针
通过上面几步我们得到了分支间差异方法集了,接下来进行探针的插入。这里就要用到JaCoCo-core的能力了,插入探针依靠的是ClassInjector这个类,把插入探针后的字节码写回到文件中,代码如下:
@Override
void processClass(File fileIn, File fileOut) throws IOException {
if (shouldIncludeClass(fileIn)) {
InputStream is = null;
OutputStream os = null;
try {
is = new BufferedInputStream(new FileInputStream(fileIn));
os = new BufferedOutputStream(new FileOutputStream(fileOut));
// For instrumentation and runtime we need a IRuntime instance
// to collect execution data:
// The Instrumenter creates a modified version of our test target class
// that contains additional probes for execution data recording:
final Instrumenter instr = new Instrumenter(new OfflineInstrumentationAccessGenerator());
final byte[] instrumented = instr.instrument(is, fileIn.getName());
os.write(instrumented);
} finally {
closeQuietly(os);
closeQuietly(is);
}
} else {
FileUtils.copyFile(fileIn, fileOut);
}
}
插入代码的逻辑在Instrumenter中,具体的实例对象其实是ClassInstrumenter,通过对visitMethod方法的调用,判断是否是前面记录的差异方法来选择是否需要插入探针,从而实现了对增量差异方法插入探针的目的,核心如下:
@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature,
final String[] exceptions) {
if (DiffAnalyzer.getInstance().containsMethod(className, name, desc)) {
InstrSupport.assertNotInstrumented(name, className);
final MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
if (mv == null) {
return null;
}
final MethodVisitor frameEliminator = new DuplicateFrameEliminator(mv);
final ProbeInserter probeVariableInserter = new ProbeInserter(access,
name, desc, frameEliminator, probeArrayStrategy);
return new MethodInstrumenter(probeVariableInserter,probeVariableInserter);
} else {
return super.visitMethod(access, name, desc, signature, exceptions);
}
}
到这里,编译期的工作就做完了,针对增量/差异代码我们已经插入了探针,下一步进入运行时。
4.2 运行时
在3.2小节中我介绍了JaCoCo是通过ASM在字节码中插入Probe指针(探测指针),JaCoCo 使用boolean[]每个类的数组实例实现探测器。每个探针对应于该数组中的一个条目。每当执行探测时,条目都被设置为true,程序运行时通过改变指针的结果来检测代码的执行情况(不会改变原代码的行为)。所以我们安装编译好的包后就可以进行测试了。
先交代一下运行时的工作流程,当我们的app启动运行时,我们在页面停留、访问、操作时都会改变探针对应的boolean值,在关闭页面时,会保存刚才的boolean数组到本地的ec执行文件中。下次重启(杀进程)app时,会上传上一次记录的ec文件到远程服务器系统中,所以这里涉及到一些初始化、保存、上传的操作,下面用代码做一个介绍。
4.2.1 初始化
public class MyApplication extends EngineApplication {
@Override
public void onCreate() {
super.onCreate();
if (AppJingGongConfig.IS_DEBUG) {
//测试包增加代码覆盖率初始化
CodeCoverageManager.init(this, "文件上传服务器地址");
//启动时上传上次记录的ec文件
CodeCoverageManager.uploadData();
}
}
}
4.2.2 保存ec文件
在BaseActivity的onDestroy方法中添加
@Override
protected void onDestroy() {
super.onDestroy();
if (AppJingGongConfig.IS_DEBUG) {
//记录操作文件
CodeCoverageManager.generateCoverageFile();
}
}
操作ec文件相关的核心代码都在插件的CodeCoverageManager类中,这里不展开详细介绍。
4.3 生成报告
联系我们目前的开发流程,QA在KeOnes上打包安装测试后,需要有一个平台能够主动触发项目里的gradle任务生成代码覆盖率报告,这里我选择集成到Jenkins上。
参数说明,这里主要关注到4个参数:
branch想测试的是哪个分支的代码覆盖率,默认选择当前开发分支;
appName、appVersion在前面运行时生成的ec文件上传,是根据当前appName+appVersion组合参数的形式作维度的,所以同一版本、不同人测试的ec文件都会存储在这同一个维度下,这里设置的参数根据想测试的app和版本号即可;
isDeleteFile上面介绍了同一版本不同人员操作生成的ec文件都会记录在以appName、appVersion维度下,所以在QA人员正式进入代码测试、生成代码覆盖率之前,需要将这个值设置为true,点击开始构建,这个时候会将appName、appVersion组合参数下的所有ec文件删除掉,避免历史数据对代码覆盖率测试结果造成干扰(比如你没覆盖到的地方,别人覆盖到了,代码覆盖率显示100%)。
接下来QA就可以进行case测试,测试结束后,重启应用,上传最新的ec文件,回到Jenkins,isDeleteFile选择false(默认false,表示生成报告),点击开始构建,这时候会触发gradle任务,执行app:generateReport任务,核心代码如下:
class BranchDiffTask extends DefaultTask {
@TaskAction
def getDiffClass() {
//下载ec文件
downloadEcData()
//生成差异报告
pullDiffClasses()
if (jacocoExtension.reportDirectory == null) {
jacocoExtension.reportDirectory = "${project.buildDir.getAbsolutePath()}/outputs/report"
}
//生成覆盖率报告
ReportGenerator generator = new ReportGenerator(jacocoExtension.execDir,
toFileList(jacocoExtension.classDirectories),
toFileList(jacocoExtension.sourceDirectories), new File(jacocoExtension.reportDirectory));
generator.create();
}
}
generateReport任务中主要做了三件事:
从服务器下载appName、appVersion对应下所有的ec文件,也就是在运行时上传的那些数据文件;
同编译期逻辑一样,获取差异方法集,这里再次获取的目的是为了JaCoCo对当前分支app/classes目录下的class文件进行读取探针;
最后调用JaCoCo中的ReportGenerator生成代码覆盖率报告,最终会输出一份html形式的报告;
jenkins执行完后会在主界面展示报告入口,如图所示:
点击即可查看覆盖率报告,这里生成展示的就是两分支比对增量差异性文件,以被窝家装App最新1.7.8版本不同阶段的测试覆盖率为例:
刚开始测试
测试后
通过测试的不同阶段发现不同类中的覆盖率统计值发生了变化,如果这时候QA觉得自己测试充分了,可以点开覆盖率未达到100%的类,查看哪些地方没有覆盖到,以其中某一个覆盖率100%的举例看一下:
这是本次迭代需求新增的一个页面,可以看到所有的方法和条件都执行到了。
那么接下来找一个并没有覆盖全的示例:
这个示例说明,我们可能修改过这个页面中点击事件的代码,但是在我操作app过程中,并没有触发相关点击逻辑,所以这里标红显示,那么我们或者QA同学需要重点关注这块逻辑回归测试时没有覆盖到,需要进行补充测试。二轮测试后再次上传ec文件,生成覆盖率报告可以关注这块是否覆盖到。我们最终的目标是所有的修改点覆盖率达到100%。
其中有几种颜色类型:
绿色:
表示行覆盖充分。
红色:
表示未覆盖的行。
空白色:
代表方法未修改,无需覆盖。
黄色棱形:
表示分支覆盖不全。
绿色棱形:
表示分支覆盖完全。
项目总结
该项目从提高团队质量稳定性出发,基于JaCoCo核心源码,实现对两个git分支进行差异性比对、生成、注入,做到增量方法级的代码覆盖,是一次技术的探索和经验的积累。
目前第一版插件已经在被窝家装App中应用起来,debug包默认集成(本地可配置参数不集成提高编译速度),release包不集成,避免编译插桩带来的影响。最近的迭代中已交付QA使用。
覆盖率报告仅代表测试流程执行到位,并不能保证逻辑正确性,是测试case覆盖完整的一个保障,对于覆盖率报告中覆盖率的值,需要由开发人员来判断这个覆盖率到底是否满足,是否会引起问题。
后续我们会不断打磨该插件的能力,提高增量识别精度,让工具更准确、更好用,敬请期待!