🎯 什麼情境該想到我
當你「測試時想擺脫真實外部服務、要它穩定又可重現」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:測試時用一個可控的假服務,取代真實的外部服務依賴。
做法要點:
- 為外部服務定義介面,正式用真實實作,測試用回傳固定結果的樁。
- 靠設定或注入(常搭配外掛/分離介面)在測試時切換到樁。
- 樁行為可預期,讓測試快速、穩定、可重現,也能模擬錯誤情境。
★★ 四種測試替身,用錯了測試就沒有意義
| 做什麼 | ★ 適用 | |
|---|---|---|
| Dummy | 只為了填參數,永不被呼叫 | 佔位 |
| ★ Stub(樁) | ★ 回傳預先安排的答案 | ★★ 最常用:「當對方回 X 時我的邏輯對不對」 |
| ★★ Fake | ★ 一個「真的能用」的簡化實作(記憶體版的 repo) | ★ 需要狀態的測試 |
| ★ Mock | ★ 驗證互動(「有沒有被呼叫、用什麼參數」) | ★★ 只在「呼叫本身就是效果」時(寄信、發訊息) |
★★ 最常見的錯誤是用 Mock 做 Stub 的事:
斷言「repo.Get被呼叫了一次」——★ 那測的是實作細節,
重構(改成一次批次查詢)會讓測試紅燈,而行為完全正確。
★ 判準:你在測「結果」還是「過程」? 結果 → Stub/Fake;過程 → Mock(★ 而那該很少)。
★★ Go 的做法:手寫 fake,通常勝過產生的 mock
// ★ 消費方定義的小介面(見 [[分離介面]])
package checkout
type charger interface {
Charge(context.Context, Money, CardToken) (ChargeID, error)
}
// ★★ 手寫 stub —— 十行,而且它「說得清楚自己在模擬什麼情境」
type stubCharger struct {
id ChargeID
err error
}
func (s stubCharger) Charge(context.Context, Money, CardToken) (ChargeID, error) {
return s.id, s.err
}
// ★ 測試讀起來就是一份「情境清單」
func TestCheckout(t *testing.T) {
cases := []struct{
name string
charger charger
wantErr error
}{
{"卡片被拒", stubCharger{err: ErrCardDeclined}, ErrCardDeclined},
{"金流限流", stubCharger{err: ErrTemporary}, ErrTemporary},
{"成功", stubCharger{id: "ch_1"}, nil},
}
…
}★★ 為什麼手寫勝過 gomock/mockery:
① ★★ 介面小(1–3 個方法)時,手寫只有十行,而產生的 mock 有幾百行
② ★ 手寫的 fake 可以「有行為」(一個 map 當儲存),產生的 mock 只能安排回傳值
③ ★★ 產生的 mock 誘使你寫「驗證互動」的測試 —— ★ 那正是最脆弱的測試
④ ★ 少一個 codegen 步驟與一個依賴
★ 例外:介面有十幾個方法時,手寫太痛——★★ 但那時真正的問題是介面太大
(見 工具-Go介面小而隱式)。
★★ Fake 的關鍵限制,以及必須配的測試
★★ Fake 只驗證「你假設的行為」。三個它抓不到的東西:
① ★ 外部 API 的真實錯誤形狀(★ 你以為 404 回 JSON,實際回 HTML)
② ★★ 序列化細節(欄位名、日期格式、大小限制)
③ ★ 外部 API 改版
→ ★★ 必須配其中一種:
a. ★ 契約測試:對真實沙箱跑一組測試(★ 在 CI 的 nightly,不是每次 PR)
b. ★★ 錄製/重播(VCR):★ 錄一次真實回應存成 testdata,之後重播
→ ★ 比 fake 真實、比沙箱穩定;★★ 但錄的內容會過時,要定期重錄
c. ★ 對真實服務的 smoke test(部署後跑一次)
★★ b 是實務上最划算的:它把「真實的回應形狀」變成版控裡的 testdata,
★ 而 diff 會顯示「對方的回應變了」。
★★ 記憶體 fake 的常見錯誤:它比真的實作寬鬆
// ★★ 一個記憶體 repo 的 fake
type fakeRepo struct{ m map[ID]*Order }
func (f *fakeRepo) Save(_ context.Context, _ Querier, o *Order) error {
f.m[o.ID()] = o // ★★ 沒有樂觀鎖檢查、沒有唯一約束、沒有外鍵
return nil
}
// → ★ 於是:① 樂觀鎖的邏輯永遠不會被測到
// ② ★★ 「重複的 email」在測試裡通過,在生產環境炸★ 三個對策:
① ★★ 讓 fake 也實作關鍵約束(版本檢查、唯一索引)—— ★ 值得,因為那些正是你要測的
② ★★ 用真的資料庫(testcontainers/docker-compose)測 repo 層
→ ★ Go 的 repo 測試通常應該連真的 Postgres,★ 而不是 fake
③ ★ fake 只用在「不碰資料庫的那一層」(服務層的業務邏輯)
★★ ②是 Go 社群的主流做法:★ 資料庫互動用真的資料庫測,
而業務邏輯用 fake 測——兩者各測自己該測的。
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
樁只是替身,別讓它累積過多邏輯而偏離真實服務行為。
-
仍需適量的整合測試驗證與真實服務的接合。
-
前提是外部依賴已被介面隔離(如透過閘道)。
-
★★ 不要用 Mock 做 Stub 的事。 斷言「
repo.Get被呼叫一次」測的是實作細節,重構成批次查詢會讓測試紅燈而行為完全正確。 -
★★ 手寫 fake 通常勝過產生的 mock:介面小時只有十行、可以有真實行為、而且不會誘使你寫驗證互動的脆弱測試。
-
★★ Fake 只驗證「你假設的行為」,抓不到外部 API 的真實錯誤形狀、序列化細節、與改版。必須配契約測試或錄製重播。
-
★★ 記憶體 fake 比真的實作寬鬆:沒有樂觀鎖、沒有唯一約束 → 「重複 email」在測試通過、在生產炸。
-
★★ Go 的 repo 層測試應該連真的資料庫(testcontainers),業務邏輯才用 fake。
-
★ 錄製/重播(VCR)是最划算的做法:把真實回應形狀變成版控裡的 testdata,diff 會顯示對方變了。
-
★ 介面有十幾個方法導致手寫太痛 → 真正的問題是介面太大。
-
契約測試放 nightly 而非每次 PR,否則外部沙箱的不穩定會讓 CI 常紅。
🔗 相關工具
- 外掛 Plugin(用設定在測試時換上服務樁)
- 閘道 Gateway(先把外部存取包成介面,才好替換成樁)
- 企業應用架構模式
- 閘道 —— 服務樁是它的測試替身
- 特例 —— 生產用的 Null Object
- 分離介面 —— 讓替換成為可能
- 外掛 —— 執行期選擇實作
- 工具-特徵測試 —— 用真實行為當基準
- 工具-打破依賴以便測試 —— 怎麼讓程式碼可測
- 工具-Go介面小而隱式 —— 介面該多小