Android Coverage

概念

简介

代码覆盖率是有系统化软件测试的一种方式。第一份出版的相关参考资料是 Miller 及 Malonely1963 年在 ACM 通讯上发表的论文
代码覆盖是飞行设备进行安全认证中的考量项目之一。其中航太产业标准要求这个值为 100%,因为其超高可靠度的产品需求,代码覆盖率的最终数值 100% 是诸多工业或者汽车产品的理想目标。


覆盖率准则

根据不同测试软件的测试程度,会用一种或者多种不同的覆盖率准则

基本的覆盖率准则

### int foo (int x, int y){    int z = 0;    if ((x>0) && (y>0)) {        z = x;    }    

准则 特点
函式覆盖率准则 函式覆盖率准则
函式覆盖率准则 具体函数中每一行代码(包括 z=x),执行一次,符合指令覆盖 100%
判断覆盖率准则 foo(1,1) 及 foo(0,1),满足判断覆盖率 100%
条件覆盖率准则 foo(1,1) foo(1,0) foo(0,0),所有条件都会出现成立及不成立的情形,满足条件覆盖
条件/判断准则 true or false 至少都出现过一次
true or false 至少都出现过一次 cell 6
多重条件覆盖准则 要求测试逻辑运算式中的所有组合 8 种
路径覆盖率准则 执行所有可能路径
循环覆盖率准则 循环执行过 0 次 一次 及一次以上
参数值覆盖率准则 一个方法的所有参数 都覆盖

一般而言代码覆盖率工具及函式库会影响代码性能,也会消耗内存和其他资源,无法在系统正常使用时进行测试。因此,一般只在开发阶段进行,不会将含覆盖率的代码交给客户。


工具选择

``return z;}

综上:选择 jacoco


覆盖率工具工作流程

  1. 对 Java 字节码进行插桩,On-The-Fly 和 Offine 两种方式。
  2. 执行测试用例,收集程序执行轨迹信息,将其 dump 到内存。
  3. 数据处理器结合程序执行轨迹信息和代码结构信息分析生成代码覆盖率报告。
  4. 将代码覆盖率报告图形化展示出来,如 html、xml 等文件格式。

插桩原理

主流代码覆盖率工具都采用字节码插桩模式,通过钩子的方式来记录代码执行轨迹信息。其中字节码插桩又分为两种模式 On-The-Fly 和 Offine。On-The-Fly 模式优点在于无需修改源代码,可以在系统不停机的情况下,实时收集代码覆盖率信息。Offine 模式优点在于系统启动不需要额外开启代理,但是只能在系统停机的情况下才能获取代码覆盖率。

我们做的事情其实就是 “插桩”,真正的代码执行轨迹追踪和覆盖率的统计都是 jacoco 帮我们做到的,所以,我们只需要知道 该如何插桩即可。




实现步骤

1.编写生成覆盖率文件 coverage.ec 的类和方法

2.build.gradle(app)中新增 jacoco 插件

3.打开覆盖率统计开关(build.gradle 中实现)

4.覆盖率生成条件监听

5.安装 debug 版本

6.服务器生成 jacoco 报告

具体实现案例

实现工具

AndroidStudio+ 夜神模拟器 +jacoco

按照步骤粘贴代码

1.编写生成覆盖率文件 coverage.ec 的类和方法

package test;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.os.Bundle;
import android.os.Looper;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;

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.isFile() && file.exists()){
            if (file.delete()){
                System.out.println("file del successs");
            }else {
                System.out.println("file del fail !");
            }
        }
        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() {
        System.out.println("onStart def");
        if (LOGD)
            Log.d(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.d(TAG, e.toString(), e);
            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, e);
        mResults.putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\nError: "
                + msg);
    }

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

    @Override
    public void dumpIntermediateCoverage(String filePath){
        // TODO Auto-generated method stub
        if(LOGD){
            Log.d(TAG,"Intermidate Dump Called with file name :"+ filePath);
        }
        if(mCoverage){
            if(!setCoverageFilePath(filePath)){
                if(LOGD){
                    Log.d(TAG,"Unable to set the given file path:"+filePath+" as dump target.");
                }
            }
            generateCoverageReport();
            setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH);
        }
    }
}

2.修改 build.gradle 的配置文件(app)

apply plugin: 'jacoco'
   jacoco{

   }

   def coverageSourceDirs = [
           '../mobile/src/main/java'
   ]
   task jacocoTestReport(type: JacocoReport,dependsOn:"connectedAndroidTest") {
       group = "Reporting"
       description = "Generate Jacoco coverage reports after running tests."
       reports {
           xml.enabled = true
           html.enabled = true
       }
       classDirectories = fileTree(
               dir: './build/intermediates/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/jacoco_instrumented_classes/").eachFileRecurse { file ->
               if (file.name.contains('$$')) {
                   file.renameTo(file.path.replace('$$', '$'))
               }
           }
       }
   }

修改 buildType

buildTypes {
    debug {
        /*打开覆盖率统计开关*/
        testCoverageEnabled=true
        debuggable=true
    }

修改 application Manifest.xml

<instrumentation
       android:handleProfiling="true"
       android:label="CoverageInstrumentation"
       android:name="test.JacocoInstrumentation"
       android:targetPackage="My application"/>
<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"/>

### 安装 debug 版本
在 app 中操作一番
### 生成报告
在 androidStudio 中的命令行中 执行 gradle jacocoTestReport ,就会在 build 目录下生成 report 啦。

至此,一个小的案例初步跑通,刚刚学习,有问题欢迎各位大神指正,后续会把他运用到公司的 app 进行尝试,还会继续更新的。


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