AndroidStudio 最新 jacoco实现代码覆盖率测试

一、前言

JaCoCo 是一个免费的 Java 代码覆盖率库,由 EclEmma 团队根据多年使用和集成现有库的经验教训创建。官网地址

JaCoCo是面向Java的开源代码覆盖率工具,JaCoCo以Java代理模式运行,它负责在运行测试时检测字节码。 JaCoCo会深入研究每个指令,并显示每个测试过程中要执行的行。 为了收集覆盖率数据,JaCoCo使用ASM即时进行代码检测,并在此过程中从JVM Tool Interface接收事件,最终生成代码覆盖率报告。

Jacoco运行有离线(offline)、在线(on the fly)模式之说,所谓在线模式就是在应用启动时加入jacoco agent进行插桩,在开发、测试人员使用应用期间实时地进行代码覆盖率分析。相信很多的java项目开发人员并不会去写单元测试代码的,因此覆盖率统计就要把手工测试或接口测试覆盖的情况作为重要依据,显然在线模式更符合实际需求。

二、AndroidStudio 集成Jacoco

  1. 环境
  • 根目录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

  1. 在项目根目录新建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('$$', '$'))
                }
            }
        }
    }
}
  1. 生成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" />

三、生成测试报告

  1. installDebug
    我们通过命令行安装app,选择你的app -> Tasks -> install -> installDebug,安装app到你的设备上。

  2. 命令行启动app
    先列出手机中已安装的instrumentation:

adb shell pm list instrumentation

启动app

adb shell am instrument com.qianyin.user/.test.JacocoInstrumentation

  1. 正常点击测试app的功能
    这个时候你可以操作你的app,对你想进行代码覆盖率检测的地方,进入到对应的页面,点击对应的按钮,触发对应的逻辑,你现在所操作的都会被记录下来,在生成的coverage.ec文件中都能体现出来。当你点击完了,根据我们之前设置的逻辑,当我们MainActivity执行onDestroy方法时才会通知JacocoInstrumentation生成coverage.ec文件,我们可以按返回键退出MainActivity返回桌面,生成coverage.ec文件可能需要一点时间哦(取决于你点击测试页面多少,测试越多,生成文件越大,所需时间可能多一点)

然后在Android Studio的Device File Explore中,找到data/data/包名/files/coverage.ec文件,右键保存到桌面备用

  1. createDebugCoverageReport


    image.png

    双击它,会执行创建覆盖率报告的命令,等待它执行完,这个会生成一个coverage.ec文件,但是这个不是我们最终需要分析的,我们需要分析的是我们刚才手动点击保存到桌面的那个。将保存到桌面的ec文件覆盖这个ec文件。


    image.png

备注
jacoco.gradle 文件中getExecutionData().setFrom(files(executionDirs)) 中 文件目录要和coverage.ec文件的目录保持一致否则会有问题。

  1. jacocoTestReport


    image.png

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

app/build/reports/jacoco/jacocoTestReport/html/index.html
  1. 分析报告


    image.png

四、问题

1、执行 jacocoTestReport task 出现 Task :app:jacocoTestReport SKIPPED
可能原因:在 gradle 配置的路径,比如上面的 app/build/outputs/code-coverage/connected/ 路径创建失败或有错误;检查路径是否有拼写错误,是否与配置的一致。该路径在配置时可自定义,在定义后存放 .ec 文件的路径需与此保持一致!
2、gradle 的更新,一些gradle方法已经过时,建议更新成最新方法使用,如getClassDirectories();getSourceDirectories();getExecutionData()

五、参考

Android jacoco
Android+jacoco实现代码

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

推荐阅读更多精彩内容