Flutter 代碼覆蓋率研究

導讀

Android java 代碼覆蓋有 Jacoco 等工具,iOS 也有對應的原生代碼覆蓋率工具,然而,目前尚未有任何關於 Flutter 覆蓋率的工具或者插件等,屬於空白區域,因此需要從無到有的開發該工具,本文將詳細說明 Flutter 代碼覆蓋率該工具的原理及其實現。

背景

由於 flutter 具有下等優點:

A、混合開發中,最接近原生開發的框架;
B、性能強大,流暢;
C、簡單易學,Dart 語言更具優勢;
D、跨多種平臺,減少開發成本;支持插件,可以訪問原生系統的調用。

我們商家通 App 使用 Flutter 進行混合開發,阿姨端全量使用 Flutter,隨着業務與代碼量的增長,相比於 Native 測試,在 Android 端已經配置代碼覆蓋率下,Flutter 的測試工作出現瞭如下弊端:

A、Flutter 測試效率低,測試難度大;

B、單端測試工作量有一定增加;
C、測試質量有所下降;
D、測試缺少覆蓋率這一重要指標;

爲了解決上述問題,增加 Flutter 代碼覆蓋率已經顯的尤爲重要。

**原理
**

代碼覆蓋率的指標從細到粗有指令、分支、行、方法、類等各個指標,綜合工作量和收益率等,我們只做到行級別的代碼覆蓋率。要做到行級別覆蓋就需要記錄每行代碼是否執行,然而在每行代碼前後去記錄執行情況是不可取的,費時費力,包體積也將陡增,只需在每個代碼塊首末記錄是否執行即可判斷整個代碼塊的執行情況,下面是一個簡單 dart 語言寫的類:

 爲實現代碼覆蓋率,我們修改代碼爲:

 我們在 Main 函數中使用 Test(null).getTestInfo(),並得到輸出內容爲: 

    1.Test_Test:[true]
    2.Test_getTestInfo:[true, false, false, true]

從輸出信息中,我們就能分析出原代碼覆蓋情況:構造函數已經覆蓋,getTestInfo 方法部分覆蓋。

以上就是行覆蓋的基本原理,但是,在實際情況中,代碼結構遠比上面的類複雜,各種嵌套函數,各種語句,都將影響我們的準確度,而且我們也不允許直接在代碼中寫這樣邏輯,我們需要專業的類似於 ASM 工具,對編譯的文件進行修改即插樁,然而 ASM 是對 class 文件進行修改,而 Flutter 則需要對編譯的 dill 文件進行插樁。

插樁工具

工欲善其事必先利其器,代碼覆蓋基本就是插樁操作,java 中插樁是對 class 文件進行操作,Flutter 則是對中間 Dill 文件操作,目前能操作 Dill 文件的開源工具目前來說很少,目前來看 AspectD 是比較全面的且比較好用的一個。AspectD 是阿里開源的一個項目,該工具接入 Flutter 生成 Dill 文件流程,對 Dill 文件進行增刪改查,最終實現 AOP 功能,我們則是藉助其對 Dill 文件的操作的功能,對 Dill 文件進行插樁。

下圖是 AspectD AOP 詳細流程圖。

代碼覆蓋率實現

  

此工具由一個核心插樁模塊、兩個數據管理以及 diff 模塊、HTML 渲染、統計、日誌記錄組成,其中核心模塊爲插樁模塊,主要負責 AST 代碼插樁、各種語句處理以及輸出插樁數據。數據生成層主要是生成原始數據和對簡單處理;數據應用層則是對原始數據分析、統計、輸出;日誌模塊主要記錄各個模塊異常情況,方便後面迭代升級。

  代碼覆蓋率的流程圖:

紅色是核心插樁模塊,編譯、打包由系統平臺處理,運行輸出需要用戶觸發,下面對各個模塊詳細說明。

1、代碼插樁

AspectD 對原有 Flutter SDK 進行了修改,把原有編譯流程增加了一個 AOP 流程,首先讀取 Flutter 編譯好的 Dill 文件,然後遍歷所有 dart 文件,接着修改需要 AOP 的地方,最後保存替換原有 Dill 文件,達到 AOP 的目的。

Dill 文件,又稱爲 Dart Intermediate Language,是 Dart 語言編譯中的一個概念,無論是 Script Snapshot 還是 AOT 編譯,都需要 Dill 作爲中間產物。dart 提供了一種 Kernel to Kernel Transform 的方式,可以通過對 dill 文件的遞歸式 AST 遍歷,實現對 Dill 的內容的修改、添加等操作。

AST 又是啥?AST 稱爲抽象語法樹(Abstract Syntax Tree),或簡稱語法樹(Syntax tree),是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構。我們操作的 Dill 文件時候,實際是在操作由 Dill 文件生成的衆多抽象語法樹,每個 dart 文件就是一顆樹。下圖是 Test.dart 文件語法樹結構:

