zoukankan      html  css  js  c++  java
  • 基于JaCoCo的Android测试覆盖率统计(二)

    本文章是我上一篇文章的升级版本,详见地址:https://www.cnblogs.com/xiaoluosun/p/7234606.html

    为什么要做这个?

    1. 辛辛苦苦写了几百条测试用例,想知道这些用例的覆盖率能达到多少?
    2. 勤勤恳恳验证好几天,也没啥bug了,可不可以上线?有没有漏测的功能点?
    3. 多人协同下测试,想了解团队每个人的测试进度、已覆盖功能点、验证过的设备机型和手机系统等等。

    数据采集和上报

    既然要做覆盖率分析,数据的采集非常重要,除了JaCoCo生成的.ec文件之外,还需要拿到额外一些信息,如被测设备系统版本、系统机型、App的版本、用户唯一标识(UID)、被测环境等等。

    什么时候触发数据的上报呢?这个机制很重要,如果设计的不合理,覆盖率数据可能会有问题。

    最早使用的上报策略是:加在监听设备按键的位置,如果点击设备back键或者home键把App置于后台,则上报覆盖率数据。
    这种设计肯定是会有问题的,因为有些时候手机设备用完就扔那了,根本没有置于后台,第二天可能才会继续使用,这时候上报的数据就变成了第二天的。还可能用完之后杀死了App,根据就不会上报,覆盖率数据造成丢失;

    所以优化后的上报策略是:定时上报,每一分钟上报一次,只要App进程活着就会上报。
    那怎么解决用完就杀死App的问题呢?解决办法是App重新启动后查找ec文件目录,如果有上次的记录就上报,这样就不会丢覆盖率数据了。

    生成覆盖率文件

     1 /**
     2  * Created by sun on 17/7/4.
     3  */
     4 
     5 public class JacocoUtils {
     6     static String TAG = "JacocoUtils";
     7 
     8     //ec文件的路径
     9     private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec";
    10 
    11     /**
    12      * 生成ec文件
    13      *
    14      * @param isNew 是否重新创建ec文件
    15      */
    16     public static void generateEcFile(boolean isNew) {
    17 //        String DEFAULT_COVERAGE_FILE_PATH = NLog.getContext().getFilesDir().getPath().toString() + "/coverage.ec";
    18         Log.d(TAG, "生成覆盖率文件: " + DEFAULT_COVERAGE_FILE_PATH);
    19         OutputStream out = null;
    20         File mCoverageFilePath = new File(DEFAULT_COVERAGE_FILE_PATH);
    21         try {
    22             if (isNew && mCoverageFilePath.exists()) {
    23                 Log.d(TAG, "JacocoUtils_generateEcFile: 清除旧的ec文件");
    24                 mCoverageFilePath.delete();
    25             }
    26             if (!mCoverageFilePath.exists()) {
    27                 mCoverageFilePath.createNewFile();
    28             }
    29             out = new FileOutputStream(mCoverageFilePath.getPath(), true);
    30 
    31             Object agent = Class.forName("org.jacoco.agent.rt.RT")
    32                     .getMethod("getAgent")
    33                     .invoke(null);
    34 
    35             out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class)
    36                     .invoke(agent, false));
    37 
    38             // ec文件自动上报到服务器
    39             UploadService uploadService = new UploadService(mCoverageFilePath);
    40             uploadService.start();
    41         } catch (Exception e) {
    42             Log.e(TAG, "generateEcFile: " + e.getMessage());
    43         } finally {
    44             if (out == null)
    45                 return;
    46             try {
    47                 out.close();
    48             } catch (IOException e) {
    49                 e.printStackTrace();
    50             }
    51         }
    52     }
    53 }
    View Code

    采集到想要的数据上传服务器

      1 /**
      2  * Created by sun on 17/7/4.
      3  */
      4 
      5 public class UploadService extends Thread{
      6 
      7     private File file;
      8     public UploadService(File file) {
      9         this.file = file;
     10     }
     11 
     12     public void run() {
     13         Log.i("UploadService", "initCoverageInfo");
     14         // 当前时间
     15         SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
     16         Calendar cal = Calendar.getInstance();
     17         String create_time = format.format(cal.getTime()).substring(0,19);
     18 
     19         // 系统版本
     20         String os_version = DeviceUtils.getSystemVersion();
     21 
     22         // 系统机型
     23         String device_name = DeviceUtils.getDeviceType();
     24 
     25         // 应用版本
     26         String app_version = DeviceUtils.getAppVersionName(LuojiLabApplication.getInstance());
     27 
     28         // 应用版本
     29         String uid = String.valueOf(AccountUtils.getInstance().getUserId());
     30 
     31         // 环境
     32         String context = String.valueOf(BuildConfig.SERVER_ENVIRONMENT);
     33 
     34         Map<String, String> params = new HashMap<String, String>();
     35         params.put("os_version", os_version);
     36         params.put("device_name", device_name);
     37         params.put("app_version", app_version);
     38         params.put("uid", uid);
     39         params.put("context", context);
     40         params.put("create_time", create_time);
     41 
     42         try {
     43             post("https://xxx.com/coverage/uploadec", params, file);
     44         } catch (IOException e) {
     45             e.printStackTrace();
     46         }
     47 
     48     }
     49 
     50     /**
     51      * 通过拼接的方式构造请求内容,实现参数传输以及文件传输
     52      *
     53      * @param url    Service net address
     54      * @param params text content
     55      * @param files  pictures
     56      * @return String result of Service response
     57      * @throws IOException
     58      */
     59     public static String post(String url, Map<String, String> params, File files)
     60             throws IOException {
     61         String BOUNDARY = java.util.UUID.randomUUID().toString();
     62         String PREFIX = "--", LINEND = "
    ";
     63         String MULTIPART_FROM_DATA = "multipart/form-data";
     64         String CHARSET = "UTF-8";
     65 
     66 
     67         Log.i("UploadService", url);
     68         URL uri = new URL(url);
     69         HttpURLConnection conn = (HttpURLConnection) uri.openConnection();
     70         conn.setReadTimeout(10 * 1000); // 缓存的最长时间
     71         conn.setDoInput(true);// 允许输入
     72         conn.setDoOutput(true);// 允许输出
     73         conn.setUseCaches(false); // 不允许使用缓存
     74         conn.setRequestMethod("POST");
     75         conn.setRequestProperty("connection", "keep-alive");
     76         conn.setRequestProperty("Charsert", "UTF-8");
     77         conn.setRequestProperty("Content-Type", MULTIPART_FROM_DATA + ";boundary=" + BOUNDARY);
     78 
     79         // 首先组拼文本类型的参数
     80         StringBuilder sb = new StringBuilder();
     81         for (Map.Entry<String, String> entry : params.entrySet()) {
     82             sb.append(PREFIX);
     83             sb.append(BOUNDARY);
     84             sb.append(LINEND);
     85             sb.append("Content-Disposition: form-data; name="" + entry.getKey() + """ + LINEND);
     86             sb.append("Content-Type: text/plain; charset=" + CHARSET + LINEND);
     87             sb.append("Content-Transfer-Encoding: 8bit" + LINEND);
     88             sb.append(LINEND);
     89             sb.append(entry.getValue());
     90             sb.append(LINEND);
     91         }
     92 
     93         DataOutputStream outStream = new DataOutputStream(conn.getOutputStream());
     94         outStream.write(sb.toString().getBytes());
     95         // 发送文件数据
     96         if (files != null) {
     97             StringBuilder sb1 = new StringBuilder();
     98             sb1.append(PREFIX);
     99             sb1.append(BOUNDARY);
    100             sb1.append(LINEND);
    101             sb1.append("Content-Disposition: form-data; name="uploadfile"; filename=""
    102                     + files.getName() + """ + LINEND);
    103             sb1.append("Content-Type: application/octet-stream; charset=" + CHARSET + LINEND);
    104             sb1.append(LINEND);
    105             outStream.write(sb1.toString().getBytes());
    106 
    107             InputStream is = new FileInputStream(files);
    108             byte[] buffer = new byte[1024];
    109             int len = 0;
    110             while ((len = is.read(buffer)) != -1) {
    111                 outStream.write(buffer, 0, len);
    112             }
    113 
    114             is.close();
    115             outStream.write(LINEND.getBytes());
    116         }
    117 
    118 
    119         // 请求结束标志
    120         byte[] end_data = (PREFIX + BOUNDARY + PREFIX + LINEND).getBytes();
    121         outStream.write(end_data);
    122         outStream.flush();
    123         // 得到响应码
    124         int res = conn.getResponseCode();
    125         Log.i("UploadService", String.valueOf(res));
    126         InputStream in = conn.getInputStream();
    127         StringBuilder sb2 = new StringBuilder();
    128         if (res == 200) {
    129             int ch;
    130             while ((ch = in.read()) != -1) {
    131                 sb2.append((char) ch);
    132             }
    133         }
    134         outStream.close();
    135         conn.disconnect();
    136         return sb2.toString();
    137     }
    138 }
    View Code

    上报数据的定时器

    1 /**
    2  * 定时器,每分钟调用一次生成覆盖率方法
    3  *
    4  */
    5 public boolean timer() {
    6     JacocoUtils.generateEcFile(true);
    7 }
    View Code

    启用JaCoCo

    安装plugin
    1 apply plugin: 'jacoco'
    2 
    3 jacoco {
    4     toolVersion = '0.7.9'
    5 }
    View Code 
    启用覆盖率开关

    此处是在debug时启用覆盖率的收集

    Android 9.0以上版本因为限制私有API的集成,所以如果打开了开关,9.0以上系统使用App时会有系统级toast提示“Detected problems with API compatibility”,但不影响功能。

    1 buildTypes {
    2     debug {
    3         testCoverageEnabled = true
    4     }
    5 }
    View Code

    分析源码和二进制,生成覆盖率报告

    执行命令生成

    1 ./gradlew jacocoTestReport
    View Code

    这块做的时候遇到三个问题。
    第一个问题是App已经拆成组件了,每个主要模块都是一个可独立编译的业务组件。如果按照之前的方法只能统计到主工程的覆盖率,业务组件的覆盖率统计不到。
    解决办法是是先拿到所有业务组件的名称和路径(我们在settings.gradle里有定义),然后循环添加成一个list,files方法支持list当做二进制目录传入。

    第二个问题是部分业务组件是用Kotlin开发的,所以要同时兼容Java和Kotlin两种编程语言。
    解决办法跟问题一的一样,files同时支持Kotlin的二进制目录传入。

    第三个问题是覆盖率数据是碎片式的,每天会有上万个覆盖率文件生成,之前只做过单个文件的覆盖率计算,如何批量计算覆盖率文件?
    解决办法是使用fileTree方法的includes,用正则表达式*号,批量计算特定目录下符合规则的所有.ec文件。

    1 executionData = fileTree(dir: "$buildDir", includes: [
    2     "outputs/code-coverage/connected/*coverage.ec"
    3 ])
    View Code

    完整代码

     1 task jacocoTestReport(type: JacocoReport) {
     2     def lineList = new File(project.rootDir.toString() + '/settings.gradle').readLines()
     3     def coverageCompName = []
     4     for (i in lineList) {
     5         if (!i.isEmpty() && i.contains('include')) {
     6             coverageCompName.add(project.rootDir.toString() + '/' + i.split(':')[1].replace("'", '') + '/')
     7         }
     8     }
     9 
    10     def coverageSourceCompName = []
    11     for (i in lineList) {
    12         if (!i.isEmpty() && i.contains('include')) {
    13             coverageSourceCompName.add('../' + i.split(':')[1].replace("'", '') + '/')
    14         }
    15     }
    16 
    17     reports {
    18         xml.enabled = true
    19         html.enabled = true
    20     }
    21     def fileFilter = ['**/R*.class',
    22                       '**/*$InjectAdapter.class',
    23                       '**/*$ModuleAdapter.class',
    24                       '**/*$ViewInjector*.class',
    25                       '**/*Binding*.class',
    26                       '**/*BR*.class'
    27     ]
    28 
    29     def coverageSourceDirs = []
    30     for (i in coverageSourceCompName) {
    31         def sourceDir = i + 'src/main/java'
    32         coverageSourceDirs.add(sourceDir)
    33     }
    34 
    35     def coverageClassDirs = []
    36     for (i in coverageCompName) {
    37         def classDir = fileTree(dir: i + 'build/intermediates/classes/release', excludes: fileFilter)
    38         coverageClassDirs.add(classDir)
    39     }
    40 
    41     def coverageKotlinClassDirs = []
    42     for (i in coverageCompName) {
    43         def classKotlinDir = fileTree(dir: i + 'build/tmp/kotlin-classes/release', excludes: fileFilter)
    44         coverageKotlinClassDirs.add(classKotlinDir)
    45     }
    46 
    47     classDirectories = files(coverageClassDirs, coverageKotlinClassDirs)
    48     sourceDirectories = files(coverageSourceDirs)
    49 //    executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec")
    50     executionData = fileTree(dir: "$buildDir", includes: [
    51             "outputs/code-coverage/connected/*coverage.ec"
    52     ])
    53 
    54     doFirst {
    55         new File("$buildDir/intermediates/classes/").eachFileRecurse { file ->
    56             if (file.name.contains('$$')) {
    57                 file.renameTo(file.path.replace('$$', '$'))
    58             }
    59         }
    60     }
    61 }
    View Code

    数据分析和处理

    待补充。。。。

    应用环境的覆盖率分析

     

    设备系统的覆盖率分析

     

    用户UID的覆盖率分析

     

    应用版本的覆盖率分析

  • 相关阅读:
    LeetCode Count of Range Sum
    LeetCode 158. Read N Characters Given Read4 II
    LeetCode 157. Read N Characters Given Read4
    LeetCode 317. Shortest Distance from All Buildings
    LeetCode Smallest Rectangle Enclosing Black Pixels
    LeetCode 315. Count of Smaller Numbers After Self
    LeetCode 332. Reconstruct Itinerary
    LeetCode 310. Minimum Height Trees
    LeetCode 163. Missing Ranges
    LeetCode Verify Preorder Serialization of a Binary Tree
  • 原文地址:https://www.cnblogs.com/xiaoluosun/p/11304178.html
Copyright © 2011-2022 走看看