本文章是我上一篇文章的升級版本,詳見地址:https://www.cnblogs.com/xiaoluosun/p/7234606.html
為什麼要做這個?
- 辛辛苦苦寫了幾百條測試用例,想知道這些用例的覆蓋率能達到多少?
- 勤勤懇懇驗證好幾天,也沒啥bug了,可不可以上線?有沒有漏測的功能點?
- 多人協同下測試,想了解團隊每個人的測試進度、已覆蓋功能點、驗證過的裝置機型和手機系統等等。
資料採集和上報
既然要做覆蓋率分析,資料的採集非常重要,除了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 }
採集到想要的資料上傳伺服器
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 }
上報資料的定時器
1 /** 2 * 定時器,每分鐘呼叫一次生成覆蓋率方法 3 * 4 */ 5 public boolean timer() { 6 JacocoUtils.generateEcFile(true); 7 }
啟用JaCoCo
安裝plugin
1 apply plugin: 'jacoco' 2 3 jacoco { 4 toolVersion = '0.7.9' 5 }
啟用覆蓋率開關
此處是在debug時啟用覆蓋率的收集
Android 9.0以上版本因為限制私有API的整合,所以如果開啟了開關,9.0以上系統使用App時會有系統級toast提示“Detected problems with API compatibility”,但不影響功能。
1 buildTypes { 2 debug { 3 testCoverageEnabled = true 4 } 5 }
分析原始碼和二進位制,生成覆蓋率報告
執行命令生成
1 ./gradlew jacocoTestReport
這塊做的時候遇到三個問題。
第一個問題是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 ])
完整程式碼
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 }
資料分析和處理
待補充。。。。
應用環境的覆蓋率分析
裝置系統的覆蓋率分析
使用者UID的覆蓋率分析
應用版本的覆蓋率分析