《Software Engineering at Google》ch 12 — Unit Testing

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

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

前一章節中提到了 Google 將測試用兩個維度劃分,這章節將著重在單元測試。除了避免 bugs,測試更重要的目的是提升工程師的生產力。而單元測試的特性使得其成為提升生產力的絕佳方式:單元測試通常不大,因此可以執行得快速且穩定,允許通程師獲得快速的回饋;單元測試通常很容易撰寫,不需要工程師花過多的時間在設定環境以及了解大型系統;單元測試可以提供測試覆蓋率;單元測試因為較為專注且簡單,因此發生錯誤時可以讓工程師較容易掌握問題;單元測試可以做為文件以及範例,指導工程師該如何使用該系統。

由於這些好處,大部分 Google 的測試都是單元測試。根據經驗,Google 鼓勵測試是由 80% 的單元測試以及 20% 其他更大範圍的測試所組成。對於一個工程師來說,很容易一天之內會跑過數千個單元測試,也是這個原因,Google 花了相當多的時間,專注在測試的維護性上面,而這個章節也將會介紹關於如何達成該目標。

維護性的重要

想像這樣的情境:瑪莉想要增加對目前的產品增加新的 feature,而且由於足夠簡單,非常快就能夠實作完成。但當她嘗試將程式碼簽入時,她從螢幕上得到了一連串自動測試失敗的錯誤。於是她花了數個小時去尋找這些問題所在,但卻發現這些錯誤都來自於對於程式碼內部的假設,並不是她所引入的 bugs,此外,由於測試不夠明確,她必須花相當多時間在了解那些測試到底測的內容是什麼。最後,原本明明很快可以解決的工作內容卻得花數天才能完成,也降低了她的生產力。

測試原本想做到的是提升生產力,但沒想到卻造成了反效果。這種情境太常見了,而 Google 的工程師也同樣需要面對這樣的問題。事實上,關於這個問題並沒有銀彈,Google 的工程師嘗試找出一些較好的實務模式以減輕這類的問題。

瑪莉遇到的問題並不是她的問題,而且她其實不太能做什麼,因為她所遇到的問題是測試的易碎性以及測試不夠清楚。

避免易碎的測試

易碎的測試指的是沒有引入任何 bugs 的程式碼改動,卻造成某些不相關的測試壞掉了。在小團隊以及小的程式碼倉庫中,一些些這樣的錯誤不是什麼大問題,然而當一個團隊不斷地寫出易碎的測試時,測試的可維護性就會不斷降低。尤其是在 Google 這種規模的公司中,一個工程師很容易地會在每天的工作中跑了數以千計的測試,即使只有一小部分是易碎的測試,也將會耗費非常多的工程時間。

追求不用改動的測試

在談論如何避免易碎的測試之前,我們先討論一個問題:究竟我們期待一個測試完成之後應該要多常被改變?任何花費在更新舊有測試的時間都代表能花在更有價值的工作上的時間減少了,因此最理想的狀況就是測試不要改變:當測試被寫下來之後,除非系統的需求改變了,否則應該永遠不需要改變。

那麼在實務上,這會是什麼樣的情景呢?對於產品程式碼的改動,我們可以將其分為四類:

  • 單純的重構 — 當一個工程師在進行不改變系統外在行為的重構時,系統的測試不應該被改變。如果需要改變測試,可能是這個行為不是單純的重構,或是測試並沒有被寫在適當的抽象層中。
  • 新的 features — 當一個工程師想要對於既有的系統增加新的 feature 時,系統既有的行為應該要保持不變。工程師可以寫新的測試來覆蓋新的行為,但舊有的測試不應該被改變。
  • 修正 bugs — 修正 bugs 與新增 features 相當類似。Bugs 的出現是因為原本的測試少考慮了某些狀況,而修正 bugs 應該要包含少考慮的狀況,但同樣不該改到原本的測試。
  • 行為變更 — 改變系統的既定行為是唯一一個我們期望去改變測試的情形。注意到這樣的情形通常影響層面較大,因為使用者原本可能依賴於系統中的某些行為,而改變這樣的行為代表系統打破了原有的合約。

