iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現

美團技術團隊發表於2018-12-28

背景

對蘋果開發者而言,由於平臺稽核週期較長,客戶端程式碼導致的線上問題影響時間往往比較久。如果在開發、測試階段能夠提前暴露問題,就有助於避免線上事故的發生。程式碼覆蓋率檢測正是幫助開發、測試同學提前發現問題,保證程式碼質量的好幫手。

對於開發者而言,程式碼覆蓋率可以反饋兩方面資訊:

  1. 自測的充分程度。
  2. 程式碼設計的冗餘程度。

儘管程式碼覆蓋率對程式碼質量有著上述好處,但在 iOS 開發中卻使用的不多。我們調研了市場上常用的 iOS 覆蓋率檢測工具,這些工具主要存在以下四個問題:

  1. 第三方工具有時生成的檢測報告檔案會出錯甚至會失敗,開發者對覆蓋率生成原理不瞭解,遇到這類問題容易棄用工具。
  2. 第三方工具每次展示全量的覆蓋率報告,會分散開發者的很多精力在未修改部分。而在絕大多數情況下,開發者的關注重點在本次新增和修改的部分。
  3. Xcode 自帶的覆蓋率檢測只適用於單元測試場景,由於需求變更頻繁,業務團隊開發單元測試的成本很高
  4. 已有工具很難和現有開發流程結合起來,需要額外進行測試,執行覆蓋率指令碼才能獲取報告檔案。

為了解決上述問題,我們深入調研了覆蓋率報告的生成邏輯,並結合團隊的開發流程,開發了一套嵌入在程式碼提交流程中、基於單次程式碼提交(git commit)生成報告、對開發者透明的增量程式碼測試覆蓋率工具。開發者只需要正常開發,通過模擬器測試開發程式碼,commit 本次程式碼(commit 和測試順序可交換),推送(git push)到遠端,就可以在本地看到這次提交程式碼的詳細覆蓋率報告了。

本文分為兩部分,先從介紹通用覆蓋率檢測的原理出發,讓讀者對覆蓋率的收集、解析有直觀的認識。之後介紹我們增量程式碼測試覆蓋率工具的實現。

覆蓋率檢測原理

生成覆蓋率報告,首先需要在 Xcode 中配置編譯選項,編譯後會為每個可執行檔案生成對應的 .gcno 檔案;之後在程式碼中呼叫覆蓋率分發函式,會生成對應的 .gcda 檔案。

其中,.gcno 包含了程式碼計數器和原始碼的對映關係, .gcda 記錄了每段程式碼具體的執行次數。覆蓋率解析工具需要結合這兩個檔案給出最後的檢測報表。接下來先看看 .gcno 的生成邏輯。

.gcno

利用 Clang 分別生成原始檔的 AST 和 IR 檔案,對比發現,AST 中不存在計數指令,而 IR 中存在用來記錄執行次數的程式碼。搜尋 LLVM 原始碼可以找到覆蓋率對映關係生成原始碼。覆蓋率對映關係生成原始碼是 LLVM 的一個 Pass,(下文簡稱 GCOVPass)用來向 IR 中插入計數程式碼並生成 .gcno 檔案(關聯計數指令和原始檔)。

下面分別介紹IR插樁邏輯和 .gcno 檔案結構。

IR 插樁邏輯

程式碼行是否執行到,需要在執行中統計,這就需要對程式碼本身做一些修改,LLVM 通過修改 IR 插入了計數程式碼,因此我們不需要改動任何原始檔,僅需在編譯階段增加編譯器選項,就能實現覆蓋率檢測了。

從編譯器角度看,基本塊(Basic Block,下文簡稱 BB)是程式碼執行的基本單元,LLVM 基於 BB 進行覆蓋率計數指令的插入,BB 的特點是:

  1. 只有一個入口。
  2. 只有一個出口。
  3. 只要基本塊中第一條指令被執行,那麼基本塊內所有指令都會順序執行一次

覆蓋率計數指令的插入會進行兩次迴圈,外層迴圈遍歷編譯單元中的函式,內層迴圈遍歷函式的基本塊。函式遍歷僅用來向 .gcno 中寫入函式位置資訊,這裡不再贅述。

一個函式中基本塊的插樁方法如下:

  1. 統計所有 BB 的後繼數 n,建立和後繼數大小相同的陣列 ctr[n]。
  2. 以後繼數編號為序號將執行次數依次記錄在 ctr[i] 位置,對於多後繼情況根據條件判斷插入。

舉個例子,下面是一段猜數字的遊戲程式碼,當玩家猜中了我們預設的數字10的時候會輸出Bingo,否則輸出You guessed wrong!。這段程式碼的控制流程圖如圖1所示。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSLog(@"Welcome to the game");
    if (guessNumber == 10) {
        NSLog(@"Bingo!");
    } else {
        NSLog(@"You guess is wrong!");
    }
}

