寫測試程式時考慮的三件事

fcamel
fcamel的程式開發心得
8 min readDec 8, 2019

寫出好的測試是一門專業,需要時間累積經驗。依我自身的經驗來看,一開始發覺要隔離使用 I/O 的物件,才能方便測試。再來明白,設計介面時,就要考慮可測性。

於是,問題變成如何有系統地在設計之初考慮可測性?依據近來的實戰心得,覺得可以簡化成三個步驟。

  1. 條列需求,構思必要的測試案例。
  2. 設計 object dependency graph。
  3. 決定如何取代 system under test (SUT) 相依的物件。

構思測試案例

這點是最為困難也是最重要的一步。一開始往往覺得很難實作,是因為沒想清楚需求。有時重新定義問題後,甚至會發現根本不需作原定功能,可以用截然不同的方式解決問題。

從需求面出發,並且理解測試碼也有維護成本,就不會拘泥於該測 setter/getter 嗎?該測 private methods (輔助函式) 嗎?Test coverage 比例重要嗎?

強迫自己使用 TDD 可以加強這方面的練習,不過我不鼓勵制式地使用 TDD,而是將它當成練習方法,刻意練習有些成果後,可以視情況彈性使用。不過仍要留意測試真的有測到需求,維持 fail first 自然能顧到此點。

Object Dependency Graph

之前舉例的限流功能來說,原本的 object dependency graph 如下圖:

測試時需要準備 SUT (=ConnectionManager) 和相依物件 Connection。

可以引入 Throttler 讓實作限流功能的物件從 ConnectionManager 轉移到 Throttler。不管是 ConnectionManager 使用 Throttler:

還是 Connection 使用 Throttler:

測試時都只需準備 SUT (=Throttler),而沒有相依物件,簡化測試碼。

此外,Throttler 需要有時間資訊,若 Throttler 使用系統函式庫取得時間,無法有 deterministic 結果,也會拖慢測試速度。所以會希望透過 Clock 取得時間。

假設我們選用 ConnectionManager 使用 Throttler 的版本,以下一樣用 Object Dependency Graph 分析 Clock 的位置。

可以是 Throttler 使用 Clock ():

或是 ConnectionManager 使用 Clock:

前者是 Throttler 新增流量記錄時,自行從 Clock 取得現在時間; 後者是 ConnectionManager 要對 Throttler 新增流量記錄時,從 Clock 取得時間傳給 Throttler。

從實作功能來說,兩者沒什麼差異,有可能認為前者封裝的比較乾淨,只有需要 Clock 的 Throttler 使用 Clock,不要多經手 ConnectionManager 。但從測試角度來看,後者較佳,因為前者的 SUT (=Throttler) 相依於 Clock,而後者沒有相依物件。

順道一提,從測試的觀點使用 object dependency graph 分析,會發覺 Mediator Pattern 是最佳的結構:

出處: https://reactiveprogramming.io/books/design-patterns/en/catalog/mediator

可以讓 Mediator 實作多個不同介面,避免曝露共同資訊給不同擁有者。比方說 ComponentA 擁有實作介面ComponentADelegate 的物件,而不是直接擁有 Mediator。

原本可能是五個物件網狀互連導致很難測試,加入 Mediator 後變成五個物件的測試各別只需準備一個 Mediator 替代物件。只要盡可能地簡化Mediator 的邏輯,避免測試 Mediator 的需求 (改用 end-to-end test 直接包含),引入 Mediator 可提高可測性並簡化測試碼。

置換 SUT 相依的物件

分析 object dependency graph 會讓我們盡量簡化 SUT 相依的物件。再來要思考如何取代相依物件。這裡有兩個議題:

如何置換?

重點是所有含控制邏輯的物件不該自行生成其它有控制邏輯的物件,而要由外面傳進來。比方說由 constructor 傳入,或是提供 setter 傳入。以前面例子來說,若 ConnectionManager 自行生成 Clock,整合測試時無法使用 fake clock。但若 ConnectionManager 是 constructor 其中一個參數是 Clock,或是提供 SetClockForTest(),就能在測試時使用 fake clock。

這種模式稱為 Dependency Injection,使用上有些囉嗦,不同語言或不同工具有減輕痛苦的方法,像 Go 有 implicit interface,方便不少

Python 標準函式庫的 mock 相當強大,可以抽換任何物件或方法,以限流為例,不需置入 Clock,架構看起來比較清爽。不過有置入 Clock 還是有其它好處,像是測試可以平行跑。若直接使用標準函式庫的時間函式,等同於使用全域物件,就不能平行跑這些用到時間函式的測試了。

像這樣方便事後置換物件的功能有好有壞:

  • 好處是物件關係比較直接,不用改變 object dependency graph 或引入 dependency injection。換句話說,介面設計受可測性影響較低,相較於考慮可測性改變思維的設計,事後抽換的作法可能較易上手。
  • 壞處是測試和實作耦合度較高,測試維護成本較高。

用什麼東西置換?

  • Mock: 分成 action → assertion 和 record → replay 兩種類型,有不同的函式庫協助作 Mock。
  • Stub: 寫個罐頭回答預期用到的輸入,其它就回預設值。
  • Dummy: 什麼操作都是 NOP。比方說系統裡有用到 Cache,測試時用 Dummy 實作 Cache,不影響正確性。
  • Fake: 簡化的實作,等於寫了另一版的實作。比方說 key-value Store 不需要永久儲存時,用標準函式庫提供的 map 就能完成 in-memory key-value Store。

我個人最常用 Fake,因為可讀性高,容易重覆用在不同測試案例。缺點是擴充介面時要多寫 Fake 的實作,可能會有些重覆的工。再來會用 Stub / Dummy。

以前寫 Java / Python 時有用 Mock,當時是 record → replay 的用法,和實作細節綁太緊,寫起來也囉嗦。稍微改一下實作,會讓修改測試很痛苦,還沒抓到正確用法前,我就大幅減少使用頻率了。也許有些情況該用 action → assertion。

寫 Go 的這一年半還沒遇到想用 Mock 的情境。也可能是以前對 Mock 的不當經驗,導致我下意識先用 Fake 處理了吧。有更多經驗後再來分享。

順道一提 Python 標準函式庫的 mock 沒特別區分 Mock / Stub / Dummy / Fake,它的 API 很靈活,基本上是 action → assertion,但有個 side_effect 參數,可用來實作成 Fake。

結語

總結來說,最重要的是理解需求,從需求面條列測試案例。然後設計 object dependency graph。

物件大致分成三種:

  • Value objects: 只有簡單的邏輯,或是 setter/getter。若能維持不變性更好。
  • 控制邏輯物件: 實作應用程式邏輯的物件。
  • Builders: 用來生成一連串物件。組合不同物件應付不同情境: 像是產品和 end-to-end tests 全部使用真實物件,而 narrow integration tests 和 unit tests 使用部份真實物件。需要將這類生成物件集中管理。

訂好 object dependency graph 後,再來要思考使用 Mock / Stub / Dummy / Fake 何者替換測試裡的相依物件,還有如何實作和管理 builders。

相關文章

--

--