背景
我們團隊在手淘中主要負責BehaviX模塊,代碼主要是一些邏輯功能,很少涉及到UI,為了減少雙端不一致問題、提高性能,我們採用了將核心代碼C++化的策略。
由於團隊項目偏底層,測試同學難以完全覆蓋,回歸成本較高,部分功能依賴研發同學自測,為了提高系統的穩定性,我們在團隊中實行了單元測試,同時由於集團客戶端C++單元測試相關經驗沉澱較少,所以在此分享下團隊在做單元測試中遇到的問題與解決思路,希望能對大家所有幫助。
為什麼要使用單元測試
1、運行快
如果由測試同學手工測試,可能測試周期很長,對於功能比較複雜的功能,測試同學可能並不能完整覆蓋所有預期鏈路,也可能由於某些操作而錯過一些關鍵性步驟。
2、減少回歸成本
使用單元測試,可以在每次修改代碼後重新運行整套測試,儘可能保證新代碼不會破壞現有功能。
3、優化代碼結構
當代碼耦合度非常大時,可能很難進行單元測試。為代碼編寫測試將自然地按照預期功能分離你的類。
單測工程搭建歷程
單測環境搭建
運行環境的選擇
C++工程由於一些三方庫的依賴(需要準備多個平台的連結庫),同一份代碼想要在不同作業系統上運行稍微有點困難。
為了能夠讓單測工程快速運行起來,同時也方便開發同學調試,兼顧Android/iOS同學的開發習慣,在運行環境上支持單測支持在MacOS和Linux下運行。
依賴剝除
由於單測環境是運行在電腦環境的,所以必須要把一些外部依賴去除。
Java/OC的API依賴
涉及到跨語言通信時,通過NativeBridge封裝,內部通過宏或cpp文件連結區分Android和iOS環境
外部庫的依賴
一般採取源碼依賴或打出多平台連結庫(需要MacOS和Linux版本的依賴)的依賴方式解決。
單測框架
目前業內C++主流單測框架為google的gtest + gmock。
gtest提供了一些單元測試中的斷言工具,gmock提供了一些mock功能,但是功能比較弱。
MOCK工具
gtest提供的gmock工具功能比較弱,只能通過繼承的方式mock虛函數,對於C++來說是極其不方便的。
在Java中,成員方法是默認可以被派生類重寫的,java主流mock工具mockito正是利用了這一特性來完成mock操作。在C++中,所有函數默認是不能被重寫的,而且存在一些靜態函數和工具函數,無法通過繼承重寫的方式完成mock。
最終我們基於開源的hook工具 frida 進行封裝,實現了自己的mock工具。
部署到伺服器運行
依賴安裝
為了使單測工程和其他系統打通(如:釘釘群、Aone),單測工程同時也支持在Linux環境中運行。
因為C++語言的特殊性,從本機環境(MacOS)遷移到Linux並不是一帆風順的。
集團的服務端機器使用的是CentOS,而且只能下載內網環境中已有的軟體,版本也比較老,而且集團機器對C++的環境支持稍弱,如:編譯器不支持C++11語法,CMake版本低,沒有Clang編譯器等。
所以大部分依賴我們都是通過源碼的形式導入到服務端機器中,編譯出可執行文件安裝。
生成鏡像(可選)
在編譯器、CMake等工具安裝好了之後,可以為當前環境創建docker鏡像,這樣下次就能部署到其他機器直接使用了。
外圍功能建設
覆蓋率
單測代碼覆蓋率
通過增加編譯參數 -fprofile-arcs 和 -ftest-coverage,在編譯完成後每個源文件會生成對應的.gcno文件,在程序運行結束時會生成.gcda文件,然後可以在單元測試運行完成後,使用lcov/gcov,統計代碼運行的覆蓋率。
注意,推薦使用動態連結的方式將你的待測工程庫連結到每個測試用例中,如果使用靜態連結,在單元測試運行完成後可能會有一些沒有被任何用例覆蓋到的文件沒有生成.gcda文件,在計算代碼覆蓋率時這些源文件會被遺漏。
增量代碼覆蓋率
使用git merge-base可以獲取兩次提交最佳的公共祖先。
拿到最佳公共祖先與當前節點的提交記錄,通過git diff和git blame,就可以獲得兩次提交的增量代碼行,結合代碼覆蓋率可以計算出增量代碼覆蓋率。
內存泄漏檢查
C++代碼很容易寫出內存泄漏,所以我們在單測工程中集成了valgrind工具,能有效的檢測出內存泄漏的代碼。
下面是一個簡單的示例
釘釘群播報
每次代碼合併到develop分支的時候,釘釘群中會播報本次測試的通過率以及代碼覆蓋率與上次合併時時差值等信息,方便大家及時修復問題,通過覆蓋率增長差值也可以調動團隊寫單測的積極性。
code review卡口
在提交code review時,大家可以看到本次代碼的單測通過率、單測覆蓋率、增量覆蓋率等信息,如果單元測試運行沒有通過,或增量覆蓋率卡口未通過(目前團隊中要求增量單測覆蓋率達到90%),則不允許合併代碼。
單元測試實踐
如何編寫有效的單元測試用例
單元測試的組成部分
一般單元測試由以下幾部分組成
- 測試數據:儘可能穩定,減少對不確定性因素的依賴
- 邏輯執行體:要明確當前測試用例測試的是哪個函數、哪個分支邏輯,不要一次性覆蓋大多
- 結果校驗:儘可能完整,不要只校驗函數返回值
單元測試的原則
單元測試必須遵循的原則:
- 獨立性:單元測試是獨立的,可以單獨運行,並且不依賴於任何外部因素,如文件系統或資料庫。
- 冪等性:每次運行單元測試應與其結果一致,測試中不要依賴如時間、日期等不確定因素
- 快速:不要依賴網絡請求等耗時操作
經驗小結
編寫單元測試時建議從以下角度思考
- 實現什麼功能,處理哪些數據,最終輸出什麼?
- 異常和邊界在哪裡?
- 函數的關鍵結果是否都驗證到?包含返回值和中間值。
- 函數的風險在哪裡,哪部分邏輯不太自信,最容易出錯?
- 並不是所有函數都需要單測,如get/set等邏輯比較簡單的的,不一定需要寫。
提高代碼的可測試性
C++是一門多範式的語言,而且由於C+語言本身的一些特性(RAII,模板等),網上很多基於Java等語言總結出來的提高可測試性的方法對C++來說可能過於麻煩,如依賴注入等,不一定特別適用。
下面整理了一些簡單常用能提高可測試性的方式。
影響可測試性的常見因素
- 外部依賴過多,需要mock
- 數據依賴鏈過長,導致構造測試數據麻煩
- 分支邏輯過於複雜
- 全局變量/靜態變量
- 內部lambda表達式過多
- 依賴的類對象不可構造/難以構造
- 函數功能過多
減少全局變量/靜態變量的使用
如果你的對象依賴了一些全局變量/靜態變量,而且這些全局變量會在多個測試case使用,這種情況是比較難測試的,你不得不在每個測試用例結束之後手動重置全局變量。這樣不符合單測測試的獨立性原則,所以應該儘量避免使用全局變量。
class MyTest {
public:
int GetIndex() {
return index++;
}
static int index; //靜態變量
};
int MyTest::index = 0;
TEST(test, demo) {
ASSERT_EQ(0, MyTest().GetIndex());
}
TEST(test, demo2) {
ASSERT_EQ(0, MyTest().GetIndex()); //Error
}
TEST(test, demo) {
MyTest::index = 0;
ASSERT_EQ(0, MyTest().GetIndex());
}
TEST(test, demo2) {
MyTest::index = 0;
ASSERT_EQ(0, MyTest().GetIndex());
}
迪米特法則
1、如果你代碼中引入一些複雜的外部依賴,可以考慮將依賴轉移給調用方
如:
class MyClass {
public:
void doSomething() {
if(getUserManager().getUser(123).getProfile().isAdmin()) { //bad 複雜的依賴鏈
//xxxx
} else {
}
}
};
class MyClass {
public:
void doSomething(bool isAdmin) { //簡單的參數依賴
if(isAdmin) {
//xxxx
} else {
}
}
};
2、直接依賴需要的參數,避免依賴類似於Context大而全的參數(可能非常難以構造)
如:
class MyClass {
public:
void processOrderBefore(const UserContext & userContext) { //修改之前
const User & user = userContext.getUser();
const PlanLevel & level = userContext.getLevel();
const Order & order = userContext.getOrder();
// ... process
}
void processOrderAfter(const UserContext & userContext) { //修改後
const User & user = userContext.getUser();
const PlanLevel & level = userContext.getLevel();
const Order & order = userContext.getOrder();
processOrderAfter(user, level, order); //核心邏輯抽成新的函數
}
void processOrderAfter(const User & user, const PlanLevel & level,const Order & order) {
//只需要對新封裝函數進行單元測試即可
// ... process
}
};
封裝分支邏輯
如果一個函數中分支太多,可以考慮將不同分支封裝成不同的函數處理,然後對封裝的函數分別編寫單元測試用例。
合理使用MOCK工具
考慮在以下場景使用mock工具,可以減少你的單元測試成本
- 代碼中依賴的某個功能在你本次測試並不關心,如:db數據讀取,發請求
- 測試用例依賴一些複雜的數據源,如:db數據讀取,流水線上游數據,網絡請求
- 一些非冪等性的函數調用或者結果返回不穩定的函數調用,如:隨機數獲取,時間獲取,db寫入
- 對象的某些狀態難以創建或者重現,如:網絡錯誤或者文件讀寫錯誤
- 驗證一些中間過程值,如:你的函數沒有返回值,或者中間過程值不方便驗證,可以mock中間某個函數調用來驗證中間過程結果是否正確
嘗試測試驅動開發(TDD)
如果你的需求所要實現的功能相對明確,那麼可以先把接口定義出來,寫一個最簡單的實現運行起來,為其補充單元測試用例,然後再一步步完善具體實現細節。
如果不能先寫測試用例也沒關係,重要的是在開發中儘早編寫測試測試,不要將它們延遲到最後,這樣可以及時重構你的代碼。
常見誤區
只測試正常數據
應當儘量補充一些特殊值(如空值、邊界值)或異常數據,以校驗目標函數在不同的輸入是否符合預期,儘量覆蓋多的代碼分支邏輯。
結果校驗不完整
如果你的目標測試函數中對屬性進行了修改,那麼應該儘可能校驗這些修改是否符合預期,而不是單單只校驗函數返回值。
輸入數據過於複雜
- 生成測試輸入數據的代碼應當避免與實際工程代碼耦合,如:讀取db或從流水線上游產生等
- 使用最小數據依賴的原則,只輸入對當前測試用例會產生影響的數據即可。
- 如果數據源構造過於複雜,可以將一個大的測試用例拆分成多個小的測試用例。
測試代碼存在分支條件
避免測試用例代碼中使用if、switch等分支邏輯,保持用例儘量簡單,如果需要測試不同分支的代碼邏輯,應該拆分成多個測試用例。
維護測試用例
- 重構代碼時,應該同步修改測試用例
- 發現新增Bug時,應當將能驗證此Bug被修復的測試用例的補充到單元測試工程中
測試用例命名規則參考
TEST_F(TestUCPPipelineCenter, checkTaskInProcess_重複觸發_true);
測試宏 被測試類名, 被測試函數名_簡單描述核心測試邏輯_要校驗的結果值
小結
我們小組的單元測試工程已經穩定運行了一段時間,代碼提交流程也逐步固化下來了,如下圖所示。後續我們會尋找一些指標去量化衡量單元測試所帶來的收益。希望本文能幫助大家更加快捷地搭建C++單元測試環境。
附錄
- 「單元測試最佳實踐」https://www.jianshu.com/p/6413fcd58b71
- 「從頭到腳說單測——談有效的單元測試(下篇)」http://testerhome.com/topics/30683
- 「Frida - Anatomy of a code tracer 」https://medium.com/@oleavr/anatomy-of-a-code-tracer-b081aadb0df8
作者 | 思兼
原文連結:https://click.aliyun.com/m/1000352260/
本文為阿里雲原創內容,未經允許不得轉載。