测试基础 使用 Jacoco 实现 Android 端手工测试覆盖率统计

淼淼淼 · May 08, 2017 · Last by 小小南瓜 replied at December 10, 2018 · 8636 hits
本帖已被设为精华帖!

背景

前段时间在研究手工测试覆盖率问题,尝试将结果记录下来。有什么问题欢迎同学指正. : )

  • 由于现在单元测试在我们这小公司无法推行,且为了解决新功能测试以及回归测试在手工测试的情况下,即便用例再为详尽,也会存在遗漏的用例。通过统计手工测试覆盖率的数据,可以及时的完善用例。 经过了解准备使用 Jacoco 完成这个需求.Jacoco 是 Java Code Coverage 的缩写,在统计完成 Android 代码覆盖率的时候使用的是 Jacoco 的离线插桩方式,在测试前先对文件进行插桩,在手工测试过程中会生成动态覆盖信息,最后统一对覆盖率进行处理,并生成报告;通过了解现在实现 Android 覆盖率的方法主要有两种方式,一是通过 activity 退出的时候添加覆盖率的统计,但是这种情况会修改 app 的源代码。另外一种是使用的是 Android 测试框架 Instrumentation。这次需求的实现使用的是 Instrumentation.。

实现

1. 将 3 个类文件放入项目 test 文件夹;

  • 具体各个类的代码如下:

FinishListener:

package 你的包名;
public interface FinishListener {
    void onActivityFinished();
    void dumpIntermediateCoverage(String filePath);
}

InstrumentedActivity:

package你的包名;
import 你的启动的activity;
import android.util.Log;

public class InstrumentedActivity extends MainActivity {
    public static String TAG = "InstrumentedActivity";

    private你的包名.test.FinishListener mListener;

    public void setFinishListener(FinishListener listener) {
        mListener = listener;
    }


    @Override
    public void onDestroy() {
        Log.d(TAG + ".InstrumentedActivity", "onDestroy()");
        super.finish();
        if (mListener != null) {
            mListener.onActivityFinished();
        }
    }

} 

JacocoInstrumentation:

package 包名.test;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.os.Bundle;
import android.os.Looper;
import android.util.Log;

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;


    /**
     * Constructor
     */
    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() {
        if (LOGD)
            Log.d(TAG, "onStart()");
        super.onStart();

        Looper.prepare();
        InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent);
        activity.setFinishListener(this);
    }

    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();
                }
            }
        }
    }

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

    private boolean setCoverageFilePath(String filePath){
        if(filePath != null && filePath.length() > 0) {
            mCoverageFilePath = filePath;
            return true;
        }
        return false;
    }


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

    @Override
    public void dumpIntermediateCoverage(String filePath){
        // TODO Auto-generated method stub
        if(LOGD){
            Log.d(TAG,"Intermidate Dump Called with file name :"+ filePath);
        }
        if(mCoverage){
            if(!setCoverageFilePath(filePath)){
                if(LOGD){
                    Log.d(TAG,"Unable to set the given file path:"+filePath+" as dump target.");
                }
            }
            generateCoverageReport();
            setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH);
        }
    }

}

2. 修改 build.gradle 文件

  • 增加 Jacoco 插件,打开覆盖率统计开关,生成日志报告.

添加的代码内容:

apply plugin: 'jacoco'

jacoco {
    toolVersion = "0.7.9"
}
android {
    buildTypes {
            debug { testCoverageEnabled = true
    /**打开覆盖率统计开关/
        }
}

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/classes/debug',
            excludes: ['**/R*.class',
                       '**/*$InjectAdapter.class',
                       '**/*$ModuleAdapter.class',
                       '**/*$ViewInjector*.class'
            ])
    sourceDirectories = files(coverageSourceDirs)
    executionData = files("$buildDir/outputs/code-coverage/connected/flavors/coverage.ec")

    doFirst {
        new File("$buildDir/intermediates/classes/").eachFileRecurse { file ->
            if (file.name.contains('$$')) {
                file.renameTo(file.path.replace('$$', '$'))
            }
        }
    }
}
dependencies {
        compile fileTree(dir: 'libs', include: ['*.jar'])
}

3. 修改 AndroidManifest.xml 文件
添加以及修改部分:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<activity android:label="InstrumentationActivity"    android:name="包名.test.InstrumentedActivity" />
 <instrumentation
    android:handleProfiling="true"
    android:label="CoverageInstrumentation"
    android:name="包名.test.JacocoInstrumentation"
    android:targetPackage="包名"/>

4. 我们需要通过 adb shell am instrument 包名/包名.test.JacocoInstrumentation 启动 app;

5. 进行 app 手工测试,测试完成后退出 App,覆盖率文件会保存在手机/data/data/yourPackageName/files/coverage.ec 目录

6. 导出 coverage.ec 使用 gradle jacocoTestReport 分析覆盖率文件并生成 html 报告

7. 查看覆盖率 html 报告

  • app\build\reports\jacoco\jacocoTestReport\html 目录下看到 html 报告

  • 打开 index.html,就可以看到具体的覆盖率数据了

