Android+Jacoco+code-diff全量、增量覆盖率生成实战

背景

主要是记录下Android项目使用jacoco生成代码覆盖率的实战流程,目前已完成全量覆盖方案,仅使用jacoco就能实现;

由于我们的Android端是使用Java和kotlin语言,目前增量的方案code-diff仅针对Java代码,卡在kotlin文件的分析,仍在思考中。

Android由于是本地安装包,只能使用offline模式:

在测试前先对文件进行插桩,然后生成插过桩的class或jar包,测试插过桩的class和jar包后,会生成动态覆盖信息到文件,最后统一对覆盖信息进行处理,并生成报告。

使用场景

其实主要是基于两个痛点:

1、新功能测试和回归测试在手工测试的情况下,即便用例写的再怎么详细,也经常会有漏测的发生,这里一方面是因为现在大量互联网公司采用外包资源来做业务测试,而外包的工作质量无法有效评估,可能存在漏执行的情况,另外一方面是本身测试用例设计的不够完善导致没有覆盖到一些关键路径的代码分支,因此亟需一种可以度量手工测试完成后对代码覆盖情况的手段或者工具;

2、研发代码变更的影响范围难以精准评估,比如研发提交一个MR,这个MR到底影响了多少用例,在没有精准测试能力的情况下是很难给出的,而做精准测试,最重要的一环就是代码用例的关系库维护,如何生成代码跟用例的关系,就需要用到代码覆盖率的采集和分析能力了;

引用简单两步实现 Jacoco+Android 代码覆盖率的接入!(最新最全版)

时机:

1.提测时-明确整个版本迭代的改动范围,测试范围,全量代码diff;

2.测试中-提交bug修复版本,明确问题,使用增量代码diff;

3.预发布-关注关键点,确保发布代码与测试代码一致,全量代码diff;

覆盖率对测试提升:

1.能了解确认需求的实现逻辑,对技术细节查漏补缺;

2.评估影响范围;

3.通过代码补充测试范围,优化测试用例;

4.加深系统实现的理解;

5.提前发现错误

项目环境

python 复制代码
1.gradle插件版本
ANDROID_GRADLE_PLUGIN = "4.2.0"

2.gradle依赖版本
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip

3.android sdk版本
BUILD_TOOLS_VERSION = "28.0.3"
COMPILE_SDK = 31
TARGET_SDK = 31
MIN_SDK = 21

代码介入

1.在app模块下新建一个 jacoco.gradle

javascript 复制代码
apply plugin: 'jacoco'
jacoco {
    toolVersion = "0.8.2"
}

android {
	//在app引入的时候指定对应的变体 会将内容传递引用的工程,主要用于多模块使用
    defaultPublishConfig "debug"
    buildTypes {
        debug {
            /**打开覆盖率统计开关**/
            testCoverageEnabled = true
        }
    }
}
//源代码路径,你有多少个module,你就在这写多少个路径
//我这里是多模块的,需要将主要代码的模块写上
def coverageSourceDirs = [
        '../lib.xx/src/main/java',
        '../lib.xx/src/main/java',
        '../lib.xx/src/main/java',
        '../lib.xx/src/main/java',
        ......
        '/src/main/java',
        '/src/mvp/java'
]

//class文件路径,就是上面提到的class路径,看你的工程class生成路径是什么,替换一下就行
def coverageClassDirs = [
        '/lib.xx/build/intermediates/javac/debug/classes',
        '/lib.xx/build/intermediates/javac/debug/classes',
        '/lib.xx/build/intermediates/javac/debug/classes',
        '/lib.xx/build/intermediates/javac/debug/classes',
        '/app/build/intermediates/javac/debug/classes'
        ......
]
//kotlin的classes文件
def kotlinClassDirs = [
        '/lib.xx/build/tmp/kotlin-classes/debug/',
        '/lib.xx/build/tmp/kotlin-classes/debug/',
        '/lib.xx/build/tmp/kotlin-classes/debug/',
        '/lib.xx/build/tmp/kotlin-classes/debug/',
        '/app/build/tmp/kotlin-classes/debug/'
        ......
]

