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