複製程式碼

例1 猜數字遊戲

這段程式碼如果開啟了覆蓋率檢測,會生成一個長度為 6 的 64 位陣列,對照插樁位置,方括號中標記了樁點序號,圖 1 中程式碼前數字為所在行數。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖 1 樁點位置

.gcno計數符號和檔案位置關聯

.gcno 是用來儲存計數插樁位置和原始檔之間關係的檔案。GCOVPass 在通過兩層迴圈插入計數指令的同時,會將檔案及 BB 的資訊寫入 .gcno 檔案。寫入步驟如下:

  1. 建立 .gcno 檔案,寫入 Magic number(oncg+version)。
  2. 隨著函式遍歷寫入檔案地址、函式名和函式在原始檔中的起止行數(標記檔名,函式在原始檔對應行數)。
  3. 隨著 BB 遍歷,寫入 BB 編號、BB 起止範圍、BB 的後繼節點編號(標記基本塊跳轉關係)。
  4. 寫入函式中BB對應行號資訊(標註基本塊與原始碼行數關係)。

從上面的寫入步驟可以看出,.gcno 檔案結構由四部分組成:

  • 檔案結構
  • 函式結構
  • BB 結構
  • BB 行結構

通過這四部分結構可以完全還原插樁程式碼和原始碼的關聯,我們以 BB 結構 / BB 行結構為例,給出結構圖 2 (a) BB 結構,(b) BB 行資訊結構,在本章末尾覆蓋率解析部分,我們利用這個結構圖還原始碼執行次數(每行等高格代表 64bit):

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖2 BB 結構和 BB 行資訊結構

.gcda

入口函式

關於 .gcda 的生成邏輯,可參考覆蓋率資料分發原始碼。這個檔案中包含了 __gcov_flush() 函式,這個函式正是分發邏輯的入口。接下來看看 __gcov_flush() 如何生成 .gcda 檔案。

通過閱讀程式碼和除錯,我們發現在二進位制程式碼載入時,呼叫了llvm_gcov_init(writeout_fn wfn, flush_fn ffn)函式,傳入了_llvm_gcov_writeout(寫 gcov 檔案),_llvm_gcov_flush(gcov 節點分發)兩個函式,並且根據呼叫順序,分別建立了以檔案為節點的連結串列結構。(flush_fn_node * ,writeout_fn_node *

__gcov_flush() 程式碼如下所示,當我們手動呼叫__gcov_flush()進行覆蓋率分發時,會遍歷flush_fn_node *這個連結串列(即遍歷所有檔案節點),並呼叫分發函式_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函式型別)。

void __gcov_flush() {
    struct flush_fn_node *curr = flush_fn_head;
    
    while (curr) {
        curr->fn();
        curr = curr->next;
    }
}
複製程式碼

具體的分發邏輯

觀察__llvm_gcov_flush的 IR 程式碼,可以看到:

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖3 __llvm_gcov_flush 程式碼示例

  1. __llvm_gcov_flush先呼叫了__llvm_gcov_writeout,來向 .gcda 寫入覆蓋率資訊。
  2. 最後將計數陣列清零__llvm_gcov_ctr.xx

__llvm_gcov_writeout邏輯為:

  1. 生成對應原始檔的 .gcda 檔案,寫入 Magic number。

  2. 迴圈執行 llvm_gcda_emit_function: 向 .gcda 檔案寫入函式資訊。

    llvm_gcda_emit_arcs: 向 .gcda 檔案寫入BB執行資訊,如果已經存在 .gcda 檔案,會和之前的執行次數進行合併

  3. 呼叫llvm_gcda_summary_info,寫入校驗資訊。

  4. 呼叫llvm_gcda_end_file,寫結束符。

感興趣的同學可以自己生成 IR 檔案檢視更多細節,這裡不再贅述。

.gcda 的檔案/函式結構和 .gcno 基本一致,這裡不再贅述,統計插樁資訊結構如圖 4 所示。定製化的輸出也可以通過修改上述函式完成。我們的增量程式碼測試覆蓋率工具解決程式碼 BB 結構變動後合併到已有 .gcda 檔案不相容的問題,也是修改上述函式實現的。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖4 計數樁輸出結構

覆蓋率解析

在瞭解瞭如上所述 .gcno ,.gcda 生成邏輯與檔案結構之後,我們以例 1 中的程式碼為例,來闡述解析演算法的實現。

例 1 中基本塊 B0,B1 對應的 .gcno 檔案結構如下圖所示,從圖中可以看出,BB 的主結構完全記錄了基本塊之間的跳轉關係。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖5 B0,B1 對應跳轉資訊

