测试覆盖率 Jacoco 统计 Android 代码覆盖率 [instrument 方式]

zailushang · 2018年10月09日 · 最后由 会说话的汤姆猫 回复于 2020年10月30日 · 811 次阅读
本帖已被设为精华帖!

示例 JacocoTestForAndroid 见:https://github.com/OnTheWay111/JacocoTestForAndroid
还有一个更灵活的非 instrument 方式: [定制触发条件] jacoco 统计 Android 代码覆盖率
本文两年前的代码,有些代码可能已过时,如果遇到问题,可参考:https://www.jianshu.com/p/ab2be01d7347#comment-60999311

1、背景

最近团队想基于 monkey,进行 app 遍历开发,为了与市场上的遍历方案做对比,以及团队遍历方案的优化比较,故选择了 APP 的代码覆盖率统计。

备注:Instrumentation 方式 1 和 Instrumentation 方式 2 几乎一样

2、代码覆盖率方案选型

目前 Java 常用覆盖率工具 Jacoco、Emma、Cobertura 和 Clover(商用)

Jacoco 是一个开源的覆盖率工具。Jacoco 可以嵌入到 Ant 、Maven 中,并提供了 EclEmma Eclipse 插件,也可以使用 Java Agent 技术监控 Java 程序。很多第三方的工具提供了对 Jacoco 的集成,如:Sonar、Jenkins、IDEA.
综合考虑:选择 jacoco 作为代码覆盖率统计工具。

3、jacoco 注入原理


本次,在尽量避免修改源代码的情况下,我们选择 instrumentation 模式,开始操作。

4、jacoco 注入实操 (instrument 方式)

4.1 创建一个安卓项目

随便创建一个简单的安卓项目

4.2 在代码主路径下新建 test 文件夹,新建 3 个类文件

在 java/下新建 test 文件夹,然后在 test 路径下
新建三个类文件,分别是抽象类 FinishListener,Instrumentation 的 Activity 和 instrumentation 类。

  • FinishListener:
public interface FinishListener {
    void onActivityFinished();
    void dumpIntermediateCoverage(String filePath);
}
  • InstrumentedActivity:
public class InstrumentedActivity extends MainActivity {
    public static String TAG = "IntrumentedActivity";
    private FinishListener mListener;
    public void setFinishListener(FinishListener listener) {
        mListener = listener;
    }
    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d(TAG + ".InstrumentedActivity", "onDestroy()");
        super.finish();
        if (mListener != null) {
            mListener.onActivityFinished();
        }
    }
}
  • JacocoInstrumentation:
public class JacocoInstrumentation extends Instrumentation implements FinishListener {
    public static String TAG = "JacocoInstrumentation:";
    private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec";
    private final Bundle mResults = new Bundle();
    private Intent mIntent;
    private static final boolean LOGD = true;
    private boolean mCoverage = true;
    private String mCoverageFilePath;

    public JacocoInstrumentation() {
    }

    @Override
    public void onCreate(Bundle arguments) {
        Log.d(TAG, "onCreate(" + arguments + ")");
        super.onCreate(arguments);
        DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath().toString() + "/coverage.ec";
        File file = new File(DEFAULT_COVERAGE_FILE_PATH);
        if(!file.exists()){
            try{
                file.createNewFile();
            }catch (IOException e){
                Log.d(TAG,"新建文件异常:"+e);
                e.printStackTrace();}
        }
        if(arguments != null) {
            mCoverageFilePath = arguments.getString("coverageFile");
        }
        mIntent = new Intent(getTargetContext(), InstrumentedActivity.class);
        mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        start();
    }

