《Software Engineering at Google》ch 13 — Test Doubles

一個沒那麼肥的肥宅
今天的天空,有點藍
22 min readSep 16, 2022

前言:對於軟體工程的興趣隨著職涯的年齡與日俱增,恰好前陣子發現 Google 出了關於軟體工程的經驗談。《Software Engineering at Google》這一系列的文章是想分享我閱讀《Software Engineering at Google》這本書的筆記。透過汲取更多前人的經驗,來讓自己對於軟體工程方面的 scalability 能夠更有感觸。希望可以給自己的學習歷程留下一些什麼,也希望對想了解這方面知識的人有一些幫助。

在軟體開發中,單元測試是相當重要的工具。儘管對於簡單的程式碼撰寫單元測試可以說是非常容易,但是隨著程式碼越來越複雜,要將單元測試寫得好卻也會逐變得困難。舉例來說,當你嘗試想要發送一個 request 到一個外部的 server 然後將其回應儲存在 database 中,寫一個測試還算可行,但當你寫了數百個這樣的測試時,可能會花費你數個小時才能跑完,更糟的是,可能還會由於網路不穩定等地關係造成測試結果不穩定。

在這種情形下,就是 test double 派上用場的時候了。Test double ( 測試替身 ) 指的是在測試中能代替函式或物件 real implementation 的替身,就如同電影中特殊場景需要使用的替身相似。這些 test doubles 可以是相對於 real implementation 來說較為簡單的實作,也可以用來驗證系統中特定行為。

前面的章節有提到小型測試的概念,以及為什麼測試套件中大部分是由其組成,然而,產品程式碼中通常不會都能符合小型測試對於跨程序或是機器的限制,這個時候就需要 test doubles 來協助讓小型測試能夠跑得夠快且穩定了。

Test double 在軟體開發中的影響

Test double 的使用會給軟體開發增加一些複雜度,因此有一些層面需要考量。

可測性

為了要能夠使用 test doubles,codebase 必須要被設計的較為可測—需要被設計的讓測試能夠用 test doubles 來替換 real implementation。如果撰寫程式碼時沒有將可測性考慮在其中,會導致程式碼本身不夠有彈性,而當之後要加入測試時,可能會需要非常多的力氣重構程式碼,以支援 test doubles 的使用。

應用性

適當地使用 test doubles 非常重要,倘若使用不慎,可能會讓測試變得脆弱易碎、複雜、無效。在巨大的 codebase 中,這樣的缺點會被放大,因此需要謹慎對待。

Fidelity ( 保真度 )

Fidelity 指的是 test double 與 real implementation 的相似程度有多少,如果差太多的話,那這樣的 test double 可能沒有辦法提供什麼價值。不過,完美的 fidelity 不可能存在,不然就沒有必要使用 test double 而是使用 real implementation 就好了。Test double 通常要比 real implementation 要簡單得多,來讓它能夠適合出現在單元測試中。

Google 中的 test doubles

在 Google,有無數個 test doubles 所帶來對於生產力以及軟體品質提升的經驗,但同樣地也有看過許多濫用 test doubles 所造成的負面影響。隨著經驗逐漸累積,Google 逐漸摸索出一些如何有效率使用 test doubles 的指導方針。

過度使用 mocking 框架尤其需要特別注意 ( mocking 框架可以讓工程師非常容易地造出 test doubles )。一開始當 mocking 框架被 Google 所使用的時候,它看起來就像萬靈丹,讓工程師不太需要在乎程式碼的 dependencies 就能輕易寫出集中且專一的測試,但是直到過了許多年才發現許多的問題開始浮現:要維護他們需要花很多時間與心力,卻不太能夠找出 bugs。因此現在許多 Google 的工程師開始避免使用 mocking 框架,為了要寫出更為真實的測試。

基本觀念

Test double 的一個範例

想像一個電子商務的網站處理信用卡付款,可能會有類似範例 13–1 的程式碼。

Example 13–1. A credit card service

如果要在測試中使用真正的信用卡服務是不切實際的,而這個時候就是可以使用 test double 的時機了,來模擬真實系統的行為,如範例 13–2。

Example 13–2. A trivial test double

儘管這樣的 test double 看起來沒什麼用,但其實使用他仍然能允許工程師來測試某些在 makePayment 方法裡面的邏輯,像是在範例 13–3 裡面就能夠驗證該方法在信用卡已經到期時,其行為表現是否正常。

