🎯 什麼情境該想到我

當你看到 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")

原文的理由,三個都很實在:

  1. 這類函式內部通常呼叫 t.Fatalf,讓測試提早死掉,其他問題看不到。
  2. 它看不到測試函式裡的上下文,產出的訊息反而更少。
  3. 每個團隊一套 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 裡。

🔗 相關工具