基於JaCoCo的Android測試覆蓋率統計(二)

xiaoluosun發表於2019-08-05

本文章是我上一篇文章的升級版本,詳見地址: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 = "\r\n";
 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的覆蓋率分析

 

 

 

應用版本的覆蓋率分析

 

 

相關文章