遗留问题:
Jacoco 报告生成中文乱码问题 (已解决,感谢@chenhengjie123)
参考文章:
JaCoCo-原理篇
Android 手工测试代码覆盖率增强版

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

不错,基础讲到了。

思寒_seveniruby 将本帖设为了精华贴 09 May 14:40

正准备来这个,学习一下。

感觉还是修改源码更方便,可以不用考虑启动方式

有源码地址吗?

淼淼淼 #6 · May 10, 2017 Author
rhyme #4 回复

嗯 我也同意 但是要求团队能够接受

淼淼淼 #7 · May 10, 2017 Author

我是用的我们公司自己的项目,其他需要配置和各个加的类 文章已经说清楚了哦 可以动手试试

淼淼淼 #6 回复

我们目前采用加入源码的方式,但前期由于我们自己的覆盖率收集也不大完善,所以通过把添加的代码做成 git patch ,然后每次打包通过 git apply patch 的形式把为了覆盖率添加的配置项及代码加上,再打出带有覆盖率收集的包(在 BaseActivity 中的 onStop 加了自动生成和上传覆盖率的功能)。测试团队在测试环境统一使用带有覆盖率收集的包进行测试,这样收集到的覆盖率信息相对比较齐全,分析价值比较大。

也和开发团队沟通过,他们的诉求是只要能确保对外发布的 release 的包不带有覆盖率相关信息(即采用 release 方式打包时,覆盖率的相关代码全部不生效或不会包含在编译后的包中),开发是可以接受把覆盖率收集加入到他们的代码仓库中的。

淼淼淼 #7 回复

菜鸟一枚,你这个是在 app 源码的基础上操作的吧

陈恒捷 #8 回复

求分享思路,搞个 demo

赞,不过各个方法一点注释没有,小白表示完全不知道为啥这么写的

淼淼淼 #12 · May 16, 2017 Author

是的,你可以在 github 上找一个 app 试试

修改 AndroidManifest.xml 文件 会报无效的 header

看你之前生成报告的时候报错了,不知道有什么解决方法?我现在也碰到了,

Configuration on demand is an incubating feature.
Incremental java compilation is an incubating feature.
:app:jacocoTestReport SKIPPED
淼淼淼 #15 · May 25, 2017 Author
wm #14 回复

可能的问题:没有读到 coverage.ec 文件或确保 coverage.ec 有数据.查看一下 jacocoTestReport 配置的路径是不是跟你自己的文件路径一致.我当时就是文件路径的问题

测试执行过程中会覆盖安装多次 APK,请问覆盖率文件会被覆盖吗?

淼淼淼 #17 · May 26, 2017 Author
yiwang #16 回复

如果怕被覆盖可以设置 DEFAULT_COVERAGE_FILE_PATH 保存的路径。因为现在的文件是存到 apkfile 里面的。如果删除 apk,文件也会被删除。

请问你从真机中把数据拷出来是怎么操作的呢?尝试了 adb shell run-as 包名 这个命令没有实现我的目的,求指教,谢谢!

淼淼淼 #19 · May 26, 2017 Author
wm #18 回复

adb pull coverage.ec 路径 本地路径

淼淼淼 #19 回复

没有 root 的机器这个命令不能用吧?我每次执行都会提示 does not exist,实际进入该目录是有的

陈恒捷 #8 回复

你好 请问有详细点的方案吗

summe #21 回复

这块我后面写个帖子吧。流程本身其实比较简单。

最后生成的报告覆盖率都为 0,但是看 coverage.ec 是有数据的,这个大概问题出在哪里😓

淼淼淼 #24 · June 08, 2017 Author

$buildDir/outputs/code-coverage/connected/flavors/coverage.ec
你看下有没有把导出来的 ec 文件放到这个路径,然后执行 gradle jacocoTestReport

@nil 按照你的教程,老是报下面的错误,如何解决,求指导,谢谢
INSTRUMENTATION_STATUS: id=ActivityManagerService
INSTRUMENTATION_STATUS: Error=Unable to find instrumentation info for: ComponentInfo{com.bluepay.example/com.bluepay.example.test.JacocoInstrumentation}
INSTRUMENTATION_STATUS_CODE: -1
android.util.AndroidException: INSTRUMENTATION_FAILED: com.bluepay.example/com.bluepay.example.test.JacocoInstrumentation
at com.android.commands.am.Am.runInstrument(Am.java:953)
at com.android.commands.am.Am.onRun(Am.java:318)
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:249)

淼淼淼 #26 · June 22, 2017 Author

能否提供详细一点的信息呢?比如运行命令和 jacoco 代码

淼淼淼 #26 回复

可否加个微信聊,方便快捷 ☺

最好在帖子回复,贴代码方便,而且其他同学有可能会遇到你类似的问题,我也有可能不能解答哦,大神们看到了可以帮助回答。

淼淼淼 #28 回复

代码和你的是一样的,但是在安装应用的时候发现那个 Instrumentation 没有安装到 app 中去,使用命令 adb shell pm list instrumentation 没有发现我的包名,所以使用命令 adb shell am instrument 包名/包名.test.JacocoInstrumentation 就会直接报上面那个错误

