~~SetUp()~~
哎彭油,Google Test(又叫那個 gtest)瞭解一下嘛。谷歌開源的 C++ 測試框架,用的人在谷歌外面多得很呢。你 C++ 程式會寫?那你十有八九把 Google Test 都用得飛起了哦。
20 年前,我到谷歌的時候,行業裡面好的 C++ 測試框架都找不著,公司的專案又像哈密瓜的籽一把一把多得數不完。我就跟老闆說:老闆!不如我把一個新的測試框架寫出來給大家用?老闆沒有反對。再後來,Google Test 就有了嘛。今年(2025 年)6 月,這個專案就足足 20 歲了,我的青春小鳥一去不回來了啊!

饢烤得久了總會有糊的,軟體寫多了總會有錯的。程式設計師嘛,要是不想兩次都踩一個坑,就只有把自己的作品經常拿出來拷打拷打,總結自己少不經事時的幼稚。這樣就進步了嘛。不然呢,就只是變老了嘛。
寫 Google Test 的時候我啥都不懂,摸著石頭過河把自己的腳砸了。有些傷治好了,有些治不好留了個疤。今天就讓大家瞧一瞧這些疤,開心開心一起進步。
我的痛可以分兩大類:
一是陽關大道建好了,獨木小橋還沒拆。有的彭油到了河邊,稀裡糊塗就上了搖搖欲墜的木頭橋,急死個人了嘛。
二是系統預設會幹傻事兒,彭油們不費點子勁都掰不回來。
先說第一類。
大多數彭油都不喜歡做選擇題。
今天穿啥子衣裳上班?晚飯是吃宮保雞丁還是蒙古牛肉?P 站 B 站上那麼多那麼多電影先看哪一部?關稅公式裡面加哪兩個希臘字母?
要是你跟我一樣,每天做這些決定煩都煩死了!
工具要足夠傻瓜。要是使用者想用我們的工具解決一個問題,最好只提供一種合理的解法,最好其它解法一看就不靠譜。
比如把一把劍交給使用者,正常人都不會有“我應該握哪頭”的疑問。這說明大寶劍的設計成功了!
問題是 gtest 不是一錘子設計出來的。前前後後好幾年,陸陸續續有新功能。有了新的好的嘛,按理說舊的差的就沒啥理由存在了。
可是,世界上的事情往往不能按理說。你試試看刪除一箇舊功能。一大堆平時不顯山不露水的使用者就會立馬跳出來:不能刪!我還用著呢!
為了不跟使用者的熊熊怒火正面硬剛,有的時候我就從心了。