Example 13–3. Using the test double

Seams ( 接縫 )

當程式碼是 testable,指的是該程式碼被寫成容易被單元測試的。Seams ( 接縫 ) 是其中一種讓程式碼變得 testable 的方式,透過讓 SUT ( System Under Test ) 可以使用與在產品程式碼中不同的 dependencies。

Dependency injection ( 依賴注入 ) 是一個常常被使用來引入接縫的技巧。簡言之,當一個類別使用 dependency injection 時,任何一個該類別所使用的 dependencies 都是被傳進去,而不是在類別內部建立,這讓這些 dependencies 可以在測試時被替換掉。

範例 13–4 是一個 dependency injection 的例子。相比於在 constructor 裡面建造一個 CreditCardService 的物件,這裡直接將物件作為參數傳入。

Example 13–4. Dependency injection

如此一來,在真正的產品程式碼可以傳入能夠與外部服務溝通的 CreditCardService,而在測試的時候就可以傳入 test double 作為替代。

Example 13–5. Passing in a test double

撰寫可測的程式碼,需要的是前期的投資,特別是對於 codebase 來說更是如此。因為當可測性越晚被放入考慮時,那麼 codebase 就越具有可測性。如果程式碼在被撰寫的時候沒有考慮到測試,那麼如果之後想要增加適合的測試,就通常需要被重構甚至重寫。

Mocking 框架

一個 mocking 框架指的是能夠在測試中讓建立 test double 變得容易的軟體工具,使用 mocking 框架可以減少工程師的心力,因為不需要特地為了測試所需要的 test double 額外再定義新的類別。範例 13–6 舉了一個這樣的例子。

Example 13–6. Mocking frameworks

許多主流的程式語言都有 mocking 框架,在 Google 裡面,Java 選用了 Mockito,C++ 選用了 Googlemock,而 Python 選用了unittest.mock。不過需要注意的是,濫用 mocking 框架將可能導致程式碼庫變得更難維護,以下將介紹一些這樣的問題。

使用 test doubles 的技巧

常用的 test doubles 有三種,而這個章節將會簡單介紹這三種的差異。

Faking

Fake 指的是對於某個 API 較為輕量級的實作,其行為表現會與 real implementation 相似,但卻不會在真正的產品中使用。範例 13–7 是一個 fake 的例子。

Example 13–7. A simple fake

使用 fake 通常會是在 test doubles 中最理想的技巧,但是 fakes 可能不一定會在想要寫測試的時候存在,而且撰寫一個 fake 是有一點挑戰的,因為必須要確保其行為與 real implementation 具有相似性。

Stubbing

Stubbing 指的是給予函式一個指定的行為,通常會指定函式回傳一個固定的回傳值,在範例 13–8 即是一個這樣的例子。Stubbing 通常是透過 mocking 框架所做到,以減少工程師花時間 hardcode 程式碼。

Example 13–8. Stubbing

Interaction Testing

Interaction testing 是一種在沒有真正呼叫到真時函式時,所使用的關於函式是如何被呼叫的。舉例來說,某個測試應該要在某個函式沒有被呼叫、被呼叫太多次,或是被呼叫時代有錯誤的參數。範例 13–9 示範了一個 interaction testing 的例子。

Example 13–9. Interaction testing

與 stubbing 類似,interaction testing 也可以透過 mocking 框架來做到。此外,interaction testing 又叫做 mocking,不過為了避免讀者混淆,在這章節中都會使用 interaction testing 來描述。

Real Implementations

儘管 test double 是相當有價值的測試工具,但 Google 對於測試的優先選擇還是使用 real implementation,也就是跟產品程式碼中所使用的相同。測試在使用與產品程式碼相同的東西時,具有較高的 fidelity,而使用 real implementation 剛好可以達成這點。Google 發現,過度使用 mocking 框架通常會有較高的傾向會讓測試受到與 real implementation 不相同的汙染,進而導致難以重構。

在測試時偏好使用 real implementation,稱為 classical testing;而偏好使用 mocking 框架的稱為 mockist testing。儘管有不少人是 mockist testing 流派,但 Google 認為 mockist testing 較難以被擴展。

偏好真實多過於隔離