    @Override
    public void onStart() {
        super.onStart();
        Looper.prepare();
        InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent);
        activity.setFinishListener(this);
    }

    private String getCoverageFilePath() {
        if (mCoverageFilePath == null) {
            return DEFAULT_COVERAGE_FILE_PATH;
        } else {
            return mCoverageFilePath;
        }
    }

    private void generateCoverageReport() {
        Log.d(TAG, "generateCoverageReport():" + getCoverageFilePath());
        OutputStream out = null;
        try {
            out = new FileOutputStream(getCoverageFilePath(), false);
            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));
        } catch (Exception e) {
            Log.d(TAG, e.toString(), e);
        } finally {
            if (out != null) {
                try {
                    out.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    @Override
    public void onActivityFinished() {
        if (LOGD)      Log.d(TAG, "onActivityFinished()");
        if (mCoverage) {
            generateCoverageReport();
        }
        finish(Activity.RESULT_OK, mResults);
    }

    @Override
    public void dumpIntermediateCoverage(String filePath) {

    }
}

4.3 修改 Build.gradle 文件

增加 jacoco 插件和打开覆盖率统计开关

apply plugin: 'com.android.application'
apply plugin: 'jacoco'

android {
    compileSdkVersion 28
    buildToolsVersion "28.0.2"
    defaultConfig {
        applicationId "com.example.app1"
        minSdkVersion 24
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            /**打开覆盖率统计开关*/
             testCoverageEnabled = true
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

4.4 在 manifest.xml 增加声明

  • 在中声明 InstrumentedActivity:
<activity android:label="InstrumentationActivity"    android:name="coverage.netease.com.test.InstrumentedActivity" />
  • 在 manifest 中声明使用 SD 卡权限:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  • 最后,在 manifest 中单独声明 JacocoInstrumentation:
<instrumentation
        android:handleProfiling="true"
        android:label="CoverageInstrumentation"
        /*这里android:name写明此instrumentation的全称*/
        android:name="com.example.test.JacocoInstrumentation"
        /*这里android:targetPackage写明被测应用的包名*/
        android:targetPackage="com.example.app1"/>

4.5 通过 gradle installDebug 安装到设备上

4.6 在命令行下通过 adb shell am instrument 命令调起 app,具体命令是:

adb shell am instrument com.example.app1/com.example.test.JacocoInstrumentation

也就是 adb shell am instrument /

4.7 调起 app 后我们就可以进行手工测试了,测试完成后点击返回键退出 app。

此时,jacoco 便将覆盖率统计信息写入/data/data//files/coverage.ec 文件。
接下来我们需要新增 gradle task,分析覆盖率文件生成覆盖率 html 报告。

5、生成 html 报告

5.1 首先将 coverage.ec 文件拉到本地,置于指定目录下。

  • adb shell 进入设备,找到 data/data//files/coverage.ec 文件。
  • adb pull 到 pc 本地。
  • 将该文件拖至入 app 根目录/build/outputs/code-coverage/connected(没有的话,可以执行 gradle createDebugCoverageReport)

5.2 新增 gradle task,修改 build.gradle 文件为:

apply plugin: 'com.android.application'
apply plugin: 'jacoco'

android {
    compileSdkVersion 28
    buildToolsVersion "28.0.2"
    defaultConfig {
        applicationId "com.example.app1"
        minSdkVersion 24
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            /**打开覆盖率统计开关*/
             testCoverageEnabled = true
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

def coverageSourceDirs = [
        '../app/src/main/java'
]

task jacocoTestReport(type: JacocoReport) {
    group = "Reporting"
    description = "Generate Jacoco coverage reports after running tests."
    reports {
        xml.enabled = true
        html.enabled = true
    }
    classDirectories = fileTree(
            dir: './build/intermediates/app_classes/debug',
            excludes: ['**/R*.class',
                       '**/*$InjectAdapter.class',
                       '**/*$ModuleAdapter.class',
                       '**/*$ViewInjector*.class'
            ])
    sourceDirectories = files(coverageSourceDirs)
    executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec")

    doFirst {
        new File("$buildDir/intermediates/app_classes/").eachFileRecurse { file ->
            if (file.name.contains('$$')) {
                file.renameTo(file.path.replace('$$', '$'))
            }
        }
    }
}

5.3 生成 html 报告

  • 执行:
gradle jacocoTestReport

执行完以上命令后,html 报告生成

  • 查看 html 报告 报告路径://app/build/reports/jacoco/jacocoTestReport/html

打开报告后,如图所示:

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

需要注意的是:build.gradle 文件中,app_classes 的名字需要和项目本身实际匹配,如果不匹配,会出现 FileNotFoundException

请教一个问题,当我把 instrumentation 在 manifest 里面申明以后,运行老报错,说不识别 instrumentation 这个资源,这个是啥问题,如何解决?

西门吹牛 回复

给一个截图或者 log 信息看看吧

@chenhengjie123 老大,请教一下,kotlin 实现的 Android 应用也能用 Jacoco 实现手工测试覆盖率的收集吗?

恒温 将本帖设为了精华贴 11月25日 00:26

手把手教程,如果能有个视频就更好了

kotlin 可以和 java 无缝配合,所以可以做的

zailushang 回复

如果单单只想测 kotlin 写的一个 sdk,但是这个 sdk 在另一个项目 A 中引用,且需要在 A 中测试 sdk 的测试覆盖率,不知道有什么好实现的思路?谢谢。

把 jacoco 的配置写在 sdk 项目的 gradle 配置文件中,我们的 SDK 就是这样做的

simple 回复

👍 正解

zailushang 回复

然后呢?在主项目的 build.gradle 中不需要配置 jacoco 的配置吗?能否多讲点大致的步骤?另,我们的 sdk 是以 jar 包形式引入主项目中的。

@simple @ 不二家的小球迷 simple 兄弟,可以给小球迷详细说说具体步骤吗?我没这么实践过,只是理论上觉得是可以的


参考一下这个

simple 回复

@zailushang @simple 感谢,我先尝试一下,统计主项目的覆盖率的内容,然后尝试 sdk

我们的 sdk 是插件化多模块的方式,收集覆盖率信息会更麻烦一些,后面可以出一篇文章发到社区里,你试一下先

@simple @zailushang 我在主项目内尝试 jacoco,然后已经安装好 apk,以后然后启动服务的时候报错了:

adb shell am instrument com.xiaomi.aaa/com.xiaomi.aaa.test.JacocoInstrumentation
android.util.AndroidException: INSTRUMENTATION_FAILED: com.xiaomi.aaa/com.xiaomi.aaa.test.JacocoInstrumentation
 at com.android.commands.am.Am.runInstrument(Am.java:956)
 at com.android.commands.am.Am.onRun(Am.java:316)
 at com.android.internal.os.BaseCommand.run(BaseCommand.java:47)
 at com.android.commands.am.Am.main(Am.java:99)
 at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
 at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:330)

能把所有报错都发出来吗?这段看不太出来原因

zailushang 回复

这就是所有报错唉,我在命令行已经安装应用成功,然后用这个命令adb shell am instrument命令调起 app 的时候报错

在 manifast 中注册 InstrumentedActivity 了吗? 你是小米的?

zailushang 回复

我不是小米的,我确认了一遍配置,没有配错。我对这个报错很懵逼啊

我参考这个地址 https://stackoverflow.com/questions/14269687/android-util-androidexception-instrumentation-failed ,然后使用命令
adb shell pm list instrumentation,的确没有看到我安装的应用包名。

方便的话,可以把你的代码发过来吗?不然这样不好解决

zailushang 回复

代码应该不行,我还是自己再看看吧,谢谢。

方便私聊不,想跟你 QQ 一下,872489864 方便不?

可以微信,QQ 一般不登录,TTMMD155

@simple 方便增加点描述关于 sdk 覆盖率收集吗?

@simple 大哥,实在想知道,如何统计以 jar 包引入的 sdk 覆盖率的统计?能多点介绍吗?

最近在搞绩效,等下周找时间写篇教程哈

simple 回复

简单介绍一下,不需要多复杂的配置。粗略的让我知道有点方向。非常感谢大哥。

请邮件给我,可以对你提供些帮助😃

VPDong 回复

邮件给你什么?

仅楼主可见

我找我们团队的专家(VPDong)来帮你解答一下,你俩聊

VPDong 回复

邮件已发。

请问大神,多个 *.ec 文件需要合并成一个时要怎么做呢

小小南瓜 回复

我没用过,但是 jacoco 有个命令行工具可以 merge,

simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 12月13日 06:44
ivy520 [该话题已被删除] 中提及了此贴 12月16日 14:55
ivy520 [该话题已被删除] 中提及了此贴 12月16日 15:53
安涛 [该话题已被删除] 中提及了此贴 12月21日 08:29

请问从 gitlab 拉下来的代码,出现这个问题是什么原因?

java.lang.ClassNotFoundException: org.jacoco.agent.rt.RT
 java.lang.ClassNotFoundException: org.jacoco.agent.rt.RT
     at java.lang.Class.classForName(Native Method)
     at java.lang.Class.forName(Class.java:400)
     at java.lang.Class.forName(Class.java:326)
     at com.example.test.JacocoInstrumentation.generateCoverageReport(JacocoInstrumentation.java:72)
     at com.example.test.JacocoInstrumentation.onActivityFinished(JacocoInstrumentation.java:94)
     at com.example.test.InstrumentedActivity.onDestroy(InstrumentedActivity.java:22)
     at android.app.Activity.performDestroy(Activity.java:7082)
     at android.app.Instrumentation.callActivityOnDestroy(Instrumentation.java:1154)
     at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:4322)
     at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:4353)
     at android.app.ActivityThread.-wrap6(ActivityThread.java)
     at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1618)
     at android.os.Handler.dispatchMessage(Handler.java:102)
     at android.os.Looper.loop(Looper.java:163)
     at android.app.ActivityThread.main(ActivityThread.java:6385)
     at java.lang.reflect.Method.invoke(Native Method)
     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:904)
     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:794)