可以看出抽象語法樹包含源代碼所有的信息,瞭解抽象語法樹能更好的幫助我們進行插樁操作,有興趣的同學可以深入瞭解抽象語法樹,它是編譯過程中必不可少中間產物。

對抽象語法樹遍歷需要,繼承 "package:kernel/ast.dart" 下的 Visitor 類,覆蓋想要修改數據對應的方法,如 visitvisitBlock,如有代碼塊,將回調該方法。

下圖爲詳細插樁流程:

 其中:

1、變單行爲 block 操作:某些情況下方法或者構造函數沒有實現,在 AST 中是單行語句,是不能再插入語句的,因此需要改成 block。

2、block 插樁包含常規 blick 處理與各種語句的特殊處理,如 while、async 標記、for 循環,這些語句在 AST 中都是經過處理的,需要跳過處理的行,爲的是能精確記錄代碼對應的行號。

3、需要先聲明 coverage_insert 數組,當前函數插樁完成過後,得到確切的數組長度過後,再進行插入操作。

操作 AST 與編寫代碼是不相同的,在上述流程中,聲明類型爲 List 的變量,在 AST 中的表示變得複雜很多。

///聲明 List<bool> coverage_insert = CoverageImpl.getLibData(lib名字, 類名_方法名);
void createListDeclaration(Member procedure, Block node){
  VariableDeclaration declaration = VariableDeclaration(
      CoverageConstant.declarationListName,
      isFinal:true);
  declaration.type = InterfaceType(CoverageConstant.dartList.parent as Class,
      Nullability.legacy,
[InterfaceType(CoverageConstant.dartBool, Nullability.legacy, null)]);
Arguments arguments = Arguments.empty();
  String lib_key = library.importUri.toString();
arguments.positional.add(StringLiteral(lib_key));
  String class_fun_key = getClassName(procedure) + CoverageConstant.class_fun_spile + procedure.name.name;
  arguments.positional.add(StringLiteral(class_fun_key));
  declaration.initializer = StaticInvocation(CoverageConstant.procedureGetListBool, arguments);
  declaration.parent = node;
  procedureListDeclaration = declaration;
  recordInsertInfo.insertStart(this, library, lib_key, class_fun_key);
}

可以看出插入聲明語句是比較複雜的,首先需要一個名字,這個簡單,第二需要指定變量類型,這個類型我們得遍歷 dart:core 拿到,包括 list 和 bool 類型;最後就是這個變量的初始化,這裏的初始化是一個靜態調用,組裝好參數賦給變量的 initializer 就行了;細心的同學可能會看出,這裏並未把改聲明變量插入的 Block 中,是的,這裏並未插入,只是創建了這個變量,爲的是後面我們執行插入 coverage_insert[procedureInsertTime] = true,並統計插入的次數,最後一併插入聲明語句以及引入相關的 dart 文件,並且這樣做可以減少插入賦值語句和記錄插入位置信息的複雜度。

再來看看流程中的插入賦值代碼:

