本文目的
1、快速生成高覆盖率的测试报告,免去研发手写单元测试用例代码的辛苦,专注本职工作。
2、一套框架,不同项目可快速移植,前后不超过10分钟
对此方案有极大帮助的帖子如下,少走了许多弯路:
https://javaforall.cn/162125.html
项目代码地址:
链接: https://pan.baidu.com/s/19MpEIpTP_f1vl5fa0HcohA?pwd=edv6
提取码: edv6
核心原理
官方生成代码覆盖率报告的流程
gradle为android提供的插件生成代码覆盖率的报告流程为首先在应用目录的生成coverage.ec文件(比如我们的应用package为com.wuba.wuxian.android_0504,那么这个coverage.ec在测试完成时会在android系统的/data/data/com.wuba.wuxian.android_0504/目录下生成),然后pull到本地的项目根目录的build/outputs/code-coverage/connected 目录下,这个时候执行createDebugCoverageReport 根据这个coverage.ec和build/intermediates/classes/debug 目录下的class文件生成报告,报告存放在项目根目录下/build/outputs/reports/coverage/debug 下。
最重要的文件为coverage.ec,我们通过写一套公共的代码,生成此coverage.ec文件,手动从测试机中pull到配置文件需要解析的地方,然后利用AndroidStudio->gradle->reporting-> jacocoTestReport生成覆盖率报告。
主流测试方法
在android测试框架中,常用的有以下几个框架和工具类:
JUnit4:Java最常用的单元测试框架
AndroidJUnitRunner:适用于 Android 且与 JUnit 4 兼容的测试运行器
Mockito:Mock测试框架
Espresso:UI 测试框架;适合应用中的功能性 UI 测试
UI Automator:UI 测试框架;适合跨系统和已安装应用的跨应用功能性 UI 测试
以上几种方法费时费力,不仅需要手写大量的测试代码,还需要学习对应框架的语法知识,无疑增加了使用成本,所以全部PASS
工具选型
Android App 开发主流语言就是 Java 语言,而 Java 常用覆盖率工具为 JaCoCo、Emma 和 Cobertura。
众所周知,获取覆盖率数据的前提条件是需要完成代码的插桩工作。而JaCoCo针对字节码的插桩方式,可分为两种:
On-The-Fly(在线插桩):
1、JVM 中 通过 -javaagent 参数指定特定的 jar 文件启动 Instrumentation 的代理程序;
2、代理程序在每装载一个 class 文件前判断是否已经转换修改了该文件,如果没有则需要将探针插入 class 文件中。
3、代码覆盖率就可以在 JVM 执行代码的时候实时获取;
优点:无需提前进行字节码插桩,无需考虑 classpath 的设置。测试覆盖率分析可以在 JVM 执行测试代码的过程中完成。
Offliine(离线插桩):
1、在测试之前先对字节码进行插桩,生成插过桩的 class 文件或者 jar 包,执行插过桩的 class 文件或者 jar 包之后,会生成覆盖率信息到文件,最后统一对覆盖率信息进行处理,并生成报告。
2、Offlline 模式适用于以下场景:
运行环境不支持 Java agent,部署环境不允许设置 JVM 参数;
字节码需要被转换成其他虚拟机字节码,如 Android Dalvik VM 动态修改字节码过程中和其他 agent 冲突;
无法自定义用户加载类
Android 项目使用的是 JaCoCo 的离线插桩方式
生成步骤
1、
首先在自己的工程App,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) {
// TODO Auto-generated method stub
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);
}
}
}
2、
新建jacoco.gradle 文件,这个文件提供给各个模块使用jacoco.gradle
jacoco {
toolVersion = "0.8.2"
}
//源代码路径,你有多少个module,你就在这写多少个路径
def coverageSourceDirs = [
"$rootDir"+ '/app/src/main/java'
]
//class文件路径,就是我上面提到的class路径,看你的工程class生成路径是什么,替换我的就行
def coverageClassDirs = [
"$rootDir"+ '/app/build/intermediates/javac/debug/classes'
]
//这个就是具体解析ec文件的任务,会根据我们指定的class路径、源码路径、ec路径进行解析输出
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,
// 过滤不需要统计的class文件
excludes: ['**/R*.class',
])
}))
sourceDirectories.setFrom(files(coverageSourceDirs))
executionData.setFrom(files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec"))
doFirst {
//遍历class路径下的所有文件,替换字符
coverageClassDirs.each {
path ->
new File(path).eachFileRecurse {
file ->
if (file.name.contains('$$')) {
file.renameTo(file.path.replace('$$', '$'))
}
}
}
}
}
项目的build.gradle 修改如下,注意不是App 下的build.gradle:
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
jcenter()
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath'com.android.tools.build:gradle:4.2.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
classpath "org.jacoco:org.jacoco.core:0.8.2"
classpath 'com.dicedmelon.gradle:jacoco-android:0.1.5'
}
}
allprojects {
repositories {
google()
jcenter()
maven {
url "https://plugins.gradle.org/m2/"
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
3、
app 中的build.gradle依赖这个 jacoco.gradle
apply from: 'jacoco.gradle'
配置 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>
生成测试报告
编译好APK后
1.installDebug
选择你的app -> Tasks -> install -> installDebug,安装app到你的手机上。2.命令行启动
adb shell am instrument com.android.test/com.android.test.jacoco.JacocoInstrumentation
3.点击测试
这个时候你可以操作你的app,对你想进行代码覆盖率检测的地方,进入到对应的页面,点击对应的按钮,触发对应的逻辑,你现在所操作的都会被记录下来,在生成的coverage.ec文件中都能体现出来。当你点击完了,根据我们之前设置的逻辑,当我们MainActivity执行onDestroy方法时才会通知JacocoInstrumentation生成coverage.ec文件,我们可以按返回键退出MainActivity返回桌面,生成coverage.ec文件可能需要一点时间哦(取决于你点击测试页面多少,测试越多,生成文件越大,所需时间可能多一点)
生成coverage.ec的逻辑可以自行修改,不必必须在onDestroy中回调,宗旨就是你感觉APP功能你点的差不多了,在某个地方调用生成coverage.ec。步骤2的目的是为了开启手动测试的模式。
然后把生成的coverage.ec从调试的设备中取出来存在电脑的某个地方以备后用,代码默认存储的地址为
DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath() + "/coverage.ec";
4.createDebugCoverageReport
执行完毕后,会在以下路径生成此 .ec结尾的文件,删除即可
然后把刚才保存的coverage.ec复制到此路径下
此路径要和 jacoco.gradle中,路径一致。
executionData.setFrom(files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec"))
5.jacocoTestReport
双击执行这个任务,会生成我们最终所需要代码覆盖率报告,执行完后,我们可以在这个目录下找到它
app/build/reports/jacoco/jacocoTestReport/html/index.html
总结:
1、原理类似于偷梁换柱,AndroidStudio生成的解析文件我们不需要,然后使用我们代码生成的解析文件。
2、不同的项目,我们只需要修改特定的几个地方就可完成报告的生成。
3、jacoco.gradle
classDirectories.setFrom(files(files(coverageClassDirs).files.collect {
fileTree(dir: it,
// 过滤不需要统计的class文件
excludes: ['**/R*.class',
])
}))
过滤文件较多的话,可以增加覆盖率
4、App下的build.gradle
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
debuggable true //此开关要打开
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
testCoverageEnabled true
zipAlignEnabled false
minifyEnabled false
shrinkResources false //自动移除无用资源
}
}
参考链接:
1、https://blog.csdn.net/u011035026/article/details/125367266
2、https://blog.csdn.net/sanmi8276/article/details/116763511