陈恒捷 #8 回复

请教个问题,如果
1.app 含有引导页,启动顺序如.SplashActivity -->.GuideActivity-->HomeActivity,
2.在 Manifest 文件中 SplashActivity 被声明
android.intent.category.LAUNCHER,android.intent.action.MAIN。
且 app 限制只能由 SplashActivity 启动。
那么,InstrumentedActivity 中 ” public class InstrumentedActivity extends XXXActivity {} “ XXXActivity 应该是哪个呢?
SplashActivity?HomeActivity?GuideActivity?

淼淼淼 #31 · June 29, 2017 Author
zhang #30 回复

SplashActivity 吧

淼淼淼 #15 回复

你好,发现 coverage.ec 没有数据这个要如何查问题

独行数息 jacoco 代码覆盖率求助 中提及了此贴 29 Jun 20:43

棒 收藏了

淼淼淼 #35 · June 30, 2017 Author
summe #32 回复

需要通过 adb shell am instrument 包名/包名.test.JacocoInstrumentation 启动 app,
通过你的手工操作后,文件会保存在/data/data/yourPackageName/files/coverage.ec,
不知道这几个步骤你有实现吗?

淼淼淼 #36 · June 30, 2017 Author

不好意思 ,没注意到 .
检查一下你的 AndroidManifest.xml,配对没有?

淼淼淼 #31 回复

谢谢回复 :)
我在带有引导页的 app 里插桩,public class InstrumentedActivity extends SplashActivity , 能顺利打出 debug 包。但是一运行 adb shell am instrument packagename/.JacocoInstrument.JacocoInstrumentation,结果 app 总是闪退。您给看看,可能是什么原因造成的呢?

淼淼淼 #38 · June 30, 2017 Author
zhang #37 回复

我之前也遇到过这个问题:https://testerhome.com/topics/7650
我的是配置问题...

淼淼淼 #36 回复

谢谢 ,按照你的方法把 applications 放到 instrumentation 前面,跑起来了。


但是有个新问题 :往 coverage.ec 这个文件里面写数据的时候报错了,java.lang.ClassNotFoundException: org.jacoco.agent.rt.RT,这个类找不到,所以最后报告没数据

淼淼淼 #40 · July 03, 2017 Author

我之前也遇到过。
https://testerhome.com/topics/2524
看下易寒这篇帖子的评论,你回找到答案!

爬楼完成,已经搞定。
遇到的问题:
1、·gradle clean· 出错,可以先使用·gradlew clean·
2、目录需要放在项目下,不能放在 AndroidTest 下
3、如果 Instrument 找不到,需要记得在 build.gradle 里面把 instrument 放在 application 外面
4、如果没有拿到 coverage.ec 文件,可以把文件目录变为/mnt/sdcard/coverage.ec
5、coverage.ec 文件为 0K 是因为没有退出 app
6、如果使用命令 adb shell am instrument package/package.test.JacocoInstrumentation启动 app 时闪退,需要用 logcat 去查日志,具体是什么问题

clean 了下,不知道怎么就好了


@nil 楼主好,请问生成的覆盖率文件:coverage.ec 从上手机上 pull 出来后,是需要放到指定目录吗?我是把这个文件放在工程的根目录,但是使用命令:gradle jacocoTestReport 报错了,日志:

E:\Code-for-Git\A-Native-TesterHome\app>gradle createDebugCoverageReport

FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring project ':app'.
> Could not resolve all dependencies for configuration ':app:_debugApk'.
   > A problem occurred configuring project ':swipebackhelper'.
      > Failed to notify project evaluation listener.
         > com.android.build.gradle.tasks.factory.AndroidJavaCompile.setDependen
cyCacheDir(Ljava/io/File;)V

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug
option to get more log output.

BUILD FAILED in 1s

实在是没有解决办法,求助!

楼主,运行 instrumentation 的时候,有一下的错误。说找不到类 java.lang.ClassNotFoundException: org.jacoco.agent.rt.RT。
gradle 版本未 3.3,应该自带了 jacoco 插件吧


麻烦帮忙看看,谢谢了。

小米 MAX2 手机,在双击 createDebuCoverageReport 后生成一个 MI MAX 2-7.1.1-coverage.ec 文件和相应的报告,删除此文件,将手工测试后生成的 coverage.ec 文件替换进去后,再次生成报告覆盖率都是 0%,求大神帮忙。

你好,请问一下 android 多 module 开发时,模块 module 在编译时,会默认用打成 aar 类似 jar 包的形式,这个时候就没办法统计到 module 中的覆盖率,不知道有么有什么好的办法呢?

你好!请问下如果采用手工测试覆盖率,然后开发做了处理,把覆盖率的报告自动上传到某个 ftp 上,这样集成到 jenkins 上会自动显示最新的覆盖率报告

47Floor has deleted
48Floor has deleted

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

51Floor has deleted

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

淼淼淼 关闭了讨论 01 Jan 09:53
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up