写在前面

覆盖率一直是我的心头痛,去年年初实践了一下 iOS 方面的手工测试覆盖率收集 iOS 手工测试代码覆盖率获取.

由于团队内推广原因,未真正带来有用的价值,现在重新拾起安卓的覆盖率,是因为独立项目组对 SDK 的覆盖率收集有需求,而技术负责人也支持推动覆盖率获取收集,有需求就有动力。

基础知识

安卓覆盖率方面的文章和资料很多。主要参考

这些文章里面介绍了主体施行流程,但是我们的项目是多个 SDK 是以 Jar 的形式引入到我们的主项目,文章中未解答多模块和多独立 SDK 覆盖率收集问题。

在自己摸索和热心 tester 大佬 @zailushang , @simple@VPDong帮助下,解决了一系列问题,最终实践取到多模块和多独立 SDK 覆盖率报告。

先简单介绍一下,覆盖率工具的选择和对 Jacoco 的理解。

覆盖率工具选择

由于项目中使用的 gradle ,且 Java 版本也在持续更新中,选择 Jacoco 作为覆盖率收集工具。

字节码插桩分为两种模式 On-The-Fly 和 Offine。

选择 Jacoco Offline 的模式实现手工测试覆盖率收集

实现过程【以公司项目为例】

1、app/src/main/java下新建 dump 测试覆盖率文件的代码,然后在在应用置于后台时候,调用 generateEcFile方法生成覆盖率文件。


package com.aa.bb.cc;

import android.util.Log;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;

/**
 * Created by sun on 17/7/4.
 */

public class JacocoUtils {
    static String TAG = "JacocoUtils";

    //ec文件的路径
    private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec";

    /**
     * 生成ec文件
     *
     * @param isNew 是否重新创建ec文件
     */
    public static void generateEcFile(boolean isNew) {
//        String DEFAULT_COVERAGE_FILE_PATH = NLog.getContext().getFilesDir().getPath().toString() + "/coverage.ec";
        Log.d(TAG, "生成覆盖率文件: " + DEFAULT_COVERAGE_FILE_PATH);
        OutputStream out = null;
        File mCoverageFilePath = new File(DEFAULT_COVERAGE_FILE_PATH);
        try {
            if (isNew && mCoverageFilePath.exists()) {
                Log.d(TAG, "JacocoUtils_generateEcFile: 清除旧的ec文件");
                mCoverageFilePath.delete();
            }
            if (!mCoverageFilePath.exists()) {
                mCoverageFilePath.createNewFile();
            }
            out = new FileOutputStream(mCoverageFilePath.getPath(), true);

            Object agent = Class.forName("org.jacoco.agent.rt.RT")
                    .getMethod("getAgent")
                    .invoke(null);

            out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class)
                    .invoke(agent, false));

            // ec文件自动上报到服务器
            //UploadService uploadService = new UploadService(mCoverageFilePath);
            //uploadService.start();
        } catch (Exception e) {
            Log.e(TAG, "generateEcFile: " + e.getMessage());
        } finally {
            if (out == null)
                return;
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

2、在项目根目录的build.gradle追加


apply from: "${rootProject.projectDir}/jacoco.gradle"

jacoco.gradle内容为:

apply plugin: 'jacoco'

jacoco {
    toolVersion = '0.8.2'
}


allprojects { p ->
    p.afterEvaluate {
            p.android {
                apply plugin: 'jacoco'
                defaultPublishConfig "debug"
                buildTypes {
                    debug {
                        testCoverageEnabled = true
                    }
            }
        }
    }
}

task jacocoTestReport(type: JacocoReport) {
    reports {
        xml.enabled true
        html.enabled true
        csv.enabled false
    }

    executionData fileTree("$buildDir/coverage.ec")

    def classExcludes = ['**/R*.class',
                         '**/*Factory*.class',
                         '**/*$InjectAdapter*.class',
                         '**/*$ModuleAdapter*.class',
                         '**/*$ViewInjector*.class']

    sourceDirectories = files()
    classDirectories = files()

    project.rootProject.allprojects.each {

            sourceDirectories += files(it.projectDir.absolutePath + '/src/main/java')
            def path = it.buildDir.absolutePath + '/intermediates/javac/debug/compileDebugJavaWithJavac/classes/'
            classDirectories += fileTree(dir: path, excludes: classExcludes, includes: ['**/*.class'])
        }

    doFirst {
        fileTree(dir: project.rootDir.absolutePath, includes: ['**/classes/**/*.class']).each { File file ->
            if (file.name.contains('$$')) {
                file.renameTo(file.path.replace('$$', '$'))
            }
        }
    }
}

这里做了两件事,

3、这个时候我们到 SDK 模块下,同样修改收集覆盖率的 build.gradle,类似主工程中更改的方式。

需要注意的是我们的 SDK 是以 jar 的形式引入主项目中。

所以,我们需要同时在 SDK 中插桩,在主项目工程中指定引入插桩的版本 SDK 依赖即可。

4、在应用内测试后,然后将应用置于系统后台。

执行adb pull /mnt/sdcard/coverage.ec将覆盖率文件导出到项目主工程下的build/下。

执行./gradlew jacocoTestReport可生成主工程下和各个子模块的覆盖率报告。

5、将覆盖率文件同样复制到 SDK 项目目录下,这里有刚才编译生成的产出物和源码。同样执行生成覆盖率报告的 task。

遇到的问题

1、覆盖率文件的格式不对

java.io.IOException: Incompatible version 1007

查阅资料:

使用命令检查

hexdump -n 5 coverage.ec

发现出现的覆盖率文件一直是 06,意味着是低版本的 Jacoco 版本生成的覆盖率文件,但是我已经在 jacoco.gradle 指定了高版本。
通过二叉对比法,一直寻找到我们项目历史提交中是否一直有此问题,直到找到问题提交,是因为 gradle plugin 升级后,我们使用的官方 plugin 版本 3.0.1 反而内置使用了低版本的 jacoco。
故,解决方案是升级 gradle plugin。

2、编译后中间产出物路径不对
升级 gradle plugin 后,生成覆盖率报告所需要的中间产出物 class 文件夹找不到。
虽然可以通过 ./gradlew createDebugCoverageReport生成临时的中间产出物 classes/classes.jar 一样可以配合生成覆盖率报告,但是这样总显得不够优雅,因为

任务 作用
connectedAndroidTest 执行 android 的 case
createDebugCoverageReport 产生代码覆盖率的报告
connectedCheck 包含上面 2 个任务

后来,我在提问

才了解到升级 plugin 后 classes 文件所在路径。

3、覆盖率报告aat模块点进去不能 link 到源码
由于我仔细检查了所有模块,只有这个模块有问题,但是找了好几个开发都不能帮我对比出这个模块和其他模块不一样的地方,从 build.gradle 到 Manifest.xml,怎么都无法解决。
我同时仔细对比了 sourceDirectories 和 classDirectories,生成覆盖率报告后能否点击源码主要依赖这里的 sourceDirectories,但是我确认路径是对的。依然无法解决。
后来我在 groups.google.com jacoco 模块提问

原来 aat的模块com.aa.bb.cc不是一个 Package,真正路径不是src/main/java/com/aa/bb/cc/,而是以com.aa.bb.cc以字符串命名的一个 Package 真实路径是src/main/java/com.aa.bb.cc

思考

把测试覆盖率作为质量目标没有任何意义,而我们应该把它作为一种发现未被测试覆盖的代码的手段。
后续将以持续集成的方式完成当前手动执行的步骤。


↙↙↙阅读原文可查看相关链接,并与作者交流