• 基于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的覆盖率分析

     

    应用版本的覆盖率分析

  • 相关阅读:
    Jmeter使用beanshell对数据进行加密传输
    接口测试:提交报文消息数据的四种常见格式(Content-Type)
    【工作Vlog】Jmeter响应结果乱码解决方案
    【Vlog】Jmeter之使用beanshell将json提取器中的多个值拼接为一个列表
    Jmeter之Json提取器详解(史上最全)
    一个Jmeter模拟上传文件接口的实例
    一文带你了解ANR(测试人员)
    一次访问网页请求的全过程详解
    在浏览器中输入一个网址后,浏览器都做了什么?
    MAC抓包工具Charles安装及破解
  • 原文地址:https://www.cnblogs.com/xiaoluosun/p/11304178.html
Copyright © 2020-2023  润新知