客戶端單元測(cè)試實(shí)踐-C++篇
背景
我們團(tuán)隊(duì)在淘寶中主要負(fù)責(zé)BehaviX模塊,代碼主要是一些邏輯功能,很少涉及到UI,為了減少雙端不一致問(wèn)題、提高性能,我們采用了將核心代碼C++化的策略。由于團(tuán)隊(duì)項(xiàng)目偏底層,測(cè)試同學(xué)難以完全覆蓋,回歸成本較高,部分功能依賴研發(fā)同學(xué)自測(cè),為了提高系統(tǒng)的穩(wěn)定性,我們?cè)趫F(tuán)隊(duì)中實(shí)行了單元測(cè)試,同時(shí)由于集團(tuán)客戶端C++單元測(cè)試相關(guān)經(jīng)驗(yàn)沉淀較少,所以在此分享下團(tuán)隊(duì)在做單元測(cè)試中遇到的問(wèn)題與解決思路,希望能對(duì)大家所有幫助。
? 為什么要使用單元測(cè)試
1、運(yùn)行快
如果由測(cè)試同學(xué)手工測(cè)試,可能測(cè)試周期很長(zhǎng),對(duì)于功能比較復(fù)雜的功能,測(cè)試同學(xué)可能并不能完整覆蓋所有預(yù)期鏈路,也可能由于某些操作而錯(cuò)過(guò)一些關(guān)鍵性步驟。
2、減少回歸成本
使用單元測(cè)試,可以在每次修改代碼后重新運(yùn)行整套測(cè)試,盡可能保證新代碼不會(huì)破壞現(xiàn)有功能。
3、優(yōu)化代碼結(jié)構(gòu)
當(dāng)代碼耦合度非常大時(shí),可能很難進(jìn)行單元測(cè)試。為代碼編寫(xiě)測(cè)試將自然地按照預(yù)期功能分離你的類。
單測(cè)工程搭建歷程
? 單測(cè)環(huán)境搭建
- 運(yùn)行環(huán)境的選擇
C++工程由于一些三方庫(kù)的依賴(需要準(zhǔn)備多個(gè)平臺(tái)的鏈接庫(kù)),同一份代碼想要在不同操作系統(tǒng)上運(yùn)行稍微有點(diǎn)困難。
為了能夠讓單測(cè)工程快速運(yùn)行起來(lái),同時(shí)也方便開(kāi)發(fā)同學(xué)調(diào)試,兼顧Android/iOS同學(xué)的開(kāi)發(fā)習(xí)慣,在運(yùn)行環(huán)境上支持單測(cè)支持在MacOS和Linux下運(yùn)行。
- 依賴剝除
由于單測(cè)環(huán)境是運(yùn)行在電腦環(huán)境的,所以必須要把一些外部依賴去除。
Java/OC的API依賴
涉及到跨語(yǔ)言通信時(shí),通過(guò)NativeBridge封裝,內(nèi)部通過(guò)宏或cpp文件鏈接區(qū)分Android和iOS環(huán)境