在測試中使用 real implementations 可以讓 SUT 表現更為真實,相反地,使用 test doubles 取代 dependencies 的話,可以隔離 SUT 與 dependencies 之間的關係。我們傾向使用越真實的測試,因為這樣的測試可以給予我們更多的信心來相信 SUT 確實正常運作;如果單元測試過於依賴 test doubles,那麼工程師可能還需要跑整合測試或是手動驗證那些元件是否如預期地運作,以能夠提供同等的信心。做這些額外的工作還會降低開發的速度,而且當工程師過於忙碌時,極有可能沒有時間驗證這些事情而導致忽略 bugs 的出現。

如果 real implementations 裡面有 bugs 的話,那麼在測試中使用 real implementations 可能會導致測試失敗。這當然是好的現象!我們會希望測試會在程式碼不如預期地正常運作時,能夠告訴我們。

該如何決定什麼時候使用 real implementation

如果 real implementations 可以執行起來快速、穩定、並且只有簡單的 dependencies 時,那麼這樣的話絕對是較好的選項。然而,對於更複雜的程式碼來說,使用 real implementation 可能沒那麼好執行,這個時候要從下面該考慮的層面中做出取捨。

執行時間

單元測試中最重要的事情之一,莫過於快速。你應該會希望在開發的時候也能夠經常執行這些單元測試以得到即時的回饋,這個時候如果 real implementations 很慢的話,那麼 test double 就會變得非常有用。

那麼要單元測試要跑多久才算是慢呢 ? 目前沒有辦法給出一個精確的答案,因為這與工程師是否感受到開發速度有下降,以及有多少個測試使用 real implementations 有關。一般而言,通常使用 real implementation 會是個不錯的選項,一直到某個時間點大家開始覺得測試跑得越來越慢的時候,就可以用 test doubles 來替代了。

Determinism ( 定性 )

如果在版本固定的 SUT 中,測試跑完得出的結果總是相同,那代表這個測試是有定性的 deterministic,反之則稱為 nondeterministic

Nondeterminism 在測試中會導致 flakiness,也就是測試偶爾會隨機性的失敗,即便 SUT 沒有任何的更動。這種現象會讓工程師開始不願意相信測試的結果,進而忽略那些跑失敗的測試。如果 real implementations 的因素讓 flakiness 常常常發生,那或許就該是用 test doubles 的時機了。這種情況容易出現在 real implementations 使用多執行緒而依賴不同執行緒執行的順序,或是 real implementations 需要依賴系統的時鐘的時候。

建構 Dependency

當使用 real implementation,會需要建立所有的 dependencies,而 test doubles 通常沒有 dependencies,因此建立 test doubles 會比建立 real implementation 容易。

在最極端的例子中,可能會有類似下面這段:

而使用 Mockito 使用起來可能會類似這樣 :

不過,儘管使用 test doubles 會比較簡單,但使用 real implementation 還是有較多的好處,將在這章節後面介紹到。相對於在測試中手動建立這些物件,理想的解決之道是使用與產品程式碼相同建立物件的方式來做,如工廠方法或是自動化的 dependency injection。

Faking

當使用 real implementation 是不可行的時候,最好的選項就是使用 fake。Fake 比其他 test doubles 好的原因是其與真正實作更為相似,這可以讓 SUT 難以分辨其互動的對象是 real implementation 還是 fake 物件。

Example 13–11. A fake file system

為什麼 fakes 重要

Fakes 是個相當強力的工具,因為他們通常可以執行得相當快速,同時不但不會有使用 real implementations 的缺點,還能夠允許工程師有效地測試程式碼。單單一個 fake 就能夠大幅的增進測試的效率,那麼若能夠廣泛地運用為數眾多的 fakes,它所能提供給軟體組織的巨大效益是不可言喻的。相反地,如果一個組織裏面使用很少的 fakes,那麼要嘛是使用 real implementation 而掙扎於緩慢且不穩的測試,不然就是使用其他的 test doubles 技巧而可能間接地讓測試不那麼有效且易碎。

什麼時候需要撰寫 fakes

Fake 需要花不少的心力來撰寫,且需要有該領域的知識,同時也要考量到維護性,倘若 real implementations 行為改變時,fake 也會因此需要改變。所以,最好是負責管理 real implementations 的團隊來負責撰寫並維護 fake。

如果團隊正在考慮是否該撰寫 fake,那麼該思考的是使用 fake 所帶來的好處是否能超過撰寫並維護該 fake。如果使用者不多的話,這恐怕不值得做;但若有數百個使用者,那麼這將會對產品的開發速度有莫大的提升。