B0,B1 的行資訊在 .gcno 中表示如下圖所示,B0 塊因為是入口塊,只有一行,對應行號可以從 B1 結構中獲取,而 B1 有兩行程式碼,會依次把行號寫入 .gcno 檔案。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖6 B0,B1 對應行資訊

在輸入數字 100 的情況下,生成的 .gcda 檔案如下:

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖7 輸入 100 得到的 .gcda 檔案

通過控制流程圖中節點出邊的執行次數可以計算出 BB 的執行次數,核心演算法為計算這個 BB 的所有出邊的執行次數,不存在出邊的情況下計算所有入邊的執行次數(具體實現可以參考 gcov 工具原始碼),對於 B0 來說,即看 index=0 的執行次數。而 B1 的執行次數即 index=1,2 的執行次數的和,對照上圖中 .gcda 檔案可以推斷出,B0 的執行次數為 ctr[0]=1,B1 的執行次數是 ctr[1]+ctr[2]=1, B2 的執行次數是 ctr[3]=0,B4 的執行次數為 ctr[4]=1,B5 的執行次數為 ctr[5]=1。

經過上述解析,最終生成的 HTML 如下圖所示(利用 lcov):

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖8 覆蓋率檢測報告

以上是 Clang 生成覆蓋率資訊和解析的過程,下面介紹美團到店餐飲 iOS 團隊基於以上原理做的增量程式碼測試覆蓋率工具。

增量程式碼覆蓋率檢測原理

方案權衡

由於 gcov 工具(和前面的 .gcov 檔案區分,gcov 是覆蓋率報告生成工具)生成的覆蓋率檢測報告可讀性不佳,如圖 9 所示。我們做的增量程式碼測試覆蓋率工具是基於 lcov 的擴充套件,報告展示如上節末尾圖 8 所示。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖9 gcov 輸出,行前數字代表執行次數,#### 代表沒執行

比 gcov 直接生成報告多了一步,lcov 的處理流程是將 .gcno 和 .gcda 檔案解析成一個以 .info 結尾的中間檔案(這個檔案已經包含全部覆蓋率資訊了),之後通過覆蓋率報告生成工具生成可讀性比較好的 HTML 報告。

結合前兩章內容和覆蓋率報告生成步驟,覆蓋率生成流程如下圖所示。考慮到增量程式碼覆蓋率檢測中程式碼增量部分需要通過 Git 獲取,比較自然的想法是用 git diff 的資訊去過濾覆蓋率的內容。根據過濾點的不同,存在以下兩套方案:

  1. 通過 GCOVPass 過濾,只對修改的程式碼進行插樁,每次修改後需重新插樁。
  2. 通過 .info 過濾,一次性為所有程式碼插樁,獲取全部覆蓋率資訊,過濾覆蓋率資訊。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖10 覆蓋率生成流程

分析這兩個方案,第一個方案需要自定義 LLVM 的 Pass,進而會引入以下兩個問題:

  • 只能使用開源 Clang 進行編譯,不利於接入正常的開發流程。
  • 每次重新插樁會丟失之前的覆蓋率資訊,多次執行只能得到最後一次的結果。

而第二個方案相對更加輕量,只需要過濾中間格式檔案,不僅可以解決我們在文章開頭提到的問題,也可以避免上述問題:

  • 可以很方便地加入到平常程式碼的開發流程中,甚至對開發者透明。
  • 未修改檔案的覆蓋率可以疊加(有修改的那些控制流程圖結構可能變化,無法疊加)。

因此我們實際開發選定的過濾點是在 .info 。在選定了方案 2 之後,我們對中間檔案 .info 進行了一系列調研,確定了檔案基本格式(函式/程式碼行覆蓋率對應的檔案的表示),這裡不再贅述,具體可以參考 .info 生成文件

增量程式碼測試覆蓋率工具的實現

前一節是實現增量程式碼覆蓋率檢測的基本方案選擇,為了更好地接入現有開發流程,我們做了以下幾方面的優化。

降低使用成本

在接入方面,接入增量程式碼測試覆蓋率工具只需一次接入配置,同步到程式碼倉庫後,團隊中成員無需配置即可使用,降低了接入成本。

在使用方面,考慮到插樁在編譯時進行,對全部程式碼進行插樁會很大程度降低編譯速度,我們通過解析 Podfile(iOS 開發中較為常用的包管理工具 CocoaPods 的依賴描述檔案),只對 Podfile 中使用原生程式碼的倉庫進行插樁(可配置指定倉庫),降低了團隊的開發成本。

對開發者透明