///插入coverage_insert[procedureInsertTime] = true;
void insertStatement(Block node, {int position = 0})
{
  if(procedureListDeclaration == null){
    return;
}
  Arguments arguments = Arguments.empty();
  Expression arg = IntLiteral(procedureInsertTime);
  arg.parent = arguments;
  arguments.positional.add(arg);
  arg = BoolLiteral(true);
  arg.parent = arguments;
  arguments.positional.add(arg);
  VariableGet get = VariableGet(procedureListDeclaration);
MethodInvocation invocation = MethodInvocation(get,
      CoverageConstant.dartListDengYu.name, arguments,
      CoverageConstant.dartListDengYu);
  ExpressionStatement statement = ExpressionStatement(invocation);
  statement.parent = node;
  if(position >= node.statements.length){
    node.addStatement(statement);
  }else {
    node.statements.insert(position, statement);
  }

上面只是插入賦值語句的代碼,插入前的一些判斷檢測並未在此方法中。插入與聲明的調用方法都差不多,只是組裝的數據,類型等不相同。

下面再看看,真實的 Dill 文件插入前後對比。原始 Dill 轉換成方便閱讀的代碼:

 插樁過後的代碼:

紅框中便是我們插入的代碼,可以看出我們已經正確插樁。查看轉換過後的文件可以檢查插樁是否正確,某些情況下插樁不正確會導致我們的 App 不能正常啓動運行,這種情況多半是由於插樁不對引起的。

2、記錄插樁位置

上面的類在插樁過後,每個方法需要生成一個數組,並在運行時在對應數組位置賦值 true,表示此位置已經運行過了,達到檢測覆蓋的目的, 同時記錄該插入位置的信息,以 getTestInfo 方法爲例,記錄的插入數據如下:

在插樁的同時我們記錄插樁的行號、代碼塊深度、方法深度、類型等,最後得到所有類和方法插入數據,輸出保存,以便後面分析處理使用。

3、運行 APP,輸出覆蓋數據

Dill 文件插樁並替換原文件過後,就可以打包生成 apk 或者依賴 aar, 運行 app,並且執行對應的一些功能過後,退出 app,得到如下數據(部分):

"package:example/test.dart": {
  "Test&**&": [
    true,
    true
],
  "Test&**&getTestInfo": [
    true,
    true,
    true,
    false
]

數據說明:運行數據與插樁位置數據一一對應,true 代表插樁位置已經執行,false 則未執行。

4、DIFF 獲取

獲取這一版本的變更數據,以便在覆蓋報告中輸出對應指標。 通過 git diff tag_branch  file_name 獲取變更數據:

   分析輸出,生成 json 數據(部分):

[
{
    "add": false,
    "line": 8,
    "src": "      return "無數據";"
  },
  {
    "add": true,
    "line": 8
  }
]

數據說明:add 代表是添加刪除, line 行號,src 刪除的代碼,添加的不需要 src。

5、分析處理運行輸出數據與插樁位置信息生成覆蓋詳細數據

在得到運行時輸出覆蓋數據與對應的插樁位置信息過後,經過一系列分析處理,我們就能得到行覆蓋詳細數據;

 以下爲分析處理過程:

輸出的具體數據如下(部分):

"package:example/test.dart": {
  "Test&**&": {
    "start": 4,
    "end": 4,
"isCoverage": true
  },
  "Test&**&getTestInfo": {
    "start": 6,
    "end": 11,
    "isCoverage": true,
"disCoverage": [
      {
        "end": 11,
"start": 9
      }
    ]
  }
}

數據說明:如果該方法所有行都覆蓋了則沒有 disCoverage 數組,如果該方法未執行則 isCoverage 未 false;disCoverage 爲該方法裏面未被執行的數據,包含了起始行號。

6、統計計算

基於以上輸出數據:插樁位置、覆蓋詳細數據、Diff 變更數據,做統計彙總,彙總數據指標如下:A、Lines 與 Missed Lines;B、Methods 與 Missed Methods;C、Clesses 與 Missed Clesses;D、Line Diff 與 Missed Line Diff。

7、報告輸出

到這一步輸出 html 報告的所有數據都已準備好,彙總數據 + html 模板,生成彙總報告:  

覆蓋詳細數據 + Diff 變更數據 + 源代碼,生成代碼覆蓋詳情:

其中:A、深紅色代表源代碼修改但是測試的時候未覆蓋;B、淺紅色代表未修改未覆蓋;

C、 綠色代表已覆蓋;D、 沒有底色代表不需要覆蓋。

成果展示

目前該工具已成功在商家通 App 中使用,商家通是 Flutter 和原生開發混合開發,其中商機、我的、新商機、我的收藏、全部服務等頁面由 Flutter 開發,在沒有該工具之前,測試人員是沒有確切的直觀的數據來查看測試的覆蓋情況,只能通過測試用例來大概覆蓋代碼。加入該工具過後,我們可以報告中直觀的看到覆蓋情況,其中整體覆蓋情況,如下(其中 Diff 對比是上一個版本代碼):

從圖中可看到整體覆蓋率是 30%,而代碼變更部分的測試覆蓋率已經達到 70%,顯然我們是將中測試重點放在修改代碼功能上面,這無疑降低了測試的工作難度與工作量。

上圖從 widget 的覆蓋率較低,再看看哪些地方未覆蓋,可以將測試重點放該功能模塊上面:

上圖中,widget 下面的只有 custom_tab_bar.dart 有修改,進入查看(部分)

詳細查看變更未覆蓋代碼,方便下次針對性測試。

自接入該工具以來,目前已正常迭代 2 個版本,下面是這兩個版本統計到的數據:

A、提升了提測質量,提測平均 bug 數由 4.3 減少至 3.0,需求 bug 數由 1.5 降至 1.0 左右;

B、Flutter 模塊平均測試周期減少 0.5day,平均提效 8% 以上;

C、Flutter 錯誤率由 3.5% 降至 3.2% 以下(由於 Flutter 頁面不會出現崩潰,只能統計所有錯誤率)。

由於目前只用於商家通項目,而且迭代次數較少,數據不一定準確。

總結

我們實現了 Flutter 代碼覆蓋率,填補了 Flutter 這一空白,從上面的數據來看,已經到達了開發這一工具的目的,同時,我們也收到了不少建議,總結如下:

 A、需要配置本地環境,對測試人員來說相對困難;
 B、多次測試的數據不能合併,影響使用效果;
 C、未適配 Flutter 其它版本;
 D、覆蓋粒度沒有 Jacoco 細;

 E、最好能實時顯示覆蓋情況。

後續我們會針對以上問題不斷迭代,提升此工具的實用性,方便大家使用。 

作者簡介:

**張科:**本地服務事業羣 - 終端技術部 - 無線技術部 Android 開發工程師

參考文獻:

1.https://github.com/alibaba-flutter/aspectd 

2.https://github.com/jacoco/jacoco

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PUmKNt_TiZiTARBWw0Fw8g