Fakes 的 fidelity

考量到 fake 最重要的一點,應該就是 fidelity 了,也就是其行為與 real implementations 有多相似。

有時候,追求完美的 fidelity 是不切實際的。畢竟,fake 需要存在也是因為real implementations 在某些方面是不適合的。舉例來說,一個 fake 的資料庫在硬碟儲存方面就與真正的資料庫不相同,因為 fake 的資料庫通常是將資料儲存在記憶體裡面。

Fakes 應該要被測試

Fakes 本身當然應該要被測試,才能確保它真的遵守 real implementations 所訂的 API。沒有被測試的 fakes 在一開始或許確實相符,但隨著時間經過,這樣的行為可能會逐漸與 real implementation 不同。而在對 fakes 寫測試時,最好是根據 API 的公開介面來撰寫 ( 又被叫做 contract tests )。

如果無法取得 fake,那該怎麼做

如果無法取得相對應的 fake 的時候,可以先去詢問 API 的擁有者能否建立一個。如果該擁有者不願意或是沒有辦法建立 fake 的話,你可以自己撰寫你自己的一份。在 Google,有些團隊甚至會將他們所寫的 fakes 提供給 API 的擁有者,來讓其他團隊也能受益。

關於使用 fake 的時機,我們可以把它想像成一個需要權衡的取捨:如果使用 real implementation 的測試太緩慢了,我們可以建立 fake 來取代他們;但如果 fake 跑起來只能提升團隊有限的開發速度,而若這樣的效益沒有超過那些需要建立以及維護 fake 的工作,那麼採用 real implementation 還是較好的選擇。

Stubbing

如前面所述,stubbing 指的是 hardcode 函式的行為。範例 13–12 即是一個使用 stubbing 去模擬信用卡服務的回應。

Example 13–12. Using stubbing to simulate responses

濫用 Stubbing 的危險

由於 stubbing 太容易使用了,因此如果沒有那麼容易使用 real implementations 時,這個選擇就相當吸引人,但這並不是一個好的現象。

Stubbing 會需要額外撰寫程式碼來定義該被 stubbed 函式的行為,這會讓測試的目的被分心掉,因而使得測試不夠清楚,而且對於不熟悉 SUT 實作細節的工程師來說,程式碼也會變得不容易理解;此外,stubbing 暴露了實作細節,因此當實作細節要被改變時,你就必須要更新相對應的測試,這讓測試變得脆弱易碎,因為理想上,一個好的測試只有在與使用者接觸的 API 行為發生改變時,才需要改變;最後,我們並沒有任何方法來確保被 stubbed 的函式是否表現起來真的如同 real implementations 一樣,如下面寫死 add() 的行為,沒辦法得到驗證,這會讓使得測試變得不夠有效。

一個過度使用 stubbing 的範例

範例 13–13 是一個過度使用 stubbing 的例子,而範例 13–14 在沒有使用 stubbing的情況下重寫了該測試,可以注意到測試變得更短而且實作的細節並沒有被暴露在測試當中。

Example 13–13. Overuse of stubbing
Example 13–14. Refactoring a test to avoid stubbing

很明顯地我們不想要讓這樣的測試去連接外部的信用卡服務,所以一個 fake 的信用卡服務會是更恰當的。

什麼時候該使用 stubbing

相對於 real implementations 是完完全全的替換,當你只需要某個函式回傳特定值來讓 SUT 處於特定狀態,就非常適合 stubbing。由於函式的行為只會在測試中被定義,因此 stubbing 可以模擬各種的回傳值,甚至是 real implementations 或是 fake 不容易模擬產生的錯誤。但需要注意的是,每一個測試中通常不應該有太多的 stubbing,因為過多的 stubbing 可能會造成測是變得不清楚。一個測試中有過多的 stubbed 函式也可能暗示該 SUT 太過複雜而需要重構。

此外,儘管有些情境相當適合 stubbing,real implementations 或是 fakes 仍然是優先選擇,因為他們不會暴露出實作細節,同時也更能確保程式碼確實正常運作。

Interaction Testing

如同前面所述,interaction testing 是一種在不用呼叫到真正的函式本身,而做到能夠驗證函式是如何被呼叫的。Mocking 框架讓這件事情變得相當容易做到,然而,根據經驗顯示,如果要讓測試具有可讀性、有效性以及有能力應對變更的話,最好在必要的時候才使用這項技巧。