xiaqing 回复

从报错信息看,是 jacoco 的类没有找到,在这里直接引入 jacoco 就行,没有明白你为什么从 gitlab 上拉代码

zailushang 专栏文章:[定制触发条件] jacoco 统计 Android 代码覆盖率 中提及了此贴 01月01日 14:14
simple [精彩盘点] TesterHome 社区 2018 年 度精华帖 中提及了此贴 01月07日 04:08

请教一下,生成的报告在手机本地可以自动上传吗,如果不能自动上传的话是手工导出来吗,如果 app 重装了,那这个覆盖率的数据不是没有用了?因为基本测试的时候是多个人用不同的手机在测试,如何体现这个版本的 app 的覆盖率呢?

迷糊群 回复

可以自动上传,什么方式都可以
也可以手动导出
app 重装不重装,跟覆盖率没什么关系吧?本文统计的是代码覆盖率。

迷糊群 回复

不同手机的,可以把不同手机的覆盖率文件下载、合并,然后再计算覆盖率

zailushang 回复

自动上传是用的什么方式呢,能说详细一点吗?感谢

迷糊群 回复

我没有做自动上传,因为每次的覆盖率文件比较大。
大文件建议用 FTP 上传吧

你好 我在查看覆盖率报告时发现报告没有行覆盖率,请问你有遇到过这关问题吗