外部庫(kù)的依賴
一般采取源碼依賴或打出多平臺(tái)鏈接庫(kù)(需要MacOS和Linux版本的依賴)的依賴方式解決。
? 單測(cè)框架
目前業(yè)內(nèi)C++主流單測(cè)框架為google的gtest + gmock。
gtest提供了一些單元測(cè)試中的斷言工具,gmock提供了一些mock功能,但是功能比較弱。
? MOCK工具
gtest提供的gmock工具功能比較弱,只能通過(guò)繼承的方式mock虛函數(shù),對(duì)于C++來(lái)說(shuō)是極其不方便的。
在Java中,成員方法是默認(rèn)可以被派生類重寫(xiě)的,java主流mock工具mockito正是利用了這一特性來(lái)完成mock操作。在C++中,所有函數(shù)默認(rèn)是不能被重寫(xiě)的,而且存在一些靜態(tài)函數(shù)和工具函數(shù),無(wú)法通過(guò)繼承重寫(xiě)的方式完成mock。
最終我們基于開(kāi)源的hook工具 frida (地址:https://github.com/frida/frida)進(jìn)行封裝,實(shí)現(xiàn)了自己的mock工具。
frida | gmock | |
普通成員函數(shù) | ? | ? |
虛函數(shù) | ? | ? |
靜態(tài)函數(shù) | ? | ? |
final函數(shù) | ? | ? |
無(wú)需為mock重構(gòu)代碼 | ? | ? |
? 部署到服務(wù)器運(yùn)行
- 依賴安裝
為了使單測(cè)工程和其他系統(tǒng)打通(如:釘釘群、Aone),單測(cè)工程同時(shí)也支持在Linux環(huán)境中運(yùn)行。
因?yàn)镃++語(yǔ)言的特殊性,從本機(jī)環(huán)境(MacOS)遷移到Linux并不是一帆風(fēng)順的。
集團(tuán)的服務(wù)端機(jī)器使用的是CentOS,而且只能下載內(nèi)網(wǎng)環(huán)境中已有的軟件,版本也比較老,而且集團(tuán)機(jī)器對(duì)C++的環(huán)境支持稍弱,如:編譯器不支持C++11語(yǔ)法,CMake版本低,沒(méi)有Clang編譯器等。
所以大部分依賴我們都是通過(guò)源碼的形式導(dǎo)入到服務(wù)端機(jī)器中,編譯出可執(zhí)行文件安裝。
- 依賴安裝
為了使單測(cè)工程和其他系統(tǒng)打通(如:釘釘群、Aone),單測(cè)工程同時(shí)也支持在Linux環(huán)境中運(yùn)行。
因?yàn)镃++語(yǔ)言的特殊性,從本機(jī)環(huán)境(MacOS)遷移到Linux并不是一帆風(fēng)順的。
集團(tuán)的服務(wù)端機(jī)器使用的是CentOS,而且只能下載內(nèi)網(wǎng)環(huán)境中已有的軟件,版本也比較老,而且集團(tuán)機(jī)器對(duì)C++的環(huán)境支持稍弱,如:編譯器不支持C++11語(yǔ)法,CMake版本低,沒(méi)有Clang編譯器等。
所以大部分依賴我們都是通過(guò)源碼的形式導(dǎo)入到服務(wù)端機(jī)器中,編譯出可執(zhí)行文件安裝。
? 外圍功能建設(shè)
- 覆蓋率
單測(cè)代碼覆蓋率
通過(guò)增加編譯參數(shù) -fprofile-arcs 和 -ftest-coverage,在編譯完成后每個(gè)源文件會(huì)生成對(duì)應(yīng)的.gcno文件,在程序運(yùn)行結(jié)束時(shí)會(huì)生成.gcda文件,然后可以在單元測(cè)試運(yùn)行完成后,使用lcov/gcov,統(tǒng)計(jì)代碼運(yùn)行的覆蓋率。
注意,推薦使用動(dòng)態(tài)鏈接的方式將你的待測(cè)工程庫(kù)鏈接到每個(gè)測(cè)試用例中,如果使用靜態(tài)鏈接,在單元測(cè)試運(yùn)行完成后可能會(huì)有一些沒(méi)有被任何用例覆蓋到的文件沒(méi)有生成.gcda文件,在計(jì)算代碼覆蓋率時(shí)這些源文件會(huì)被遺漏。
增量代碼覆蓋率
使用git merge-base可以獲取兩次提交最佳的公共祖先。

拿到最佳公共祖先與當(dāng)前節(jié)點(diǎn)的提交記錄,通過(guò)git diff和git blame,就可以獲得兩次提交的增量代碼行,結(jié)合代碼覆蓋率可以計(jì)算出增量代碼覆蓋率。
- 內(nèi)存泄漏檢查
C++代碼很容易寫(xiě)出內(nèi)存泄漏,所以我們?cè)趩螠y(cè)工程中集成了valgrind工具,能有效的檢測(cè)出內(nèi)存泄漏的代碼。
下面是一個(gè)簡(jiǎn)單的示例

- 釘釘群播報(bào)
每次代碼合并到develop分支的時(shí)候,釘釘群中會(huì)播報(bào)本次測(cè)試的通過(guò)率以及代碼覆蓋率與上次合并時(shí)時(shí)差值等信息,方便大家及時(shí)修復(fù)問(wèn)題,通過(guò)覆蓋率增長(zhǎng)差值也可以調(diào)動(dòng)團(tuán)隊(duì)寫(xiě)單測(cè)的積極性。
- code review卡口
在提交code review時(shí),大家可以看到本次代碼的單測(cè)通過(guò)率、單測(cè)覆蓋率、增量覆蓋率等信息,如果單元測(cè)試運(yùn)行沒(méi)有通過(guò),或增量覆蓋率卡口未通過(guò)(目前團(tuán)隊(duì)中要求增量單測(cè)覆蓋率達(dá)到90%),則不允許合并代碼。

