![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAjM2EzLcd3LcJzLcJzdllmVldWYtl2Pml2ZuYTZihzM0gTZzUTNiZGOyIzMxIGNxkTM5UDNyEWNkZjZvwFNzQDM3EzLcVmdhNXLwRHdo9CXt92YucWbpRWdvx2Yx5yazF2Lc9CX6MHc0RHaiojIsJye.gif)
作者:ciuwaalu,騰訊安全平台部背景開發
研發效能提升是一個系統化的龐大工程,它涵蓋了軟體傳遞的整個生命周期,涉及到産品、架構、開發、測試、運維等各個環節。而單元測試作為軟體中最小可測試單元的檢查驗證環節,可以說是這個龐大工程中最細緻但又不可忽視的一個細節因素。本文内容梳理自安全平台部測試效能提升的經驗實踐,從零開始介紹探讨單測的方法論和優化思路,期望為大家帶來參考,歡迎共同交流。
什麼是單元測試?
在最開始,我們先看看大家認為的單元測試是什麼:
在計算機程式設計中,單元測試是一種軟體測試方法,通過該方法對源代碼的各個單元(一個或多個計算機程式子產品的集合以及相關的控制資料、使用過程和操作過程)進行測試以确定它們是否符合使用要求。—— 維基百科《Unit testing》
一個單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,之後對這個單元的單個最終結果的某些假設進行檢驗。單元測試幾乎都是用單元測試架構編寫的。單元測試容易編寫,能快速運作。單元測試可靠、可讀,并且可維護。隻要産品代碼不發生變化,單元測試的結果是穩定的。—— Roy Osherove《單元測試的藝術》
以上這些定義為了嚴謹起見,都是長長的一大段。在這裡,我們結合工程實踐經驗,給出一個“太長不看”版的定義,這個定義不太嚴謹但更為簡單:
開發同學 在 編碼階段 以 函數方法 為粒度編寫測試用例,檢驗 代碼邏輯 的正确性。
在這個一句話定義裡,有四個核心要素:
-
角色:開發同學
單元測試是開發同學工作的一部分,而不是測試同學的工作内容。
-
階段:編碼階段
單元測試是在開發編碼階段進行的,而不是轉測試之後才開始的。
-
粒度:函數方法
單元測試主要針對函數方法,而不是整個子產品或系統。
-
檢驗:代碼邏輯
單元測試主要驗證函數方法中的代碼邏輯實作,而不是子產品接口、系統架構、使用者需求。
結合測試 V 型圖,可以清晰看到單元測試在項目周期中所處的位置階段。
單元測試有什麼好處?
我們不打算羅列《單元測試的N大優勢》《寫單元測試的N大好處》,隻說一條最核心的:單元測試可以盡早發現編碼中的低級錯誤。
越早發現問題,也越容易解決問題。很顯然:
- 如果問題在編碼階段、由開發同學通過單元測試發現,開發同學可以立即修複
- 如果問題在轉測之後、由測試同學發現,可能會走缺陷單,修複流程時間長,影響項目進展
- 如果問題在測試階段未被發現,而在上線後才觸發,需要運維同學復原,甚至可能會導緻現網事故
來自微軟的資料,不同測試階段發現BUG的平均耗時,供參考:
- 單元測試階段,平均耗時 3.25 小時
- 內建測試階段,平均耗時 6.25 小時 (+92%)
- 系統測試階段,平均耗時 11.5 小時 (+254%)
低級錯誤造成重大損失的例子實在太多了。有了單元測試,可以避免 面向運氣開發,面向復原釋出,打破“不知道有沒有BUG ~ 上線出事復原 ~ 緊急修複 ~ 代碼品質逐漸劣化 ~ 不知道有沒有新BUG” 的惡性循環。
黑盒與白盒
在軟體測試理論中,常常将被測試對象視為一個盒子,這個神秘的盒子接受一些輸入,并做某些處理工作,産生特定的輸出結果。
在構造輸入資料進行測試時:
- 如果知道盒子的用途,但不知道盒子的構造,就是黑盒測試
- 如果知道盒子的用途,也知道盒子的構造,就是白盒測試
白盒測試一般隻在單元測試中使用,黑盒測試在單元測試、內建測試等各個階段都可以使用。
我們以下方這個函數為例子,看看單元測試中如何應用黑盒與白盒測試。首先需要明确,設計單元測試,我們肯定是知道這個函數的具體用途、輸入參數和傳回結果的含義(即知道盒子的用途):
// 從 IPv4 封包中提取源 IP 位址
uint32_t GetSrcAddrFromIPv4Packet(const void *buffer, size_t size);
複制
如果我們手上隻有編譯好的二進制庫檔案,不知道函數的内部實作方式,通過想象這個函數在上線後會遇到什麼類型的輸入,設計了一些合法和非法的 IP 封包來做驗證,此時是 黑盒測試。
如果我們手上有函數源代碼,一邊看着函數實作,一邊根據代碼裡的分支、邏輯構造各種輸入,此時是 白盒測試:
比如看到函數内部的
if (buffer == nullptr) return -1;
設計了一個空緩沖區的用例;
比如看到函數内部的
if (size < sizeof(iphdr)) return -1;
設計了緩沖區大小為 19Bytes 的用例。
在大部分情況下,我們是自己給自己寫的函數做單元測試,當運用黑盒測試的思路時,要 假裝 被測函數是别人寫的。
覆寫
在單元測試中,覆寫率是一個常用的評估名額。
所謂覆寫,可以簡單了解為 “被執行過”。具體來說:在某個測試用例中,執行了某行代碼,則可以說這行代碼“被覆寫”;同樣,當某個分支的真/假條件都被取到時,則可以說這個分支“被覆寫了”。
常見的覆寫可以分為這幾種:
- 語句覆寫
- 分支覆寫
- 條件覆寫
假設我們有一個這麼一個待測函數:
int foo(int a, int b, int c, int d) {
int result = 0;
if (a && b) // 分支 1
result += a;
if (c || d) // 分支 2
result += c;
return result;
}
複制
語句覆寫 是指 每條語句都被執行一次。當輸入
a=1, b=1, c=1, d=1
一組用例時可以達到。
分支覆寫 是指 每個分支 真/假 條件都被執行一次。當輸入
a=1, b=1, c=1, d=1
以及
a=0, b=0, c=0, d=0
兩組用例時可以達到。
條件覆寫 是指 每個分支的條件組合方式都被執行一次。當輸入
a=1, b=1, c=1, d=1
(真真)、
a=1, b=0, c=1, d=0
(真假)、
a=0, b=1, c=0, d=1
(假真)、
a=0, b=0, c=0, d=0
(假假)四組用例時可以達到。
語句覆寫是最容易達到、也是最弱的覆寫方式。在工程實踐中,考慮到測試成本及測試效果,分支覆寫的覆寫率是最常使用的考察名額。
樁與驅動
假設我們還有這麼一個待測函數:
void foo(int a) {
if (a > 0) {
A();
} else {
B();
}
}
複制
foo()
調用了外部函數
A()
B()
。
假設
A()
是一個很重的函數(操作 DB、檔案或者網絡通信……),進行單元測試時,我們不希望引入這些外部依賴,而是希望調用
A()
時立即傳回一些提前準備好的“假資料”,這時需要“仿冒”一個
A()
,這個僞造過程就叫做 插樁,假冒的
A()
就稱為 樁函數(stub)。
在做測試時,需要寫一個函數來調用
foo()
,這個調用者就是 驅動(driver)。
單元測試簡單實踐
一個簡單的單元測試
一個單元測試用例至少包含:
- 斷言
- 輸入資料
- 預期輸出
一個簡單但完整的單元測試看起來會是這樣的:
// 待測函數
int add(int a, int b) {
return a + b;
}
// 測試用例
void TestAdd() {
// 被測對象 預期輸出
// ||| |
assert(add(1, 2) == 3);
// |||||| | |
// 斷言 輸入資料
}
// 執行測試
int main() {
TestAdd();
}
複制
Given-When-Then
單元測試中 被測函數、斷言、輸入資料、預期輸出 幾個要素,可以通過經典模闆 Given-When-Then(GWT) 來做一些嚴謹的描述。
- Given 描述測試的前置條件或初始狀态
- When 描述測試過程中發生的行為
- Then 描述測試結束後斷言輸出結果
使用 GWT 來描述上一節的用例:
assert(
add( // When - 測試過程發生的行為 - 調用被測函數 add()
1, 2 // Given - 測試前置條件和初始狀态 - 用例輸入參數
)
== 3 // Then - 測試結束斷言輸出結果 - 斷言預期輸出
);
複制
有些現代化的測試架構(例如 catch2)對 GWT 描述做了表達上的優化。下方粘貼了一段單元測試代碼示例,有對 GWT 更為具體的描述:
SCENARIO( "vectors can be sized and resized", "[vector]" ) {
GIVEN( "A vector with some items" ) {
std::vector<int> v( 5 );
REQUIRE( v.size() == 5 ); // REQUIRE() 即 assert()
REQUIRE( v.capacity() >= 5 );
WHEN( "the size is increased" ) {
v.resize( 10 );
THEN( "the size and capacity change" ) {
REQUIRE( v.size() == 10 );
REQUIRE( v.capacity() >= 10 );
}
}
WHEN( "the size is reduced" ) {
v.resize( 0 );
THEN( "the size changes but not capacity" ) {
REQUIRE( v.size() == 0 );
REQUIRE( v.capacity() >= 5 );
}
}
}
}
複制
組織結構
原則:單元測試盡可能以函數方法等較小粒度進行組織。
假設我們有下邊一個類,設計單元測試時,最好以各個功能函數為測試目标,而不是将類本身為測試目标:
// IPv4 封包解析
struct IPv4Parser {
IPv4Parser(const void *buffer, size_t size);
size_t GetHeaderSize(); // 擷取頭部大小
uint32_t GetSrcAddr(); // 擷取源 IP
uint32_t GetDstAddr(); // 擷取目的 IP
};
複制
建議:為
GetHeaderSize()
GetSrcAddr()
GetDstAddr()
分别構造不同的測試輸入資料。
不建議:為
IPv4Parser
類構造測試輸入資料,然後對
GetHeaderSize()
GetSrcAddr()
GetDstAddr()
使用同樣的資料進行單元測試。
常見的測試架構都支援通過測試套件(TestSuite)對測試用例(TestCase)在邏輯上進行組織,測試套件可以嵌套,整個單元測試可以組織為樹狀結構。
常見的測試架構還支援 Fixture。Fixture 是對測試環境進行組織,通過
SetUp()
TearDown()
函數,以友善進行測試開始前的準備工作,以及測試完成後的清理工作。Fixture 一般會與測試套件結合使用。
組織單元測試的幾點準則:
- 輕量:不要有過多的前置條件或外部依賴
輕量的測試用例易于重複執行,友善重制和定位問題。
-
獨立:同一個測試套件的不同的用例互相獨立
測試用例之間盡量獨立,避免依賴,可亂序執行,結果穩定複現。
-
隔離:使用測試套件隔離資源
使用測試套件與 Fixture 隔離測試用例的資源依賴,以友善管理。
用例設計
設計單元測試用例中有很多方法:等價類劃分、邊界值分析、路徑測試……
在實踐中,我們可以設計覆寫 正常流程 & 異常流程 兩大類用例:
- 正常流程通過輸入合法的 典型資料、邊界值 看基本功能是否正确實作
- 異常流程通過輸入非法資料看異常處理流程是否符合預期
一個函數的内部實作可能是 異常處理-正常流程-異常處理-正常流程 的重複,比如這樣:
size_t IPv4Parser::GetHeaderSize() {
// 異常處理
if (buffer_size < sizeof(iphdr)) return 0;
// 正常流程
auto ip = (const iphdr*) buffer;
// 異常處理
if (ip->version != 4) return false;
// ...
}
複制
是以我們在設計測試用例時,可以:
- 首先設計覆寫 正常流程 的用例,構造一些合法的輸入:一個典型的 IP 封包,一個有擴充頭部的 IP 封包,一個帶有 TCP/UDP payload 的 IP 封包……
- 其次設計覆寫 異常流程 的用例,構造一些非法的輸入:空指針,不完整的 IP 頭,非 IP 協定……
- 最後再考慮一些邊界情況:一個不帶 payload 的 IP 封包,一個大小為 64K 上限的 IP 封包,一個頭部完整但payload 不完整的 IP 封包……
在設計測試用例過程中,可能會遇到被測函數需要與外部 DB、檔案、網絡互動的情況,這時候需要使用 Fakes/Stubs/Mocks 進行模拟:
-
Fakes:包含了生産環境下具體實作的簡化版本的對象
比如模拟的資料庫對象、檔案描述符、網絡連接配接等。
-
Stubs:包含了預定義好的資料并且在測試時傳回給調用者的對象
比如很多組預定義好的輸入、輸出資料,比如資料庫查詢結果。
-
Mocks:僅記錄它們的調用資訊的對象
比如模拟的檔案儲存接口、資料發送接口等。
在實踐中通常并不糾結這幾個詞語的差別,常被統稱為 插樁,對應的工具也一般被稱作 Mock 工具。
C++ 單元測試
常見單元測試架構
GoogleTest 是老牌測試架構,功能完善,使用者很多。
Catch2 是現代化測試架構,提供了很多特色功能,依賴簡單,可以一試。
Boost.Test 是 Boost 自帶的測試架構,依賴 Boost 的程式可以直接使用,功能強大。
一些 Mock 工具
- GoogleMock
- 通過 C++ 多态實作對虛函數進行 Mock
- 不支援 Free Function 以及非虛函數
- 目前已經合并為 GoogleTest 的一個子子產品
- 《效能優化實踐:C/C++單元測試萬能插樁工具》
- 通過 Hook 函數入口實作用 Mock 函數無縫替換原始函數
- 内部開源工具
- MySQL Server Mock
- MySQL 官方提供的服務端 Mock 工具
編譯參數選項
- 開啟調試資訊:
-
-g
-
- 關閉優化和代碼保護:
-
-O0
-
-fno-inline
-
-fno-access-control
-
- 覆寫率:
-
--coverage
-
-fprofile-arcs
-
-ftest-coverage
-
Python 單元測試
點選閱讀《研效優化實踐:Python單測——從入門到起飛》。
小經驗分享
三條準則
單元測試必須經常跑
- 錯誤做法:為了完成 KPI 寫了一堆測試,跑一次就不管了
- 正确做法:持續內建,自動化運作
從增量到存量,從主要到次要
- 從覆寫新子產品、新功能做起,單元測試先跑起來再說
- 不要追求 100% 的覆寫率,但主要功能邏輯要完成覆寫測試
測試用例需要逐漸積累
- 上線前已經有了第一批用例,每次疊代都會增加新用例來覆寫變更
實踐經驗
思路:以黑盒指導功能驗證,以白盒提升覆寫率
黑盒測試為主:
- 黑盒測試驗證功能邏輯實作是否正确
- 不關心内部實作方式,代碼優化重構用例仍可複用
白盒測試為輔:
- 白盒測試關注黑盒測試用例遺漏的分支、路徑
- 可以聚焦于異常處理邏輯是否合理
- 項目工期緊時可推遲進行
可能踩到的坑
不要被高覆寫率騙了
- 單元測試的目标是發現問題,不是追求高覆寫率
- 宏、模闆等文法功能可能會使得覆寫率虛高
Debug/Release 目标結果不一緻
- Debug 目标關閉優化,啟用堆棧保護,某些錯誤代碼可正常執行
- 單測在 Debug 下跑完後,建議在 Release 下再跑一次
代碼合并導緻單測失敗
- 小A和小B分别開發新功能,push 前單測都通過了,MR 後單測卻挂了
- 使用持續內建發現問題
提高代碼的可測性
在編碼過程中,多多考慮代碼的可測性,可以讓單元測試事半功倍:
- 開發過程及時編寫測試用例,邊開發邊測試,不要等全部開發完畢了才開始寫測試用例
- 函數功能簡單,避免随機性,以免測試結果不穩定
- 函數減少輸入輸出,使簡單的輸入資料組合可以完成測試覆寫
- 遵循 SOLID 原則
最後
在實際研發與測試工作中,單元測試是保證代碼品質的有效手段,也是效能優化實踐的重要一環。安平研效團隊仍在持續探索優化中,若大家在工作中遇到相關問題,歡迎一起交流探讨,共同把研效工作做好、做強。