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

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

背景

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

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

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

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

有源码地址吗?

CY 回复

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

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上会自动显示最新的覆盖率报告

47楼 已删除
48楼 已删除

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

51楼 已删除

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

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