單元測(cè)試實(shí)踐
? 如何編寫(xiě)有效的單元測(cè)試用例
- 單元測(cè)試的組成部分
一般單元測(cè)試由以下幾部分組成
- 測(cè)試數(shù)據(jù):盡可能穩(wěn)定,減少對(duì)不確定性因素的依賴
- 邏輯執(zhí)行體:要明確當(dāng)前測(cè)試用例測(cè)試的是哪個(gè)函數(shù)、哪個(gè)分支邏輯,不要一次性覆蓋大多
- 結(jié)果校驗(yàn):盡可能完整,不要只校驗(yàn)函數(shù)返回值
- 單元測(cè)試的原則
單元測(cè)試必須遵循的原則:
- 獨(dú)立性:?jiǎn)卧獪y(cè)試是獨(dú)立的,可以單獨(dú)運(yùn)行,并且不依賴于任何外部因素,如文件系統(tǒng)或數(shù)據(jù)庫(kù)。
- 冪等性:每次運(yùn)行單元測(cè)試應(yīng)與其結(jié)果一致,測(cè)試中不要依賴如時(shí)間、日期等不確定因素
- 快速:不要依賴網(wǎng)絡(luò)請(qǐng)求等耗時(shí)操作
- 經(jīng)驗(yàn)小結(jié)
編寫(xiě)單元測(cè)試時(shí)建議從以下角度思考
- 實(shí)現(xiàn)什么功能,處理哪些數(shù)據(jù),最終輸出什么?
- 異常和邊界在哪里?
- 函數(shù)的關(guān)鍵結(jié)果是否都驗(yàn)證到?包含返回值和中間值。
- 函數(shù)的風(fēng)險(xiǎn)在哪里,哪部分邏輯不太自信,最容易出錯(cuò)
- 并不是所有函數(shù)都需要單測(cè),如get/set等邏輯比較簡(jiǎn)單的的,不一定需要寫(xiě) 。
? 提高代碼的可測(cè)試性
C++是一門多范式的語(yǔ)言,而且由于C+語(yǔ)言本身的一些特性(RAII,模板等),網(wǎng)上很多基于Java等語(yǔ)言總結(jié)出來(lái)的提高可測(cè)試性的方法對(duì)C++來(lái)說(shuō)可能過(guò)于麻煩,如依賴注入等,不一定特別適用。
下面整理了一些簡(jiǎn)單常用能提高可測(cè)試性的方式。
- 影響可測(cè)試性的常見(jiàn)因素
外部依賴過(guò)多,需要mock
數(shù)據(jù)依賴鏈過(guò)長(zhǎng),導(dǎo)致構(gòu)造測(cè)試數(shù)據(jù)麻煩
分支邏輯過(guò)于復(fù)雜
全局變量/靜態(tài)變量
內(nèi)部lambda表達(dá)式過(guò)多
依賴的類對(duì)象不可構(gòu)造/難以構(gòu)造
函數(shù)功能過(guò)多
- 減少全局變量/靜態(tài)變量的使用
如果你的對(duì)象依賴了一些全局變量/靜態(tài)變量,而且這些全局變量會(huì)在多個(gè)測(cè)試case使用,這種情況是比較難測(cè)試的,你不得不在每個(gè)測(cè)試用例結(jié)束之后手動(dòng)重置全局變量。這樣不符合單測(cè)測(cè)試的獨(dú)立性原則,所以應(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) { //簡(jiǎn)單的參數(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) {
//只需要對(duì)新封裝函數(shù)進(jìn)行單元測(cè)試即可
// ... process
}
};
- 封裝分支邏輯
如果一個(gè)函數(shù)中分支太多,可以考慮將不同分支封裝成不同的函數(shù)處理,然后對(duì)封裝的函數(shù)分別編寫(xiě)單元測(cè)試用例。
- 合理使用MOCK工具
考慮在以下場(chǎng)景使用mock工具,可以減少你的單元測(cè)試成本
- 代碼中依賴的某個(gè)功能在你本次測(cè)試并不關(guān)心,如:db數(shù)據(jù)讀取,發(fā)請(qǐng)求
- 測(cè)試用例依賴一些復(fù)雜的數(shù)據(jù)源,如:db數(shù)據(jù)讀取,流水線上游數(shù)據(jù),網(wǎng)絡(luò)請(qǐng)求
- 一些非冪等性的函數(shù)調(diào)用或者結(jié)果返回不穩(wěn)定的函數(shù)調(diào)用,如:隨機(jī)數(shù)獲取,時(shí)間獲取,db寫(xiě)入
- 對(duì)象的某些狀態(tài)難以創(chuàng)建或者重現(xiàn),如:網(wǎng)絡(luò)錯(cuò)誤或者文件讀寫(xiě)錯(cuò)誤
- 驗(yàn)證一些中間過(guò)程值,如:你的函數(shù)沒(méi)有返回值,或者中間過(guò)程值不方便驗(yàn)證,可以mock中間某個(gè)函數(shù)調(diào)用來(lái)驗(yàn)證中間過(guò)程結(jié)果是否正確
- 嘗試測(cè)試驅(qū)動(dòng)開(kāi)發(fā)(TDD)
如果你的需求所要實(shí)現(xiàn)的功能相對(duì)明確,那么可以先把接口定義出來(lái),寫(xiě)一個(gè)最簡(jiǎn)單的實(shí)現(xiàn)運(yùn)行起來(lái),為其補(bǔ)充單元測(cè)試用例,然后再一步步完善具體實(shí)現(xiàn)細(xì)節(jié)。
如果不能先寫(xiě)測(cè)試用例也沒(méi)關(guān)系,重要的是在開(kāi)發(fā)中盡早編寫(xiě)測(cè)試測(cè)試,不要將它們延遲到最后,這樣可以及時(shí)重構(gòu)你的代碼。

