C/C++ 單元自動化測試解決方案實踐

2022-06-01 12:00:31

vivo 網際網路伺服器團隊 - Li Qingxin

C/C++ 開發效率一直被業內開發人員詬病,單元測試開發效率也是如此,以至於開發人員不願花時間來寫單元測試。那麼我們是不是可以通過改善編寫單元測試的效率來提升專案的測試用例覆蓋率?

本文主要介紹如何利用GCC外掛來實現提升C/C++開發者的單元效率工具解決方案,希望對大家在提升單元測試效率上有所啟發。

一、動機

上圖展示了C/C++單元測試的基本流程,在日常開發過程中寫單元測試是一項比較大工程量的事情,C/C++ 目前單元測試程式碼都需要自己手動寫,而且對於一些私有方法打樁就更加麻煩。

目前業內無開源的自動化測試框架或者工具,倒是有一些商業的自動測試工具,下圖展示了我們自動化測試工具及單元測試庫:

即使開源界有gtest等測試庫的支援,我們仍然需要編寫大量的單元測試用例程式碼。對於一些private、protected的類方法,編寫單元測試用例的效率就更低,需要手動打樁(mock)。同時我們分析測試用例發現,存在很多邊界的用例,它們基本上都是很固定或者有一定模式,比如int 最大最小值等。

如何改善編寫單元測試的效率,提升C/C++同學開發效率以及程式質量?我們可以通過提取原始檔中的函數、類等資訊,然後生成對應的單元測試用例。自動生成用例時需要依賴函數的宣告、類的宣告等資訊,那麼我們應該如何獲取這些資訊呢?

例如:如下的函數定義:

void test(int arg) {}

我們希望能夠從上面的函數定義中得到函數的返回值型別、函數名稱、函數引數型別、函數作用域。通常我們可以通過以下幾種方式得到:

1.1 方法1:使用正規表示式

無奈C/C++ 格式比較複雜能夠雖然能夠使用多種組合來獲取對應的函數宣告等資訊:

void test(int arg){}
void test1(template<template<string>> arg,...){}
void test2(int(*func)(int ,float,...),template<template<string>> arg2){}

那麼就需要寫一系列的正規表示式:

  • 提取函數名稱、引數名:[z-aA-Z_][0-9]+

  • 提取函數返回值:^[a-zA-Z_]

關鍵詞提取出來了,但是他有一個很大的問題:怎麼判斷檔案中書寫的程式碼是符合C/C++語法描述呢?

1.2 方法2:使用flex/bison 分析c/c++原始碼檔案

這當然是一種很好的方式,但是工作量巨大,相當於實現一個具備詞法、語法分析器簡易版本的編譯器,而且要適配不同的語法格式,雖然bison可以解決上述的如何判斷語法是否正確問題,但是仍然很複雜。

1.3 方法3:利用編譯已經生成的AST 來生成程式碼

通常我們瞭解到的GCC編譯的過程是以下四個階段:

原始檔->預處理->編譯->組合→連結

但實際上GCC為了支援更多的程式語言、不同的CPU架構做了很多的優化,如下圖所示:

上圖展示了GCC處理原始碼及其他優化過程,在前端部分生成的Generic 語言是gcc編譯過程中為原始碼生成的一種與原始碼語言無關的抽象語法表現形式(AST)。既然GCC編譯過程中生成了AST樹,那麼我們可以通過GCC外掛來提取GCC 前端生成的抽象語法樹關鍵資訊比如函數返回值、函數名稱、引數型別等。總體難度也很高,一方面業內可參考資料很少,只能通過分析GCC的原始碼來分析AST語法樹上的各個節點描述。

本文所描述的自動化生成單元測試用例的解決方案(我們稱之為TU:Translate Unit,後文統稱為TU)就是基於方法3來實現的,下面我們先來看看我們的自動化測試用例解決方案的效果展示。

二、效果展示

2.1 業務程式碼零修改, 直接使用TU生成邊界用例

在該用例中我們不需要修改任何業務程式碼就能夠為業務程式碼生成邊界測試用例,而且函數引數可邊界值實現全排列,大大降低用例遺漏風險。大家可能發現這種沒有做任何修改生成的用例是沒有斷言的,雖然沒有斷言,它仍然能夠幫助發現單元是否會存在邊界值引起coredump。

那麼如果想要給他加上斷言、mock函數,是否沒有辦法呢?通過C++11 [[]] 新的屬性語法,只需要在方法宣告或者定義時新增下根據TU的格式新增斷言即可,對業務邏輯無侵入。

