🎯 什麼情境該想到我
當你看到 CI 上一行 FAIL,卻得點進測試原始碼才知道到底哪裡不對的時候。
⚙️ 怎麼用(步驟 / 公式)
0. 整章只有一個標準
不看測試原始碼,就要能診斷失敗。
失敗訊息要回答四件事:什麼造成失敗、什麼輸入造成的、實際得到什麼、原本期望什麼。 下面全部都是從這一句推出來的。
1. 固定格式:YourFunc(%v) = %v, want %v
// Bad
t.Errorf("got %v, want %v", got, want)
// Good
t.Errorf("Compare(%q, %q) = %v, want %v", test.a, test.b, got, test.want)三個要素:
- 報出函式名,即使測試函式名看起來已經很明顯(同一個測試可能呼叫它好幾次)。
- 報出輸入,只要輸入夠短。太大或太不透明就給測試案例取個描述性的名字,把描述印進訊息裡。
- got 在 want 前,而且用 “got”/“want” 這兩個字,不要用 “actual”/“expected”。
2. ⭐ 用 t.Error 讓測試跑完,t.Fatal 只在「繼續下去沒意義」時用
測試應該盡量跑到底,一次把所有失敗都印出來——否則修 bug 的人得修一個跑一次。
// Good: 設定失敗 → Fatal;各項比對 → Error,全部都印出來
gotMean, gotVariance, err := MyDistribution(input)
if err != nil {
t.Fatalf("MyDistribution(%v) returned unexpected error: %v", input, err)
}
if diff := cmp.Diff(wantMean, gotMean); diff != "" {
t.Errorf("MyDistribution(%v) mean diff (-want +got):\n%s", input, diff)
}
if diff := cmp.Diff(wantVariance, gotVariance); diff != "" {
t.Errorf("MyDistribution(%v) variance diff (-want +got):\n%s", input, diff)
}t.Fatal 的正當時機是後續失敗會沒有意義、甚至誤導:
gotEncoded := Encode(input)
if gotEncoded != wantEncoded {
t.Fatalf("Encode(%q) = %q, want %q", input, gotEncoded, wantEncoded)
// 編碼就錯了,再去解碼它毫無意義
}表格測試裡的規則:
- 沒用
t.Run子測試 → 用t.Error後面接continue,跳到下一列。 - 有用
t.Run子測試 → 用t.Fatal,它只結束這個子測試,其他列照跑。 - ⚠️ 不要在另一個 goroutine 裡呼叫
t.Fatal(它靠runtime.Goexit,在別的 goroutine 裡不會終止測試)。
測試輔助函式(setup/cleanup 那種)裡失敗,該用 t.Fatalf 並先呼叫 t.Helper(),訊息要講清楚是 setup 失敗:
func mustAddGameAssets(t *testing.T, dir string) {
t.Helper()
if err := os.WriteFile(path.Join(dir, "pak0.pak"), pak0, 0644); err != nil {
t.Fatalf("Setup failed: could not write pak0 asset: %v", err)
}
}3. 結構比對用 cmp,不要手寫逐欄位比
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("AddPost() returned unexpected diff (-want +got):\n%s", diff)
}cmp.Equal判等、cmp.Diff產生人看得懂的差異。- 一定要在訊息裡標明方向:傳
(want, got)就寫(-want +got),傳(got, want)就寫(-got +want)——因為-/+會對應到 diff 每行開頭的符號。既有程式碼在這件事上很不一致,所以必須顯式寫出來。 - diff 前面要先換行,因為它跨多行。
- protobuf 要加
protocmp.Transform()。
為什麼不用別的:
reflect.DeepEqual不該用來判等——它對未匯出欄位與實作細節敏感。pretty會把「視覺上一樣」當成相等,分不出 nil slice 與空 slice,且預設比較未匯出欄位。- ⚠️
cmp是為測試設計的,不要用在 production:它懷疑你比對方式不對時會 panic。
回傳多個值時,不用先包成 struct 再比,分別比對並分別印就好。
4. ⭐ 不要自製 assertion library
// Bad
assert.IsNotNil(t, "obj", obj)
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")原文的理由,三個都很實在:
- 這類函式內部通常呼叫
t.Fatalf,讓測試提早死掉,其他問題看不到。 - 它看不到測試函式裡的上下文,產出的訊息反而更少。
- 每個團隊一套 assertion 庫 → 大規模重構的人要面對 N 種輸出格式。
要共用比對邏輯,回傳值或 error,讓測試自己組訊息:
// Good
func postLength(p BlogPost) int { return len(p.Body) }
if got, want := postLength(post), 60; got != want {
t.Errorf("Length of post = %v, want %v", got, want)
}(順帶一提:如果 postLength 不只這麼簡單,它本身就該有自己的測試。)
5. 不要讓測試變成 change detector
- 不要用字串比對判斷錯誤種類。錯誤訊息被改寫,測試就壞——但行為根本沒變。要判斷就用
errors.Is,或cmp加cmpopts.EquateErrors。- 只在乎「有沒有錯」時,連
cmp都不用,wantErr用bool就好:err := f(test.input) if gotErr := err != nil; gotErr != test.wantErr { t.Errorf("f(%q) = %v, want error presence = %v", test.input, err, test.wantErr) } - 用字串比對來檢查「錯誤訊息包含某個參數名」這種性質,是可以的。
- 只在乎「有沒有錯」時,連
- 不要比對不是你擁有的套件的輸出穩定性。
json.Marshal的輸出 byte 變過;比對 JSON 字串相等的測試會因為標準庫的改動而壞。改成 parse 回來比對語義結構。
6. 詳細程度要分情況
- 複雜互動要講清楚是第幾次呼叫失敗、以及當時的系統狀態。
- 大 struct 可以只描述重點,但不要模糊到看不出資料。
- Setup 失敗不需要同等詳細:
t.Fatalf("Setup: Failed to set up test database: %s", err)通常就夠了。 - 印字串用
%q(一眼看出空白與怪字元),印小 struct 用%+v,大的用 diff。
實用招數:開發時故意讓它失敗一次,看看訊息長什麼樣、維護者能不能靠它解決問題。
🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
cmp不在標準函式庫(github.com/google/go-cmp),但由 Go team 維護。專案不想加依賴時,至少守住「格式固定、got 在 want 前、報函式名與輸入」這幾條。- 「不要 assertion library」不等於「不要 test helper」。 做 setup/cleanup 的 helper 是鼓勵的;被禁的是把「驗證」跟「產生失敗訊息」綁在一起的東西。
- 資料含不相關欄位、會蓋掉測試意圖時,才不做完整結構比對;這時用
cmpopts.IgnoreFields/IgnoreInterfaces而不是退回逐欄位手寫。 - Go 1.14 起有
t.Cleanup,比defer更適合寫在 helper 裡。
🔗 相關工具
- 工具-Go表格測試與子測試 —— 同一章的另一半:測試怎麼組織;那張的失敗訊息規則就是這張
- 工具-Go錯誤該包還是該轉 —— 「不要靠錯誤字串判斷種類」的另一面:所以錯誤要有結構
- 工具-特徵測試 —— 對照:特徵測試刻意要鎖住現況,change detector 在那裡反而是目的