示例 JacocoTestForAndroid 见:https://github.com/OnTheWay111/JacocoTestForAndroid
还有一个更灵活的非 instrument 方式: [定制触发条件] jacoco 统计 Android 代码覆盖率
本文两年前的代码,有些代码可能已过时,如果遇到问题,可参考:https://www.jianshu.com/p/ab2be01d7347#comment-60999311

1、背景

最近团队想基于 monkey,进行 app 遍历开发,为了与市场上的遍历方案做对比,以及团队遍历方案的优化比较,故选择了 APP 的代码覆盖率统计。

备注:Instrumentation 方式 1 和 Instrumentation 方式 2 几乎一样

2、代码覆盖率方案选型

目前 Java 常用覆盖率工具 Jacoco、Emma、Cobertura 和 Clover(商用)

Jacoco 是一个开源的覆盖率工具。Jacoco 可以嵌入到 Ant 、Maven 中,并提供了 EclEmma Eclipse 插件,也可以使用 Java Agent 技术监控 Java 程序。很多第三方的工具提供了对 Jacoco 的集成,如:Sonar、Jenkins、IDEA.
综合考虑:选择 jacoco 作为代码覆盖率统计工具。

3、jacoco 注入原理


本次,在尽量避免修改源代码的情况下,我们选择 instrumentation 模式,开始操作。

4、jacoco 注入实操 (instrument 方式)

4.1 创建一个安卓项目

随便创建一个简单的安卓项目

4.2 在代码主路径下新建 test 文件夹,新建 3 个类文件

在 java/下新建 test 文件夹,然后在 test 路径下
新建三个类文件,分别是抽象类 FinishListener,Instrumentation 的 Activity 和 instrumentation 类。

public interface FinishListener {
    void onActivityFinished();
    void dumpIntermediateCoverage(String filePath);
}
public class InstrumentedActivity extends MainActivity {
    public static String TAG = "IntrumentedActivity";
    private FinishListener mListener;
    public void setFinishListener(FinishListener listener) {
        mListener = listener;
    }
    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d(TAG + ".InstrumentedActivity", "onDestroy()");
        super.finish();
        if (mListener != null) {
            mListener.onActivityFinished();
        }
    }
}
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) {
        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() {
        super.onStart();
        Looper.prepare();
        InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent);
        activity.setFinishListener(this);
    }

    private String getCoverageFilePath() {
        if (mCoverageFilePath == null) {
            return DEFAULT_COVERAGE_FILE_PATH;
        } else {
            return mCoverageFilePath;
        }
    }

    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();
                }
            }
        }
    }

    @Override
    public void onActivityFinished() {
        if (LOGD)      Log.d(TAG, "onActivityFinished()");
        if (mCoverage) {
            generateCoverageReport();
        }
        finish(Activity.RESULT_OK, mResults);
    }

    @Override
    public void dumpIntermediateCoverage(String filePath) {

    }
}

4.3 修改 Build.gradle 文件

增加 jacoco 插件和打开覆盖率统计开关

apply plugin: 'com.android.application'
apply plugin: 'jacoco'

android {
    compileSdkVersion 28
    buildToolsVersion "28.0.2"
    defaultConfig {
        applicationId "com.example.app1"
        minSdkVersion 24
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            /**打开覆盖率统计开关*/
             testCoverageEnabled = true
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

4.4 在 manifest.xml 增加声明

<activity android:label="InstrumentationActivity"    android:name="coverage.netease.com.test.InstrumentedActivity" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<instrumentation
        android:handleProfiling="true"
        android:label="CoverageInstrumentation"
        /*这里android:name写明此instrumentation的全称*/
        android:name="com.example.test.JacocoInstrumentation"
        /*这里android:targetPackage写明被测应用的包名*/
        android:targetPackage="com.example.app1"/>

4.5 通过 gradle installDebug 安装到设备上

4.6 在命令行下通过 adb shell am instrument 命令调起 app,具体命令是:

adb shell am instrument com.example.app1/com.example.test.JacocoInstrumentation

也就是 adb shell am instrument /

4.7 调起 app 后我们就可以进行手工测试了,测试完成后点击返回键退出 app。

此时,jacoco 便将覆盖率统计信息写入/data/data//files/coverage.ec 文件。
接下来我们需要新增 gradle task,分析覆盖率文件生成覆盖率 html 报告。

5、生成 html 报告

5.1 首先将 coverage.ec 文件拉到本地,置于指定目录下。

5.2 新增 gradle task,修改 build.gradle 文件为:

apply plugin: 'com.android.application'
apply plugin: 'jacoco'

android {
    compileSdkVersion 28
    buildToolsVersion "28.0.2"
    defaultConfig {
        applicationId "com.example.app1"
        minSdkVersion 24
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            /**打开覆盖率统计开关*/
             testCoverageEnabled = true
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

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/app_classes/debug',
            excludes: ['**/R*.class',
                       '**/*$InjectAdapter.class',
                       '**/*$ModuleAdapter.class',
                       '**/*$ViewInjector*.class'
            ])
    sourceDirectories = files(coverageSourceDirs)
    executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec")

    doFirst {
        new File("$buildDir/intermediates/app_classes/").eachFileRecurse { file ->
            if (file.name.contains('$$')) {
                file.renameTo(file.path.replace('$$', '$'))
            }
        }
    }
}

5.3 生成 html 报告

gradle jacocoTestReport

执行完以上命令后,html 报告生成

打开报告后,如图所示:


↙↙↙阅读原文可查看相关链接,并与作者交流