優先選擇 state testing 相對於 interaction testing

相對於 interaction testing,測試的時候更傾向用 state testing。在 state testing 中,開發者會呼叫 SUT 並且驗證回傳值或是系統的狀態是否有如預期地被改變,如範例 13–15。

Example 13–15. State testing

比較之下,範例 13–16 是一個用 interaction testing 的方式所撰寫的測試。注意到,其實根本就無法知道該測是根本無法知道數字到底有沒有被排序成功,因為 test doubles 並不知道如何排序數字,唯一能知道的是 SUT 的確有嘗試著想要排序。在 Google 我們認為 state testing 較具有可擴性,能夠減少測試的易碎性,使其更容易改動並且容易維護。

Example 13–16. Interaction testing

Interaction testing 最大的問題在於,它只能驗證特定的函式是否被如期的呼叫,而不能檢查系統 SUT 到底是否順利運作。舉例來說,當呼叫 database.save(item) 的話,應該可以預期該 item 有被存入資料庫中,而 state testing 可以驗證這種假設,但 interaction testing 不行。

另一個 interaction testing 的缺點在於,它會使用到 SUT 的實作細節。在測試中使用暴露出產品程式碼的實作細節,會讓測試變得較為脆弱易碎。在 Google 中甚至有些人開玩笑地說,過度使用 interaction testing 就是一種 change-detector tests,因為這樣的測試禁不起任何一點產品程式碼的改動。

什麼時候適用 interaction testing

儘管其缺點,但有些情境確實適用 interaction testing。

  • 沒辦法使用 real implementation 與 fake 等等來做到 state testing 的時候。這種情況下,雖然使用 interaction testing 不是非常理想,但多少能夠提供一點保護作用。
  • 當呼叫函式時,次數或是順序不同將會造成超出預期的行為的時候。這時 interaction testing 就非常管用,因為 state testing 不容易驗證這種情形。像是當你為了減少呼叫 database 的次數,而對系統增加一個快取的功能,就可以透過這種方式來驗證。

Interaction testing 不必然是 state testing 的補集。如果在單元測試中沒有辦法使用 state testing,那麼其實可以考慮在測試套件中增加較大範圍的測試來達成 state testing。大範圍的測試是個降低風險的重要策略,將在下一章節中介紹。

Interaction testing 的最佳實踐

盡量對 state-changing 的函式進行 interaction testing。

  • State-changing 函式,如:sendEmail(), saveRecord(), logAccess()
  • Non-state-changing 函式,如:getUser(), findResults(), readFile()

通常對於 non-state-changing 函式進行 interaction testing 較為多餘且讓測試變得脆弱,因為這樣的 interaction 不會產生 side effect,因此不會是需要強調正確性的重點細節。從範例 13–17 可以看出兩者的差異。

Example 13–17. State-changing and non-state-changing interactions

避免 overspecification

前一章節有提到,測試一個函式的時候應該要專注在驗證某個行為,而不應該要在一個測試中驗證多種行為。同樣地,在 interaction testing 中仍然是類似的道理,避免過度指定函式以及參數,這樣可以讓測試變得較為清楚且較能承受改變。範例 13–18 演示了一個 overspecification 的例子,這個測試想要驗證的是使用者的名稱是否被包含在打招呼的服務中,但該測試會在不相關的行為發生改變時一樣無法通過。

Example 13–18. Overspecified interaction tests

範例 13–19 則是更進一步將相關的參數考慮得更加仔細,該被測試的行為被分散成不同的測試項目,而每一個測試只驗證非常小範圍的必要程度。

Example 13–19. Well-specified interaction tests

結論

在這章節中,我們看到了 test doubles 對於工程是多麼重要的存在,他們能夠協助工程師測試程式碼,並確保測試跑得足夠快且穩定;然而,錯誤的使用這項技巧同樣也會造成開發上的瓶頸,讓測試變得不夠清楚、脆弱以及無效。因此,了解如何有效地使用 test doubles 對於工程師來說至關重要。使用 test doubles 的時機以及該使用哪項技巧,仰賴著工程師明智的判斷與取捨,才能發揮最大效益。

儘管 test doubles 是個解決 dependencies 的厲害手段,但是如果工程師想要對程式碼產生更多的信心,那麼遲早還是要將這些 dependencies 納入測試之中,這將會在下一章節中有進一步的討論。

--

--