分析之後可以發現,只有第四種才是合理能夠讓測試失敗的原因,而這也是系統能夠規模化的理由:因為當你改變某些程式碼時,儘管有數以千計的測試,但應該只有一小部分的測試需要修正才對。

對於公開的 API 進行測試

呼叫公開的 API 而不是實作細節,可以說是最重要的事情之一。參考範例 12–1 的例子,所做的是驗證一個 transaction 並將其儲存到 database 中。

Example 12–1. A transaction API

如果要對這段程式碼做測試,其中一個相當直覺的作法是將 private 移除,並且對其實作測試,如下:

Example 12–2. A naive test of a transaction API’s implementation

不過這種與 transaction processor 的互動方式顯然不是真實使用者與 transaction processor 的互動方式,過度窺視系統的內部狀態並且呼叫那些非公開的函式將會使得測試變得不穩定,甚至讓重構變得十分痛苦。相反地,範例 12–3 則是示範了只對公開函式的測試。這種測試較為真實且穩定,只對公開的函式進行測試的意義代表當重構這種系統內部發生改變時,你也不需要去擔心測試是否會容易失敗。

Example 12–3. Testing the public API

在 Google,工程師有時候需要被提醒說對於公開的 API 進行測試而不是測試時做細節,這多少會讓工程師感到不情願,這是可以理解的,畢竟專注在測試剛剛所寫的程式碼遠比該系統如何被剛剛所寫的程式碼影響還要來的容易得多。然而,持續實踐這個原則是有價值的,因為如此長期下來,將能夠減少程式碼維護的負擔。

測試狀態,而非互動

一般而言,要驗證 SUT 是否如預期執行分為兩種。一種是狀態測試,在這種情形中,你觀察的是系統在呼叫某個函式前後是否有發生變化;另一種則為互動測試,指的是檢查系統是否有按照某種順序與其他相關的物件進行互動。通常互動測試比起狀態測試更為脆弱,理由與對私有函數測試較對公有函數測試更為脆弱是一樣的:互動測試檢查的是系統是如何滿足結果 ( how ),然而通常我們在乎的其實是到底結果是什麼 ( what )。範例 12–4 是一個互動測試的範例。

Example 12–4. A brittle interaction test

這樣的測試存在著一些缺陷:若 SUT 存有某種 bug 導致當記錄被寫入之後就被刪除,那麼這個測試就仍然會通過,這不符合我們預期;此外,如果 SUT 被重構成表面不同但實際意義相同的 API,這個測試就會失敗,也不如我們預期。因此,範例 12–5 是一個相對較好的狀態測試。

Example 12–5. Testing against state

常見有問題的互動測試源自於過度依賴 mocking 框架,這樣的框架使得創建測試替身過於簡單,進而經常在測試中驗證這些假物件,而非使用真實物件。因此,我們傾向只要真實物件足夠快速且穩定,就盡量使用這些真實物件。

寫出清楚明瞭的測試

測試失敗通常可以歸因成兩種原因:系統真的發生問題了、測試本身隱藏著缺陷。當工程師遇到測試失敗時,第一件事情就是找出失敗的原因並診斷,而診斷的速度取決於測試是否足夠清楚。測試的清楚程度會隨著時間的經過逐漸顯得越發重要,尤其時當多年以後撰寫該測試的工程師已經不在原本的崗位時,那麼不夠清楚的測試將會讓讀者無法理解其存在的目的。因此為了要讓整個測試套件能夠擁有可擴性,並且隨著時間的經過仍然發揮效用,那麼讓測試清楚清楚就是相當重要的事情。

讓測試保持完整且簡潔

測試應該要保持完整 ( complete ),使其內容足夠清楚的讓讀者知道其重點是什麼;測試也該保持簡潔 ( concise ),避免有不必要的或不相關的資訊。下面是一個不完整也不簡潔的例子:

Example 12–6. An incomplete and cluttered test