小小南瓜 回复

意思是,点进去以后,看不到哪些代码覆盖了,哪些代码没有覆盖吗?

zailushang 回复

是的 ,想看到具体哪些行被覆盖了,但是我的报告只能看到方法级别的覆盖率

小小南瓜 回复

检查一下这两个路径是否是有效的路径

@zailushang 感谢大神!!

InstrumentedActivity 是不是只能继承标识为 android.intent.action.MAIN 的 Activity,不一定是 ManiActivity 对吧?

不是,用这个方式配置吧,比 Instrument 方式好,更灵活:https://testerhome.com/articles/17546

是用的你说的这个,请问如何实现增量覆盖,我查了很多资料,还使用过 diff-cover 工具,还是没实现增量覆盖率

花开 Android 测试增量和全量覆盖率实践 中提及了此贴 12月06日 08:05
simple 回复

求问,我们 Android 自动化是每执行一条 case 就杀进程,杀了进程就没有代码覆盖记录了。如何统计啊?现在全部都做完了发现了这个问题。求助感谢

jiawei0113 回复

杀进程之前,执行覆盖率文件生成逻辑。

zailushang 回复

每次杀进程之前都执行生成 ec 文件,然后我们大概一百多条 case,这样会生成 100 多个.ec 文件?然后再合并?

jiawei0113 回复

这个怎么成本低,怎么来吧

求助,找了一天没有解决这个问题!!!
在单模块项目里,我按照上面操作成功了。在多模块安装项目里,我把上述文件都放到主模块了,然后使用 adb shell am instrument 出现如下报错:

执行 adb shell am instrument 启动 app 闪退是啥情况呢?

晓只 回复

看日志

zailushang 回复

嗯 找到问题了。。用错模版了 用了 basic 的模版。。

adb pull /data/data/xxx/files/coverage.ec(Permission denied)怎么办,我打印出来在 /data/user/0/xx/files/coverage.ec 下,但是还是 Permission denied,换成/sdcard/下,加了权限,还是 Permission denied😭

这是米 8,安卓 9 系统这样。换个 5.0 手机,pull 文件的时候提示:does not exist

蓝蓝 回复

首先,建议写到 sdcard 中,如果写到包 data 路径下,包被卸载后,.ec 文件会被删除
关于权限问题,可以放到 Cache 路径下,getExternalCacheDir() + "/“,我验证了,安卓 10 也没有权限问题

关于 getExternalCacheDir() 的具体路径信息,可以打印查看

zailushang 回复

可以的,灰常感谢哇!👍

楼主,我还有个问题呀,android.support.* 这种的怎么屏蔽掉?说是 gradle excludes 不管用导致的?

蓝蓝 回复

看看代码?这样说,不知道具体是什么

zailushang 回复

我使用 adb shell am instrument 后 app 闪退了

看看 logcat 的报错

能生产 coverage.ec 了,但是使用上面方法生产报告方法是失败的

zailushang 回复

使用了你的配置 构建失败 代码提示这几个方法 classDirectories sourceDirectories executionData 过时

哦哦,那应该是库方法更新了导致的吧,毕竟这代码两年前的了。
辛苦将替换代码留言一下,我在文章中更新一下,避免误导了后面的同行

zailushang 回复

小米和红米完全不得行 root 过 但是 data 文件夹路径里面没有创建 com.xxx.xx

在线调试看看 log

还有使用真机 adb shell am instrument com.xxx.gotoheipi/com.xxx.gotoheipi.test.JacocoInstrumentation 手机也 root 过 小米 10 完全没反应 如果用模拟器还行

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