一、前言
JaCoCo 是一个免费的 Java 代码覆盖率库,由 EclEmma 团队根据多年使用和集成现有库的经验教训创建。官网地址
JaCoCo是面向Java的开源代码覆盖率工具,JaCoCo以Java代理模式运行,它负责在运行测试时检测字节码。 JaCoCo会深入研究每个指令,并显示每个测试过程中要执行的行。 为了收集覆盖率数据,JaCoCo使用ASM即时进行代码检测,并在此过程中从JVM Tool Interface接收事件,最终生成代码覆盖率报告。
Jacoco运行有离线(offline)、在线(on the fly)模式之说,所谓在线模式就是在应用启动时加入jacoco agent进行插桩,在开发、测试人员使用应用期间实时地进行代码覆盖率分析。相信很多的java项目开发人员并不会去写单元测试代码的,因此覆盖率统计就要把手工测试或接口测试覆盖的情况作为重要依据,显然在线模式更符合实际需求。
二、AndroidStudio 集成Jacoco
- 环境
- 根目录build.gradle
buildscript {
dependencies {
classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.5.3'
classpath "org.jacoco:org.jacoco.core:0.8.8"
}
}
plugins {
id 'com.android.application' version '7.3.0' apply false
id 'com.android.library' version '7.3.0' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
id 'com.google.dagger.hilt.android' version '2.44' apply false
}
- gradle依赖版本
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
- 编译sdk
compileSdk : 33
minSdk : 21
targetSdk : 33
3、 配置jacoco
- 在项目根目录新建jacoco.gradle 文件
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.8"
}
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
}
ext {
getFileFilter = { ->
def jacocoSkipClasses = null
if (project.hasProperty('jacocoSkipClasses')) {
jacocoSkipClasses = project.property('jacocoSkipClasses')
}
//忽略类文件配置
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*$ViewInjector*.*']
if (jacocoSkipClasses != null) {
fileFilter.addAll(jacocoSkipClasses)
}
return fileFilter
}
}
task jacocoTestReport(type: JacocoReport) {
group = "Reporting"
description = "Generate Jacoco coverage reports"
reports {
xml.enabled(true)
html.enabled(true)
}
def fileFilter = project.getFileFilter()
println("files:"+fileFilter)
def coverageClassDirs = ["$project.buildDir/intermediates/javac/debug/classes"
, "$rootDir/module_data/build/intermediates/javac/debug/classes"]
getClassDirectories().setFrom(files(files(coverageClassDirs).files.collect {
println("class:"+it)
fileTree(dir: it, excludes: fileFilter)
}))
def coverageSourceDirs = ["$project.projectDir/src/main/java", "$rootDir/module_data/src/main/java"]
//设置需要检测覆盖率的目录
println("source:"+coverageSourceDirs)
getSourceDirectories().setFrom(files(coverageSourceDirs))
def executionDirs=["$project.projectDir/build/outputs/code_coverage/debugAndroidTest/connected/coverage.ec"]
println("exe:"+executionDirs)
//以下路径也需要检查
// getExecutionData().setFrom(fileTree(dir: project.buildDir, includes: ['outputs/code-coverage/debugAndroidTest/connected/coverage.ec']))
getExecutionData().setFrom(files(executionDirs))
doFirst {
//遍历class路径下的所有文件,替换字符
coverageClassDirs.each { path ->
new File(path).eachFileRecurse { file ->
if (file.name.contains('$$')) {
file.renameTo(file.path.replace('$$', '$'))
}
}
}
}
}
- 生成ec 文件。
在app/module 包下新建test目录,新建如下类
FinishListener
public interface FinishListener {
void onActivityFinished();
void dumpIntermediateCoverage(String filePath);
}
InstrumentedActivity
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
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;
public JacocoInstrumentation() {
}
@Override
public void onCreate(Bundle arguments) {
LogUtil.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()) {
LogUtil.e(TAG, "file del successs");
} else {
LogUtil.e(TAG, "file del fail !");
}
}
if (!file.exists()) {
try {
file.createNewFile();
} catch (IOException e) {
LogUtil.e(TAG, "异常 : " + e);
e.printStackTrace();
}
}
if (arguments != null) {
LogUtil.e(TAG, "arguments不为空 : " + arguments);
mCoverageFilePath = arguments.getString("coverageFile");
LogUtil.e(TAG, "mCoverageFilePath = " + mCoverageFilePath);
}
mIntent = new Intent(getTargetContext(), InstrumentedActivity.class);
mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
start();
}
@Override
public void onStart() {
LogUtil.e(TAG, "onStart def");
if (LOGD) {
LogUtil.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) {
LogUtil.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;
LogUtil.e(TAG, msg);
mResults.putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\nError: "
+ msg);
}
@Override
public void onActivityFinished() {
if (LOGD) {
LogUtil.e(TAG, "onActivityFinished()");
}
if (mCoverage) {
LogUtil.e(TAG, "onActivityFinished mCoverage true");
generateCoverageReport();
}
finish(Activity.RESULT_OK, mResults);
}
@Override
public void dumpIntermediateCoverage(String filePath) {
// TODO Auto-generated method stub
if (LOGD) {
LogUtil.e(TAG, "Intermidate Dump Called with file name :" + filePath);
}
if (mCoverage) {
if (!setCoverageFilePath(filePath)) {
if (LOGD) {
LogUtil.e(TAG, "Unable to set the given file path:" + filePath + " as dump target.");
}
}
generateCoverageReport();
setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH);
}
}
}
配置AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application>
<activity
android:name=".test.InstrumentedActivity"
android:label="InstrumentationActivity" />
</application>
<instrumentation
android:name=".test.JacocoInstrumentation"
android:handleProfiling="true"
android:label="CoverageInstrumentation"
android:targetPackage="com.qianyin.user" />
三、生成测试报告
installDebug
我们通过命令行安装app,选择你的app -> Tasks -> install -> installDebug,安装app到你的设备上。命令行启动app
先列出手机中已安装的instrumentation:
adb shell pm list instrumentation
启动app
adb shell am instrument com.qianyin.user/.test.JacocoInstrumentation
- 正常点击测试app的功能
这个时候你可以操作你的app,对你想进行代码覆盖率检测的地方,进入到对应的页面,点击对应的按钮,触发对应的逻辑,你现在所操作的都会被记录下来,在生成的coverage.ec文件中都能体现出来。当你点击完了,根据我们之前设置的逻辑,当我们MainActivity执行onDestroy方法时才会通知JacocoInstrumentation生成coverage.ec文件,我们可以按返回键退出MainActivity返回桌面,生成coverage.ec文件可能需要一点时间哦(取决于你点击测试页面多少,测试越多,生成文件越大,所需时间可能多一点)
然后在Android Studio的Device File Explore中,找到data/data/包名/files/coverage.ec文件,右键保存到桌面备用
-
createDebugCoverageReport
双击它,会执行创建覆盖率报告的命令,等待它执行完,这个会生成一个coverage.ec文件,但是这个不是我们最终需要分析的,我们需要分析的是我们刚才手动点击保存到桌面的那个。将保存到桌面的ec文件覆盖这个ec文件。
备注
jacoco.gradle 文件中getExecutionData().setFrom(files(executionDirs)) 中 文件目录要和coverage.ec文件的目录保持一致否则会有问题。
-
jacocoTestReport
找到这个路径,双击执行这个任务,会生成我们最终所需要代码覆盖率报告,执行完后,我们可以在这个目录下找到它。生成文件的目录可以自定义
app/build/reports/jacoco/jacocoTestReport/html/index.html
-
分析报告
四、问题
1、执行 jacocoTestReport task 出现 Task :app:jacocoTestReport SKIPPED
可能原因:在 gradle 配置的路径,比如上面的 app/build/outputs/code-coverage/connected/ 路径创建失败或有错误;检查路径是否有拼写错误,是否与配置的一致。该路径在配置时可自定义,在定义后存放 .ec 文件的路径需与此保持一致!
2、gradle 的更新,一些gradle方法已经过时,建议更新成最新方法使用,如getClassDirectories();getSourceDirectories();getExecutionData()