? 常見(jiàn)誤區(qū)
- 只測(cè)試正常數(shù)據(jù)
應(yīng)當(dāng)盡量補(bǔ)充一些特殊值(如空值、邊界值)或異常數(shù)據(jù),以校驗(yàn)?zāi)繕?biāo)函數(shù)在不同的輸入是否符合預(yù)期,盡量覆蓋多的代碼分支邏輯。
- 結(jié)果校驗(yàn)不完整
如果你的目標(biāo)測(cè)試函數(shù)中對(duì)屬性進(jìn)行了修改,那么應(yīng)該盡可能校驗(yàn)這些修改是否符合預(yù)期,而不是單單只校驗(yàn)函數(shù)返回值。
- 輸入數(shù)據(jù)過(guò)于復(fù)雜
- 生成測(cè)試輸入數(shù)據(jù)的代碼應(yīng)當(dāng)避免與實(shí)際工程代碼耦合,如:讀取db或從流水線上游產(chǎn)生等
- 使用最小數(shù)據(jù)依賴的原則,只輸入對(duì)當(dāng)前測(cè)試用例會(huì)產(chǎn)生影響的數(shù)據(jù)即可。
- 如果數(shù)據(jù)源構(gòu)造過(guò)于復(fù)雜,可以將一個(gè)大的測(cè)試用例拆分成多個(gè)小的測(cè)試用例。
- 測(cè)試代碼存在分支條件
避免測(cè)試用例代碼中使用if、switch等分支邏輯,保持用例盡量簡(jiǎn)單,如果需要測(cè)試不同分支的代碼邏輯,應(yīng)該拆分成多個(gè)測(cè)試用例。
? 維護(hù)測(cè)試用例
- 重構(gòu)代碼時(shí),應(yīng)該同步修改測(cè)試用例
- 發(fā)現(xiàn)新增Bug時(shí),應(yīng)當(dāng)將能驗(yàn)證此Bug被修復(fù)的測(cè)試用例的補(bǔ)充到單元測(cè)試工程中
? 測(cè)試用例命名規(guī)則參考
TEST_F(TestUCPPipelineCenter, checkTaskInProcess_重復(fù)觸發(fā)_true);
測(cè)試宏 被測(cè)試類名, 被測(cè)試函數(shù)名_簡(jiǎn)單描述核心測(cè)試邏輯_要校驗(yàn)的結(jié)果值
小結(jié)
我們小組的單元測(cè)試工程已經(jīng)穩(wěn)定運(yùn)行了一段時(shí)間,代碼提交流程也逐步固化下來(lái)了,如下圖所示。后續(xù)我們會(huì)尋找一些指標(biāo)去量化衡量單元測(cè)試所帶來(lái)的收益。希望本文能幫助大家更加快捷地搭建C++單元測(cè)試環(huán)境,限于本人水平,如有不足或錯(cuò)誤之處歡迎批評(píng)指正。




