結果嘛,就是使用者只看見一堆功能,有些事情吧,好像用這個也行,用那個也行。到底該用哪個?哎呦我的頭疼病犯了。
再說第二類。
懶惰是程式設計師的美德。正因為想偷懶,他們才搞出了自動化。不然,computer 這個詞今天的意思還是“打算盤的人”。
既然這個行業懶人多,我們設計工具的時候就要考慮到對懶人友好。最好拿來就能用,最好不需要改配置。
gtest 的行為有一些是不太合理的,我也想改。但是有的東西一改就會影響到老使用者。他們惹不起,那就不改了吧。使用者想要更合理的行為?只能主動改配置。
這樣,老使用者舒服了,新使用者麻煩了。
接下來,我掰開了講一講。
~~匹配器好,斷言謂詞不好~~
一千個彭油有一千個測試需求。一個好的測試框架一定要允許使用者自己上手擴充。
Google Test 允許使用者擴充套件他們的測試詞彙表。
最初,我的做法是讓使用者定義自己的謂詞斷言(Predicate Assertions):使用者可以寫一個布林函式,用來判定它的引數是否滿足某個特定條件。然後,他們可以在 EXPECT_PREDn() 這個宏裡面使用這個布林函式來判斷一組資料是否滿足這個條件。如果不滿足,Google Test 會自動打印出所有引數,方便使用者除錯。
我們來看一個具體的例子。假定使用者想測試兩個整數是不是互質,一種做法是先寫一個 MutuallyPrime(m, n) 函式來做這個判斷:
// 如果 m 和 n 除了 1 以外沒有公約數,返回 true。
boolMutuallyPrime(int m, int n) { ... }
然後,就可以配合 EXPECT_PRED2() 使用這個函數了:
EXPECT_PRED2(MutuallyPrime, 2, 3); // 成功
EXPECT_PRED2(MutuallyPrime, 8, 6); // 失敗
EXPECT_PRED2(MutuallyPrime, GetFoo(), GetBar());
要是上面最後一行測試失敗了,系統會列印:
Expected: MutuallyPrime(GetFoo(), GetBar()) istrue
Actual: false, where
GetFoo() is 42
GetBar() is 48
問題來了:到底是 GetFoo() 算錯了,還是 GetBar() 算錯了,或者兩個都錯了?
除非這段程式碼是我自己昨天才寫的,我也不清楚啊!
所以說,這個謂詞斷言功能雖然能讓使用者擴充他們的測試詞彙,但是沒有讓測試程式碼清楚表現出使用者的意圖。維護測試麻煩了!
春去春又回……
一年後,我在設計 Google Mock(Google Test 的一個模組)。在調研時,我從 jMock(一個 Java mocking 框架)那裡學到了匹配器的概念。茅塞頓開了呀!相見恨晚了啊!
啥子是匹配器(matcher)?跟謂詞一樣,它們可以判斷一條資料是不是符合測試要求。除此之外,它們還有一些額外的功能:
-
每個匹配器都會描述自己是幹啥的。
-
如果匹配一條資料失敗,它們還會解釋為啥失敗了。
-
最後,我們還可以把幾個小匹配器組合成一個大匹配器,完成更高階的測試任務。更妙的是,這個大匹配器可以藉助小匹配器的幫助,描述清楚自己的功能並且解釋清楚為啥某條資料不能匹配。
比如,Lt(5) 是一個簡單的匹配器,它的自我描述是“小於5”。
要是想驗證 GetFoo() 的結果小於5,就可以寫:
EXPECT_THAT(GetFoo(), Lt(5));
要是想驗證一個數組裡面每個數都小於5,就可以寫:
EXPECT_THAT(SomeArray(), Each(Lt(5)));
要是驗證失敗,你會得到一條有用的訊息:
期望:SomeArray() 每個元素都小於5
現實:{1, 3, 2, 6, 7}, 3號元素是6(不小於5)
這條訊息裡面,“每個元素都”是 Each() 提供的,“小於5”是 Lt(5) 提供的,“3號元素是6”是 Each() 提供的,“不小於5”是 Lt(5) 提供的。這些小匹配器彼此配合,不但完成了驗證工作,還一起生成了清楚的報錯資訊,對使用者幫助很大。
匹配器嘛,比謂詞好得多得多。
而且,使用者可以按自己的需求定義新的匹配器,科技樹不會被框架鎖死。
我馬上就在 Google Mock 中實現了匹配器。
要是用匹配器風格改寫上面這個 MutuallyPrime 的例子,看起來是這個樣子的:
// 判定被測試的資料是否和 rhs 互質。
MATCHER_P(IsMutuallyPrimeWith, rhs, "") { ... }
...
EXPECT_THAT(GetFoo(), IsMutuallyPrimeWith(GetBar()));
讓我們把注意力放在最後一行。很明顯 GetFoo() 是被測試的物件,而“is mutually prime with GetBar()”(與 GetBar() 互質)是我們期望測試物件有的屬性。這樣的程式碼讀起來就像自然語言,清楚地表達出了作者的意圖。
所以,儘管匹配器最初是為 mocking 這一特定需求設計的,但它們可以完美取代謂詞斷言。
謂詞斷言嘆曰:既生瑜,何生亮!
要是能去掉 Google Test 的謂詞斷言功能該多麼美好啊!
我悔之晚矣。只能建議使用者彭油們:
-
不要再寫新的謂詞斷言了!從今天起,面朝大海,改成使用匹配器。
-
寫測試斷言時,儘量用 EXPECT_THAT,避免其它那些花裡胡哨的斷言宏,比如 EXPECT_LT, EXPECT_PRED2 之流。你只需要 EXPECT_THAT。
~~建構函式好,SetUp()不好~~
Google Test 遵循的是 xUnit 的設計思想:要是一組測試案例在邏輯上是相關的,你就可以定義一個測試夾具(test fixture class)來安放它們的共享邏輯。(彭油,測試夾具跟上大刑的關係是莫有的,害怕就不要了。)
每次 gtest 要跑一個測試案例之前,都會建立一個新的夾具供這個案例使用。不同的案例不會共享同一個夾具,因為它們的狀態要分開的嘛,不然就互相下絆子了嘛。
在跑測試案例的邏輯之前,gtest 會自動先初始化這個夾具。等案例邏輯跑完了,還會自動把夾具清理乾淨,爛攤子不留給系統。
在 gtest 裡面,使用者可以把夾具的初始化邏輯寫在一個叫 SetUp() 的函數里面。類似地,清理夾具的邏輯要寫在 TearDown() 函數里面。
以上是我的原始設計。
後來我發覺自己傻叉了,SetUp() 和 TearDown() 沒有必要的嘛。反正 gtest 在建立夾具物件的時候會呼叫它的建構函式,用完了又會呼叫它的解構函式。我們把初始化邏輯放在構造函數里面,把清理邏輯放在析構函數里面,不好嗎?
相比之下,建構函式和解構函式還有幾個優勢:
-
要是我們在構造函數里初始化一個成員變數,有機會把它定義成 const。大家知道,const 變數最耿直,說一不二,生下來是幾,到死還是幾,海枯石爛心不變。這樣寫的測試,好懂好改好 debug,丈母孃看了好歡喜。
-
有時候我們要從一個夾具類派生出一個子類。經常學 C++ 的彭油都知道,子類的建構函式/解構函式總是會自動呼叫父類的建構函式/解構函式,編譯器都幫你做好了,不用管,根本不用管。但是!要是把邏輯放到 SetUp() 和 TearDown() 裡面,寫子類的彭油就一定要記得在子類的 SetUp() 和 TearDown() 裡面呼叫父類的 SetUp() 和 TearDown()。哎呀,麻煩。
所以說,老萬寫 SetUp() —-多此 one set-up。
現在嘛,把 SetUp() 和 TearDown() 去掉有點晚了,太多人都在用它們了嘛。
我的建議是:不要再用 SetUp() 和 TearDown() 了。甚至可以在專案的 CI 裡面新增一個靜態檢查,看到有人在測試程式碼裡定義 SetUp() 和 TearDown() 就報警,看他還敢不敢!
~~4132好,1234不好~~
有沒有想過,要是你定義了測試案例 1,2,3,4,系統應該按什麼順序跑它們?
啊?難道不是 1,2,3,4 嗎?
確實,這麼幹實現容易、好理解,也不會讓人意外。
但是,這麼幹也讓一個測試案例很容易依賴於它前面的那些測試案例。
比如,案例 1 建立了一個檔案,案例 2 二話不說就可以把這個檔案拿來就用。
這種依賴性很糟糕—-現在嘛,要理解測試案例 2 的邏輯,你還需要了解案例 1 幹了些啥。
所有的測試案例都應該獨立—-這樣,要是哪個案例失敗了,我們可以單獨除錯它,不用每次把它前面的案例再跑一遍。要是我們需要刪除一個案例,其它案例也不應該因此崩潰。
gtest 一開始是按固定順序跑案例的。後來,為了幫助彭油們養成寫獨立測試案例的好習慣,我添加了一個 –gtest_shuffle 開關,讓 Google Test 按隨機順序來跑。基本上,開啟這個開關後,測試案例之間的隱藏依賴就很難了。
當然,gtest 的預設行為還是按固定順序跑,因為改成隨機跑很多使用者的測試就通不過了嘛。
不過,我強烈建議你在跑 Google Test 測試的時候開啟 –gtest_shuffle。這麼說吧,所有新專案一開始就該這麼設定。聽我的沒錯。
~~有測試案例好,沒有測試案例不好~~
你知不知道一種感覺叫做荒涼,
在無垠的時間的曠野上,
聽到自己心跳的聲音?
你試沒試過測試程式裡一個案例都不放,
讓 Google Test 白跑一趟,
還以為自己程式碼沒毛病?
要是找不到測試案例,Google Test 會高高興興告訴你太平無事。畢竟,一個失敗案例都木有哇!
這……好像哪裡有點不對勁?
沒有測試案例,恐怕是使用者不小心搞錯了。做為一個剛正不阿的測試框架,gtest 理應讓測試掛掉,不然使用者哪來機會反省。
你可能會想:我才不會犯這種錯誤!其實,出這種錯比很多人想象的更容易。
有的大聰明彭油在指令碼中用萬用字元(比如“foo_*_test.cpp”)來匹配測試原始檔。萬用字元有可能寫錯(比如檔名是 foo-abc-test.cpp,不是 foo_abc_test.cpp),導致啥都匹配不到。結果,測試程式裡不就一個案例都沒有了嗎!
還有一種情況,有的彭油在編譯測試程式時把連結器的引數搞錯了,明明寫了一堆案例,最後一個都沒有用上。
痛定思痛,今年(2025 年)2 月,我給 Google Test 加了一個 –gtest_fail_if_no_test_linked 開關。開啟後,gtest 終於會在找不到測試案例時報錯了!
建議彭油們馬上升級到最新的 gtest,然後在跑測試時把這個開關開啟。
~~TearDown()~~
彭油們,你覺得 Google Test 還有哪些坑?請把答案打在留言區。
~~~~~~~~~~
老萬近期文章:
~~~~~~~~~~
關注老萬故事會公眾號:
碼字不易,嘔心瀝血只是希望更多人看到。如果喜歡這篇文章,請不吝三連。謝謝!🙏