1、需求背景
- 随着业务与需求的增长,回归测试的范围越来越大,测试人员的压力也日益增加,但即使通过测试同学的保障,线上仍然会存在回归不到位或测试遗漏的地方导致出现线上故障。
- 因此我们需要通过类似
JaCoCo
的集成测试覆盖率统计框架,来衡量测试人员的回归范围是否精准、测试场景是否遗漏;保障上线的代码都已经经过测试人员验证。 - 针对这一点,我们提出了
Android
测试覆盖率统计工具, 借此来提升测试人员精准测试的能力,借助覆盖率数据补充测试遗漏的测试用例。
2、工具选型
Android App
开发主流语言就是 Java
语言,而 Java
常用覆盖率工具为 JaCoCo
、Emma
和 Cobertura
。
类别 | JaCoCo | Emma | Cobertura |
---|
原理 | 使用 ASM 修改字节码 | 可以修改 Jar 文件、class 文件字节码文件 | 基于 Jcoverage 和 ASM 框架对 class 插桩 |
覆盖粒度 | 方法、类、行、分支、指令、圈 | 行、块、方法、类 | 行、分支 |
插桩 | on-the-fly 和 offline | on-the-fly 和 offline | offline |
缺点 | | 不支持 JDK8 | 关闭服务器才能获取覆盖率报告 |
性能 | 快 | 较快 | 较快 |
- 根据上述的一些特点,最终选择 JaCoCo 作为测试覆盖率统计工具。
3、技术选型
![image](https://i-blog.csdnimg.cn/blog_migrate/9370582f63374a7203ad1f20e4a31b91.png)
- 众所周知,获取覆盖率数据的前提条件是需要完成代码的插桩工作。
- 而针对字节码的插桩方式,可分为两种:
3.1 On-The-Fly(在线插桩)
JVM
中 通过 -javaagent
参数指定特定的 jar
文件启动 Instrumentation
的代理程序;- 代理程序在每装载一个
class
文件前判断是否已经转换修改了该文件,如果没有则需要将探针插入 class
文件中。 - 代码覆盖率就可以在
JVM
执行代码的时候实时获取; - 优点:无需提前进行字节码插桩,无需考虑
classpath
的设置。测试覆盖率分析可以在 JVM
执行测试代码的过程中完成。
3.2 Offliine(离线插桩)
- 在测试之前先对字节码进行插桩,生成插过桩的
class
文件或者 jar
包,执行插过桩的 class
文件或者 jar
包之后,会生成覆盖率信息到文件,最后统一对覆盖率信息进行处理,并生成报告。 Offlline
模式适用于以下场景:- 运行环境不支持
Java agent
,部署环境不允许设置 JVM
参数; - 字节码需要被转换成其他虚拟机字节码,如
Android Dalvik VM
动态修改字节码过程中和其他 agent
冲突; - 无法自定义用户加载类
3.3 结论
Android
项目只能使用 JaCoCo
的离线插桩方式。为什么呢?- 一般运行在服务器
Java
程序的插桩可以在加载 class
文件进行,运用 Java Agent
的机制,可以理解成“实时插桩”。但是因为 Android
覆盖率的特殊性,导致 Android
系统破坏了 JaCoCo
这种便利性,原因有两个: - (1)
Android
虚拟机不同与服务器上的 JVM
,它所支持的字节码必须经过处理支持 Android Dalvik
等专用虚拟机,所以插桩必须在处理之前完成,即离线插桩模式。 - (2)
Android
虚拟机没有配置 JVM
配置项的机制,所以应用启动时没有机会直接配置 dump
输出方式。 - 所以通过上述这些最终确定了
Android JaCoCo
覆盖率是采用离线插桩的方式进行。
4、手工获取测试覆盖率
- 为了不修改开发的核心代码,我们可以采用通过
instrumentation
调起被测 App
,在 instrumentation activity
退出时增加覆盖率的统计(不修改核心源代码)。
4.1 添加代码
- 在不修改
Android
源码的情况下,在 src/main/java
里面新增一个 JaCoCo
目录 里面存放 3 个文件:FinishListener
、InstrumentedActivity
、JacocoInstrumentation
。 FinishListener.java
代码:
public interface FinishListener {
void onActivityFinished();
void dumpIntermediateCoverage(String filePath);
}
InstrumentedActivity.java
代码:
public class InstrumentedActivity extends MainActivity {
public FinishListener finishListener;
public void setFinishListener(FinishListener finishListener) {
this.finishListener = finishListener;
}
@Override
public void onDestroy() {
if (this.finishListener != null) {
finishListener.onActivityFinished();
}
super.onDestroy();
}
}
JacocoInstrumentation.java
代码:
public class JacocoInstrumentation extends Instrumentation implements FinishListener {
public static String TAG = "JacocoInstrumentation:";
private static String DEFAULT_COVERAGE_FILE_PATH = "";
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.e(TAG, "onCreate(" + arguments + ")");
super.onCreate(arguments);
DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath() + "/coverage.ec";
File file = new File(DEFAULT_COVERAGE_FILE_PATH);
if (file.isFile() && file.exists()) {
if (file.delete()) {
Log.e(TAG, "file del successs");
} else {
Log.e(TAG, "file del fail !");
}
}
if (!file.exists()) {
try {
file.createNewFile();
} catch (IOException e) {
Log.e(TAG, "异常 : " + e);
e.printStackTrace();
}
}
if (arguments != null) {
Log.e(TAG, "arguments不为空 : " + arguments);
mCoverageFilePath = arguments.getString("coverageFile");
Log.e(TAG, "mCoverageFilePath = " + mCoverageFilePath);
}
mIntent = new Intent(getTargetContext(), InstrumentedActivity.class);
mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
start();
}
@Override
public void onStart() {
Log.e(TAG, "onStart def");
if (LOGD) {
Log.e(TAG, "onStart()");
}
super.onStart();
Looper.prepare();
InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent);
activity.setFinishListener(this);
}
private boolean getBooleanArgument(Bundle arguments, String tag) {
String tagString = arguments.getString(tag);
return tagString != null && Boolean.parseBoolean(tagString);
}
private void generateCoverageReport() {
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.e(TAG, e.toString());
e.printStackTrace();
} 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;
}
private void reportEmmaError(Exception e) {
reportEmmaError("", e);
}
private void reportEmmaError(String hint, Exception e) {
String msg = "Failed to generate emma coverage. " + hint;
Log.e(TAG, msg);
mResults.putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\nError: "
+ msg);
}
@Override
public void onActivityFinished() {
if (LOGD) {
Log.e(TAG, "onActivityFinished()");
}
if (mCoverage) {
Log.e(TAG, "onActivityFinished mCoverage true");
generateCoverageReport();
}
finish(Activity.RESULT_OK, mResults);
}
@Override
public void dumpIntermediateCoverage(String filePath) {
if (LOGD) {
Log.e(TAG, "Intermidate Dump Called with file name :" + filePath);
}
if (mCoverage) {
if (!setCoverageFilePath(filePath)) {
if (LOGD) {
Log.e(TAG, "Unable to set the given file path:" + filePath + " as dump target.");
}
}
generateCoverageReport();
setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH);
}
}
}
4.2 新建一个 jacoco.gradle 文件
- 在项目根目录下新建一个
jacoco.gradle
文件,这个文件提供给各个模块使用。
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.7"
}
def coverageSourceDirs = [
'../app/src/main/java',
]
def coverageClassDirs = [
'../app/build/intermediates/javac/debug/classes',
]
task jacocoTestReport(type: JacocoReport) {
group = "Reporting"
description = "Generate Jacoco coverage reports after running tests."
reports {
xml.enabled(true)
html.enabled(true)
}
classDirectories.setFrom(files(files(coverageClassDirs).files.collect {
fileTree(dir: it,
excludes: ['**/R*.class',
'**/*$InjectAdapter.class',
'**/*$ModuleAdapter.class',
'**/*$ViewInjector*.class'
])
}))
sourceDirectories.setFrom(files(coverageSourceDirs))
executionData.setFrom(files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec"))
doFirst {
coverageClassDirs.each { path ->
new File(path).eachFileRecurse { file ->
if (file.name.contains('$$')) {
file.renameTo(file.path.replace('$$', '$'))
}
}
}
}
}
4.3 在依赖的 Library 模块中添加依赖
- 在您的
app
或子模块的 build.gradle
文件中依赖这个 jacoco.gradle
apply from: '../jacoco.gradle'
4.4 配置 AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="您的包名">
// 添加所需的权限
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_PROFILE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
...
>
<activity
android:name=".jacoco.InstrumentedActivity"
android:label="InstrumentationActivity" />
</application>
<instrumentation
android:name=".jacoco.JacocoInstrumentation"
android:handleProfiling="true"
android:label="CoverageInstrumentation"
android:targetPackage="您的包名" />
</manifest>
4.5 生成测试报告
- (1)
installDebug
- 首先我们通过命令行安装 app
) - 选择您的
app -> Tasks -> install -> installDebug
,安装 app
到您的手机上。 - (2)命令行启动
adb shell am instrument 您的包名/您的包名.jacoco.JacocoInstrumentation
- (3)点击测试
- 这个时候你可以操作您的
app
,对您想进行代码覆盖率检测的地方,进入到对应的页面,点击对应的按钮,触发对应的逻辑,你现在所操作的都会被记录下来,在生成的 coverage.ec
文件中都能体现出来。当您点击完了,根据我们之前设置的逻辑,当我们 MainActivity
执行 onDestroy()
方法时才会通知 JacocoInstrumentation
生成 coverage.ec
文件,我们可以按返回键退出 MainActivity
返回桌面,生成 coverage.ec
文件可能需要一点时间哦(取决于您点击测试页面多少,测试越多,生成文件越大,所需时间可能多一点) - 然后在
Android Studio的Device File Explore
中,找到 d ata/data/包名/files/coverage.ec
文件,右键保存到桌面备用。 - (4)
createDebugCoverageReport
![image](https://i-blog.csdnimg.cn/blog_migrate/3393fbd9e11220bfb8792f3065864d94.png)
- 选择您的
app -> Tasks -> verification -> createDebugCoverageReport
,然后执行。 - (5)
jacocoTestReport
![image](https://i-blog.csdnimg.cn/blog_migrate/ca19a00b8becf2db74bad0c1d90e8c37.png)
- 找到这个路径,双击执行这个任务,会生成我们最终所需要代码覆盖率报告,执行完后,我们可以在这个目录下找到它
app/build/reports/jacoco/jacocoTestReport/html/index.html
4.6 分析报告
![image](https://i-blog.csdnimg.cn/blog_migrate/babc2b20398e9fe969760a2a5258cf6e.png)
5、在上述方案上可再改进
- 上述的步骤最终可以通过一个或者多个
.sh
脚本去执行,从而降低复杂程度 。 - 上述方法都是基于 Android 全量代码手工测试的覆盖率统计,需要改进的是最终变成 Android 增量代码手工测试的覆盖率统计。
6、参考
- https://zhuanlan.zhihu.com/p/88332971
- https://tech.meituan.com/2017/06/16/android-jacoco-practace.html
- https://blog.csdn.net/woshizisezise/article/details/115638097
- https://tech.kujiale.com/androidjing-zhun-ce-shi-tan-suo-ce-shi-fu-gai-lu-tong-ji/
- https://juejin.cn/post/6920029313316159502