🎯 什麼情境該想到我

當你的程式「很難寫測試」(依賴一堆、要 mock 一大票),或想讓設計更清楚時。難測,往往是設計不良的訊號。

⚙️ 怎麼用

  1. 先寫測試 = 先從「使用者角度」設計介面:你被迫先想「這東西怎麼被呼叫最順」,介面自然變好用。
  2. 難測就是設計警報:如果要測某段得建構一堆相依 → 代表耦合太緊,該解耦(見 工具-打破依賴以便測試、工具-針對介面編程)。
  3. 讓測試逼出低耦合:依賴用注入、對介面編程,程式自然可測、可替換。
  4. 測試即活文件:讀測試就知道這段程式該有什麼行為。

★ 「難測」是哪一種設計問題:對照表

測試寫起來痛苦時,痛的位置會指出設計問題的種類:

測試時的痛對應的設計問題改法
要 mock 5 個以上的依賴這個單元職責太多拆成幾個各自依賴較少的單元
想測私有函式私有函式裡藏了另一個概念把它提煉成獨立的型別或套件,變成公開的東西來測
準備資料要 50 行物件建構太複雜或依賴太深建構函式只收真正需要的東西;用 builder 或測試用的工廠函式
結果取決於現在時間、亂數隱藏的依賴(全域的時鐘、亂數來源)把時鐘、亂數當參數注入
要真的連 DB 才能測業務規則業務邏輯和 I/O 糾纏把純邏輯拉出來成純函式,I/O 留在外層(「功能核心、命令外殼」)
斷言要檢查內部欄位行為沒有可觀察的輸出讓函式回傳結果,而不是只改內部狀態

用法:遇到難測的程式碼,不要先找更強大的 mock 工具——先查這張表,看是不是該改設計。

★ Go 讓「可測性」自然出現的三個手法

1. 在使用端定義小介面

// 在 billing 套件(使用端)定義,只放用到的方法
type invoiceStore interface {
	OpenInvoices(ctx context.Context, month time.Time) ([]Invoice, error)
}
 
type Closer struct{ store invoiceStore }

生產環境傳進真的 *postgres.Repo(它有 30 個方法,但隱式地滿足了這個 1 個方法的介面);測試傳一個 5 行的假實作。不需要 mock 框架。詳見 工具-Go介面小而隱式。

2. 時間當參數注入

type Clock func() time.Time
 
func IsExpired(now Clock, exp time.Time) bool { return now().After(exp) }
// 測試:IsExpired(func() time.Time { return fixed }, exp)

3. 需要真的等待時間的併發程式碼:testing/synctest(Go 1.25+)

synctest.Test 在一個隔離的「泡泡」裡執行測試,泡泡裡的 time 套件使用假時鐘,只有在泡泡內所有 goroutine 都阻塞時才前進(依 Go 1.26.4 套件文件,2026-09-25)。所以一個 time.Sleep(2 * time.Second) 的測試會立即完成,而且結果是決定性的——不用為了可測性把程式碼改寫成注入時鐘的形狀。

★ 兩個學派:該 mock 到什麼程度

古典學派(Detroit/Chicago)Mock 學派(London)
測試單位一個行為,可以跨好幾個真實物件一個類別,所有協作者都 mock
替身用在哪只替換慢的或不可控的(DB、網路、時鐘)所有協作者
優點重構時測試不會壞設計介面時強迫你想清楚協作關係
缺點失敗時比較難定位是哪個物件測試綁住實作細節,重構時一堆測試跟著壞

Go 社群多數偏向古典學派:用真實的物件,只在系統邊界(DB、HTTP、時間)換成假實作。如果你每次重構都在改測試,可能是 mock 得太多了(見 工具-小步重構「測試不該跟著改」)。

🧪 我實際套用的紀錄

  • 2026-07-15:(待填)

⚠️ 注意

  • 目標是「設計驅動」,不是「為了 100% 覆蓋率而測」;測試是設計的副產品與保護網。

  • 為了可測性而加的介面,只有一個實作時要自問:這個介面是在「系統邊界」(值得)還是在「兩個內部 struct 之間」(常常只是為了 mock,徒增間接層)?

🔗 相關工具