//这个就是具体解析ec文件的任务,会根据我们指定的class路径、源码路径、ec路径进行解析输出
task jacocoTestReport(type: JacocoReport) {
    group = "Reporting"
    description = "Generate Jacoco coverage reports after running tests."
    reports {
        xml.enabled(true)
        html.enabled(true)
    }
	//设置class文件的路径
	classDirectories.setFrom(files(coverageClassDirs.collect{
            fileTree(
                    dir: "$rootDir"+it,
                    excludes: ['**/R*.class',
                               '**/*$InjectAdapter.class',
                               '**/*$ModuleAdapter.class',
                               '**/*$ViewInjector*.class'])}))
	
    classDirectories.setFrom(files(kotlinClassDirs.collect{
        fileTree(
                dir: "$rootDir"+it,
                excludes: ['**/R*.class',
                           '**/*$InjectAdapter.class',
                           '**/*$ModuleAdapter.class',
                           '**/*$ViewInjector*.class'
                ])}))
                
	//设置源码文件的路径
    sourceDirectories.setFrom(files(coverageSourceDirs))
//设置ec文件
    executionData.setFrom(files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec"))
    doFirst {
        coverageClassDirs.each { path ->
            println("$rootDir" + path)
            new File("$rootDir" + path).eachFileRecurse { file ->
                if (file.name.contains('$$')) {
                    file.renameTo(file.path.replace('$$', '$'))
                }
            }
        }
    }
}

2.在app模块下的build.gradle.kts引用jacoco.gradle,并在buildtype为debug下开启覆盖率的开关

javascript 复制代码
apply(from = "jacoco.gradle")

//引入jacoco
// 开发版本,可打开开发者模式
        getByName("debug") {
            isMinifyEnabled = false
            //引入jacoco
            isTestCoverageEnabled = true
            zipAlignEnabled(false)

3.定义采集覆盖率coverage.ec的方式,网上的方式都是通过监听主activity Destroy后收集,这里可以自己定义适合的方式,比如在项目新增按钮点击采集。参考网上的代码可以,直接用:

在app的代码新建jacoco目录

添加一下代码

FinishListener

java 复制代码
package xx.app.jacoco;

public interface FinishListener {
    void onActivityFinished();
    void dumpIntermediateCoverage(String filePath);
}

InstrumentedActivity

java 复制代码
package xx.app.jacoco;

import xx.Activity;

public class InstrumentedActivity extends Activity {
    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:";
    @SuppressLint("SdCardPath")
    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.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);
        }
    }
}

配置AndroidManifest.xml

javascript 复制代码
<!--引入jacoco-->
        <activity android:name=".jacoco.InstrumentedActivity"
            android:label="InstrumentationActivity"/>
<!--引入jacoco-->
    <instrumentation
        android:name=".jacoco.JacocoInstrumentation"
        android:handleProfiling="true"
        android:label="CoverageInstrumentation"
        android:targetPackage="包名" />

统计子module的覆盖率

因为很多Android项目肯定不只要app module,有很多子module提供使用,需要一起统计覆盖率

目前的做法是在jacoco.gradle 加上参数 defaultPublishConfig "debug"

javascript 复制代码
android {
    //在app 引入的时候指定对应的变体 会将内容传递引用的工程,主要用于多模块使用
    defaultPublishConfig "debug"
    buildTypes {
        debug {
            /**打开覆盖率统计开关**/
            testCoverageEnabled = true
        }
    }
}

然后让子module去引用,这就需要修改子module的build.gradle,一行代码完成

javascript 复制代码
//在子模块引入jacoco
apply(from = "../app/jacoco.gradle")

实战使用

1.通过命令行打debug安装包

installDebug 或者 gradlew app都行

2.通过instrument 启动app

安装完后先打开app再退出一下,不然启动不了

javascript 复制代码
adb shell pm list instrumentation
//会看到以下信息
instrumentation:xx.app/.jacoco.JacocoInstrumentation (target=xx.app)
//然后复制启动
adb shell am instrument co.runner.app/.jacoco.JacocoInstrumentation

3.执行测试

4.完成测试后,在主页面退出app

5.通过Android stdio的device file explorer复制出coverage.ec

路径 /data/data/xx.app/files/coverage.ec

6.将coverage.ec复制到项目文件\app\build\outputs\code_coverage\debugAndroidTest\connected下,如没有的话新建

7.用命令jacocoTestReport生成报告,报名路径如下:

\app\build\reports\jacoco\jacocoTestReport\html

增量代码覆盖率

使用code-diffjacoco二开

用code-diff获取两个commit之间的代码差异,然后生成json文件,使用jacoco二开的jar包通过

--diffCodeFiles 传入差异代码json文件,然后只生成差异代码文件的覆盖报告

总结:KT文件需要改造code-diff才能用,目前只能用于java,后续看看怎么修改。

引用下该作者的话,总结得很好,学习学习:

代码覆盖率100% 不代表没有bug。代码没有覆盖100% 一定有bug;

但是有可能你覆盖到80% 很轻松,往后增加5% 都费很大劲。那么我们可以去没有覆盖到的进行分析。不一定要做到代码100%全覆盖,尤其在功能测试阶段,代码100% 覆盖,会给大家增加很多的工作量,很有可能为了1%的覆盖率而耽误整体测试,得不偿失。

覆盖率是为了提升我们测试用例的覆盖度,检验我们测试用例设计的全面性,它有两面性,合理引入覆盖率,合理选择一定的阈值。

https://cloud.tencent.com.cn/developer/article/1801772

相关推荐
拭心9 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王12 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡12 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道12 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库13 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道14 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe14 小时前
Android Hook - 动态加载so库
android
居居飒15 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He18 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗18 小时前
Android笔试面试题AI答之Android基础(1)
android