Android使用JaCoCo生成代码覆盖率

本文目的

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。

各工具比较.png

众所周知,获取覆盖率数据的前提条件是需要完成代码的插桩工作。而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.png

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。

选择你的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

image.png

执行完毕后,会在以下路径生成此 .ec结尾的文件,删除即可
image.png

然后把刚才保存的coverage.ec复制到此路径下
此路径要和 jacoco.gradle中,路径一致。

executionData.setFrom(files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec"))
5.jacocoTestReport
image.png

双击执行这个任务,会生成我们最终所需要代码覆盖率报告,执行完后,我们可以在这个目录下找到它

app/build/reports/jacoco/jacocoTestReport/html/index.html
image.png

总结:

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353

推荐阅读更多精彩内容