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

淼淼淼 · 2017年05月08日 · 最后由 小小南瓜 回复于 2018年12月10日 · 7443 次阅读
本帖已被设为精华帖!

背景

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

  • 由于现在单元测试在我们这小公司无法推行,且为了解决新功能测试以及回归测试在手工测试的情况下,即便用例再为详尽,也会存在遗漏的用例。通过统计手工测试覆盖率的数据,可以及时的完善用例。 经过了解准备使用 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 将本帖设为了精华贴 05月09日 14:40

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

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

有源码地址吗?

rhyme 回复

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

xiaoshuangbei 回复

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

淼淼淼 回复

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

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

淼淼淼 回复

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

陈恒捷 回复

求分享思路,搞个 demo

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

xiaoshuangbei 回复

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

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

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

Configuration on demand is an incubating feature.
Incremental java compilation is an incubating feature.
:app:jacocoTestReport SKIPPED
wm 回复

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

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

yiwang 回复

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

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

wm 回复

adb pull coverage.ec 路径 本地路径

淼淼淼 回复

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

陈恒捷 回复

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

summe 回复

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

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

对酒当歌 回复

$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)

独行数息 回复

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

淼淼淼 回复

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

独行数息 回复

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

淼淼淼 回复

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

陈恒捷 回复

请教个问题,如果
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?

zhang 回复

SplashActivity 吧

淼淼淼 回复

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

独行数息 jacoco 代码覆盖率求助 中提及了此贴 06月29日 20:43
summe 回复

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

独行数息 回复

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

淼淼淼 回复

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

zhang 回复

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

淼淼淼 回复

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


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

独行数息 回复

我之前也遇到过。
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 上会自动显示最新的覆盖率报告

45楼 已删除
46楼 已删除

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

47楼 已删除

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

淼淼淼 关闭了讨论 01月01日 09:53
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册