這個例子明顯看得出來有許多不必要的資訊在建構式裡面,而真正重要的資訊卻被隱藏住了。較好的範例如下:

Example 12–7. A complete, concise test

測試行為,而非函式

許多工程師一開始總會直覺上將每一個他們所寫的去對應上他們所寫的每一個產品程式碼的函式,但隨著時間經過,當產品程式碼越來越複雜,也就相對應造成測試越來越複雜且難以理解。範例 12–8 與範例 12–9 就演示了這樣的例子。

Example 12–8. A transaction snippet
Example 12–9. A method-driven test

這個問題的成因正是因為關注在測試函式本身,久而久之就會導致測試本身日益複雜且難以繼續撰寫下去。相對於為了每一個函式去寫一個測試,我們可以考慮的是為每一個行為去寫一個測試。因為行為代表的是系統在某個特定狀態下、針對特定的輸入所產生的回應,在撰寫測試時,可以用 givenwhenthen 來描述,如

Given that a bank account is empty, when attempting to withdraw money from it, then the transaction is rejected.

如果銀行帳戶裡面沒有存款,此時若想要對該帳戶提款,那麼這樣的交易會被拒絕。範例 12–10 是一個前面例子的改寫。

Example 12–10. A behavior-driven test

這種行為驅動的測試有幾個好處:他們唸起來很像是自然語言,因此很容易被理解;因為這樣的測試被限制為固定格式,所以能夠更清楚地表達成因以及效果;此外,測試因此變得簡短且有容易描述,因此工程師更容易知道系統中已經保證了什麼樣的功能,也會鼓勵工程師繼續加上新的測試,而不是只有在既有的測試中繼續硬塞東西。

為了強調行為,結構化的測試也是相當重要的一環。範例 12–11 即是一例。

Example 12–11. A well-structured test

當遇到測試需要驗證一個較多步驟的過程時,分離 when 與 then 的區塊是可以被接受的:

Example 12–12. Alternating when/then blocks within a test

不過撰寫這種測試要小心,注意不要在一個測試裡面驗證多種行為,因此大絕大多數的測試都應該是只有一個 when 與 then 的區塊。

關於測試名稱

方法導向的測試通常是根據方法是否有被測試來命名,如 updateBalance 方法的測試名稱可能是 testUpdateBalance。而行為驅動的測試在名稱方面則較為彈性且能夠傳達更多有用的資訊,這樣的測項名稱應該要包含對於系統的呼叫動作本身以及所期待的結果。範例 12–13 就是在使用 Jasmine 框架下的測試範例。

Example 12–13. Some sample nested naming patterns

其他的程式語言則是需要工程師將這些資訊表述在測試名稱之內。

Example 12–14. Some sample method naming patterns

當在命名測試時,一個值得推薦的小技巧是,用 should 來思考。舉例來說,一個 BankAccount 類別的測項可能是

shouldNotAllowWithdrawalsWhenBalanceIsEmpty

藉由閱讀該測項名稱,我們就能夠對於系統有更多具體的了解,而這樣的命名方式也讓測試本身能夠專注在一項行為上:如果在命名時用到了 and,那麼你可能要小心注意是否一次測試了多個行為。

請不要在函式裡面放入邏輯

複雜度 ( Complexity ) 通常根源於某種形式的邏輯,在程式語言中,邏輯往往是透過操作子、迴圈、以及控制流程來表現。當一段程式碼裡面包含邏輯時,往往需要讀者更多的心力精神去理解。舉例來說,下面的範例 12–15 是否正確呢?

Example 12–15. Logic concealing a bug

這個例子中並沒有太多的邏輯,只不過就是字串連接而已。但如果把這僅有的一點點邏輯簡化,就馬上能夠看出該例的問題了。

Example 12–16. A test without logic reveals the bug

這樣的經驗提供了工程師一些想法:在測試中,專注於撰寫直接的程式碼,而不是看起來很聰明的程式碼。

在測試中討論到共享程式碼,DAMP 比 DRY 更好