接入增量程式碼測試覆蓋率工具後,開發者無需特殊操作,也不需要對工程做任何其他修改,正常的 git commit 程式碼,git push 到遠端就會自動生成並上傳這次 commit 的覆蓋率資訊了。

為了做到這一點,我們在接入 Pod 的過程中,自動部署了 Git 的 pre-push 指令碼。熟悉 Git 的同學知道,Git 的 hooks 是開發者的本地指令碼,不會被納入版本控制,如何通過一次配置就讓這個倉庫的所有使用成員都能開啟,是做好這件事的一個難點。

我們考慮到 Pod 本身會被納入版本控制,因此利用了 CocoaPods 的一個屬性 script_phase,增加了 Pod 編譯後指令碼,來幫助我們把 pre-push 插入到本地倉庫。利用 script_phase 插入還帶來了另外一個好處,我們可以直接獲取到工程的快取檔案,也避免了 .gcno / .gcda 檔案獲取的不確定性。整個流程如下:

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖11 pre-push 分發流程

覆蓋率累計

在實現了覆蓋率的過濾後,我們在實際開發中遇到了另外一個問題:修改分支/迴圈結構後生成的 .gcda 檔案無法和之前的合併。 在這種情況下,__gcov_flush會直接返回,不再寫入 .gcda 檔案了導致覆蓋率檢測失敗,這也是市面上已有工具的通用問題

而這個問題在開發過程中很常見,比如我們給例 1 中的遊戲增加一些提示,當輸入比預設數字大時,我們就提示出來,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSInteger targetNumber = 10;
    NSLog(@"Welcome to the game");
    if (guessNumber == targetNumber) {
        NSLog(@"Bingo!");
    } else if (guessNumber > targetNumber) {
        NSLog(@"Input number is larger than the given target!");
    } else {
        NSLog(@"Input number is smaller than the given target!");
    }
}
複製程式碼

這個問題困擾了我們很久,也推動了對覆蓋率檢測原理的調研。結合前面覆蓋率檢測的原理可以知道,不能合併的原因是生成的控制流程圖比原來多了兩條邊( .gcno 和舊的 .gcda 也不能匹配了),反映在 .gcda 上就是陣列多了兩個資料。考慮到程式碼變動後,原有的覆蓋率資訊已經沒有意義了,當發生邊數不一致的時候,我們會刪除掉舊的 .gcda 檔案,只保留最新 .gcda 檔案(有變動情況下 .gcno 會重新生成)。如下圖所示:

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖12 覆蓋率衝突解決演算法

整體流程圖

結合上述流程,我們的增量程式碼測試覆蓋率工具的整體流程如圖 13 所示。

開發者只需進行接入配置,再次執行時,工程中那些作為本地倉庫進行開發的程式碼庫會被自動插樁,並在 .git 目錄插入 hooks 資訊;當開發者使用模擬器進行需求自測時,插樁統計結果會被自動分發出去;在程式碼被推到遠端前,會根據插樁統計結果,生成僅包含本次程式碼修改的詳細增量程式碼測試覆蓋率報告,以及向遠端推送覆蓋率資訊;同時如果測試覆蓋率小於 80% 會強制拒絕提交(可配置關閉,百分比可自定義),保證只有經過充分自測的程式碼才能提交到遠端。

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現
圖13 增量程式碼測試覆蓋率生成流程圖

總結

以上是我們在程式碼開發質量方面做的一些積累和探索。通過對覆蓋率生成、解析邏輯的探究,我們揭開了覆蓋率檢測的神祕面紗。開發階段的增量程式碼覆蓋率檢測,可以幫助開發者聚焦變動程式碼的邏輯缺陷,從而更好地避免線上問題。

作者介紹

丁京,iOS 高階開發工程師。2015 年 2 月校招加入美團到店餐飲事業群,目前負責大眾點評 App 美食頻道的開發維護。

王穎,iOS 開發工程師。2017 年 3 月校招加入美團到店餐飲事業群,目前參與大眾點評 App 美食頻道的開發維護。

招聘資訊

到店餐飲技術部交易與資訊科技中心,負責點評美食使用者端業務,服務於數以億計使用者,通過更好的榜單、真實的評價和完善的資訊為使用者提供更好的決策支援,致力於提升使用者體驗;同時承載所有餐飲商戶端線上流量,為餐飲商戶提供多種營銷工具,提升餐飲商戶營銷效率,最終達到讓使用者“Eat Better、Live Better”的美好願景!我們的團隊包含且不限於 Android、iOS、FE、Java、PHP 等技術方向,已完備覆蓋前後端技術棧。只要你來,就能點亮全棧開發技能樹。誠摯歡迎投遞簡歷至 wangkang@meituan.com

參考資料

iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現

相關文章