2.2 使用註解tu::case生成使用者自定義用例

很多情況下預設生成的邊界測試用例還不能覆蓋到核心邏輯,所以我們也提供tu::case 來給使用者自定義自己的測試用例及斷言。比如有一個int foo (int x,long y) 方法,現在想新增一個測試用例返回值123,函數實參1,1000,那麼只要在函數宣告前加入,以下程式碼即可:

[[tu::case("NE","123","1","1000")]]

2.3 使用註解tu::mock 自動生成mock方法

開發過程中我們也常需要對某個方法進行mock(即對原有方法設定一個臨時代替方法並且呼叫方式保持一致),比如某個函數存取Redis、DB這種情況下進行單元測試往往需要對這些方法進行mock,方便其他函數呼叫進行單元測試,為了方便進行單元測試我們往往會對其進行mock,所以為了方便開發人員進行快速的mock,所以我們提供了tu::mock 的註解幫助開發同學快速的定義註解,然後TU會自動生成對應的mock函數。例如:現在給foo_read 方法mock一個函數,讓mock的函數返回10:

三、TU實現方案

3.1 AST 是什麼?

GENERIC、GIMPLE和RTL三者構成了gcc中間語言的全部,它們以GIMPLE為核心,由GENERIC承上,由RTL啟下,在原始檔和目標指令之間的鴻溝之上構建了一個三層的過渡。

GCC在語法分析過程中,所有識別出來的語言部件都用一個叫TREE的變數儲存著。這個TREE就是GCC語法樹(AST),這個過程叫做GENERIC。實際上它也是GCC的符號表,因為變數名、型別等等這些資訊都由TREE關聯起來。

下面我們通過gcc編譯選項來看下gcc的ast表現形式:

3.2 AST(Abstract syntax tree)

GCC 可以通過新增編譯選項-fdump-tree-all 來生成ast 樹,ast樹檔案內容如下:

AST 各個型別描述可以參考:https://gcc.gnu.org/onlinedocs/gccint/Types.html

雖然上圖中簡單看下一下可以發現,gcc這種表現形式節點與節點之間還存在依賴,比較難於理解,沒有clang生成的直觀更容易閱讀。雖然不利於閱讀,但是不影響通過編碼來提取AST資訊。

3.3  方案

如上圖所示,我們通過使用不同的外掛收集被測試原始檔的AST資訊、標頭檔案資訊、函數註解(屬性),將這些重要資訊儲存起來。GCC將使用者註冊外掛事件儲存到陣列中:

然後在編譯構建過程中到就會去查詢對應的事件有沒有設定回撥方法如果設定則進行呼叫,TU主要使用以下幾種外掛:

  • PLUGIN_INCLUDE_FILE 用於獲取當前檔案的所包含的標頭檔案

  • PLUGIN_OVERRIDE_GATE 使用者獲取普通函數、類

  • PLUGIN_PRE_GENERICIZE 用於獲取模板函數的具現化

  • PLUGIN_ATTRIBUTES 用於實現自定義屬性或者註解(tu::case\tu::mock ....)

GCC 支援的所有外掛型別如下圖所示:(摘自gcc 6.3.0 原始碼)

四、TU 外掛使用的簡易程度對比

如果僅僅只是做邊界測試那麼僅需要修改構建的指令碼比如cmake 新增對應的外掛引數即可。

五、使用TU的優點

  1. 接入簡單、邊界單元測試可以做到業務程式碼0修改

  2. 函數引數可邊界值實現全排列,大大降低用例遺漏風險、減少大量重複性的工作

  3. 快速生成使用者自定義用例、mock方法等

六、TU支援的功能

七、總結與展望

1、文章中對比了三種方法自動生成測試用例的方法,下面對這幾種方法進行對比:

2、文章中還主要介紹了TU的功能特點以及基於GCC-AST的實現自動生成測試用例的解決方案。

TU解決方案目前在構建時能夠自動生成測試用例已經極大降低了單元測試門檻提升單元測試覆蓋率,未來我們也希望能夠把TU與IDE相結合,探索更高效便捷的使用方式,通過更加便捷的方式生成指定方法的測試用例。比如通過在函數、方法上,通過快捷鍵生成當前方法的測試用例等。

參考文獻:

【1】gcc plugins

【2】Functions for C++ (GNU Compiler Collection (GCC) Internals)