测试覆盖率 安卓多 module 和多独立 SDK 手工测试覆盖率收集实践

不二家 · 2018年12月14日 · 最后由 不二家 回复于 2020年04月06日 · 4406 次阅读

写在前面

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

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

基础知识

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

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

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

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

覆盖率工具选择

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

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

  • 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('$$', '$'))
            }
        }
    }
}

这里做了两件事,

  • 第一是对所有的主模块,包括项目中的子模块插入收集覆盖率代码的 testCoverageEnabled=true

  • 第二是增加了 jacocoTestReport 的 task,收集所有模块下,包括主模块和子模块下的覆盖率报告

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

思考

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

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 12 条回复 时间 点赞

赞,楼主是个爱专研的同学,向楼主学习

您好,麻烦请教一下!

当把 apply from: "${rootProject.projectDir}/jacoco.gradle"配到根目录的 build.gradle 时,出现 Could not find method android() 错误:

把 apply from: "${rootProject.projectDir}/jacoco.gradle"配到具体子模块的 build.gradle 时,出现如下异常:

请问该怎么解决呢?

DefuTai 回复

1、第一个问题,看看你们根目录下的 build.gradle 是不是存在 android {}?
2、如果是同一个项目下的多个 module,不需要每个 module 都配置引用 jacoco.gradle,只需要根目录下的 build.gradle 引用即可。

不二家 回复

根目录的 build.gradle 引用的其他 gradle 文件中有 android

DefuTai 回复

问问开发,我的能力解决不了了。

java.lang.ClassNotFoundException: org.jacoco.agent.rt.RT
这个问题有没有遇到过?困惑了一天

crazy mouse 回复

遇到过,好像是版本和 gradle plugin 不匹配

不二家 回复

jacoco 的版本么?和哪个 plugin 不匹配?辛苦给详细的说下你遇到的时候如何解决的吧。

crazy mouse 回复

gradle wrapper plugin 版本,对了确认一下 debug {
testCoverageEnabled = true
}开没开


gradle 运行生成 report 会报错

yuvivien 回复

开启--debug,看什么类型的报错,大概率是 gradle 和 gradle wrapper 版本不一致的问题。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册