大部分軟體工程師所遵守的 DRY — Don’t Repeat Yourself,指的是當撰寫程式碼遇到相同的概念時,盡量減少複製相同的程式碼,這樣可以讓程式碼更容易維護,尤其是在修改程式碼時,只需要改少少的地方即可,相對應的,其缺點是可能會需要讀者跟著引用的連鎖不斷地搜尋。這個小缺點在產品程式碼是可以被接受的,但當考慮到測試程式碼時,情境就大不相同了。好的測試是足夠穩定的,而當系統真的改變某些行為時,開發者就會希望這些測試真的能夠跑失敗,進而發揮作用。

因此,當談及測試時,更傾向是用 DAMPDescriptive And Meaningful Phrases 原則。只要程式碼的複製能讓測試看起來簡單且清楚,那麼一些些的複製是可以被接受的。範例 12–19 是個太 DRY 的例子,測試主體太過精簡卻不構完整清楚,重要的細節被藏在輔助函式中,導致讀者需要來回滾動頁面取查找輔助函式的內容。而範例 12–20 是根據 DAMP 原則來改寫。

Example 12–19. A test that is too DRY
Example 12–20. Tests should be DAMP

共享值

範例 12–21 是一個共享變數的測試例子。儘管這些測試有發揮作用,但讀者仍然需要來回滾動頁面來知道 ACCOUNT_1 與 ACCOUNT_2 的內涵。即使改成用 CLOSED_ACCOUNT 或是 ACCOUNT_WITH_LOW_BALANCE 多少會提升一些可讀性,但仍然不足。較佳的方式是透過輔助函式來強調測項內容值得注意的細節。這在有支援 named parameters 的語言可以輕鬆達成,而對於沒有支援的語言,則可以採用 Builder pattern 來做到。

Example 12–21. Shared values with ambiguous names
Example 12–22. Shared values using helper methods

共享 setup

在共享程式碼中,許多的測試框架都會允許工程師在測試跑之前,可以進行某些前置作業的設定。當遇到測試所需要的物件或是其相依性時,往往是這種 setup 發揮功效的最佳場景。不過相對應地,使用 setup 的風險是,若測試會需要依賴於否些在 setup 中設定的特定值,那麼可能會產生不清楚的測試,像是在範例 12–23,讀者就需要到處翻找 Donald Knuth 出現在哪裡。

Example 12–23. Dependencies on values in setup methods

像這種需要依賴於特定值得測試,應該要在測試中再次覆寫其值,如範例 12–24。

Example 12–24. Overriding values in setup methods

共享輔助函式與驗證

最後一個關於在測試之間共享程式碼的是,在測試主體裡面呼叫輔助函式。不過應用的時候要注意,尤其是在驗證的時候。最極端的例子是,在每個測試的最後面都呼叫一個 validate 函式,該函式內部卻都是固定一系列的檢查。這樣的測試,問題點在於很有可能不是根據行為驅動為主的驗證。此外,在眾多測試之中若都採用這種方式,那麼讀者會較難搞懂撰寫測試者當時撰寫該測項的真正意圖為何,且當發生問題時,會同時造成大量的測試不通過,因此也較難被定位。因此需要注意,當在驗證階段,較好的驗證函式是專注而不發散的、驗證單一一個概念為主,如範例 12–25。

Example 12–25. A conceptually simple test

定義測試基礎設施

大部分工程師所使用的測試的基礎設施來自於第三方的支援,如 JUnit。目前的測試框架多的不勝枚舉,因此在組織內最好盡早選定統一的框架。舉例來說,Google 在多年以前就已經選定 Mockito 作為 Java 的 mocking 框架,且同時禁止使用其他的 mocking 框架的新的測試。

結論

單元測試是軟體工程師在確保系統在經歷不預期的變動時保持穩定的最強力的武器。但水能載舟亦能覆舟,沒有細心照料的單元測試會需要工程師花更多心力來維護卻不能提升工程師對於系統的信心。Google 內部的單元測試離完美還相當遙遠,但這章節所列出的注意事項是能夠讓單元測試變得更有價值的經驗談。

--

--