5.3 table-driven test
table-driven test 的核心規則是:同一個行為的多組案例放進表格,測試流程只寫一次。本章將說明如何設計案例欄位、命名子測試,並避免把太多不同行為塞進同一張表。
table-driven test 解決重複案例
table-driven test 的核心目標是把「案例資料」和「測試流程」分開。當同一個函式需要測多組輸入與預期結果時,表格能讓案例集中呈現,測試流程只保留一次。
1func NormalizeName(input string) string {
2 input = strings.TrimSpace(input)
3 return strings.ToLower(input)
4}這個函式有多個值得驗證的案例:一般字串、前後空白、已經是小寫、空字串。若每個案例都寫一個完整測試,程式會很快重複。
1func TestNormalizeName(t *testing.T) {
2 tests := []struct {
3 name string
4 input string
5 want string
6 }{
7 {name: "lowercase", input: "alice", want: "alice"},
8 {name: "trim spaces", input: " Alice ", want: "alice"},
9 {name: "empty", input: "", want: ""},
10 }
11
12 for _, tt := range tests {
13 t.Run(tt.name, func(t *testing.T) {
14 got := NormalizeName(tt.input)
15 if got != tt.want {
16 t.Fatalf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)
17 }
18 })
19 }
20}表格中的每一列是一個案例,迴圈中的程式是共同驗證流程。新增案例時,只要新增一列,不必複製整個測試函式。
案例欄位要對應行為
測試表格欄位的核心原則是只放描述案例所需的資料。常見欄位包括 name、輸入值、預期輸出與是否預期錯誤。
1tests := []struct {
2 name string
3 input string
4 want int
5 wantErr bool
6}{
7 {name: "valid", input: "8080", want: 8080},
8 {name: "not number", input: "abc", wantErr: true},
9 {name: "zero", input: "0", wantErr: true},
10}wantErr 表示這個案例預期出錯。它比把錯誤訊息塞進 want 更清楚,因為成功結果與錯誤結果是兩種不同觀察。
1for _, tt := range tests {
2 t.Run(tt.name, func(t *testing.T) {
3 got, err := ParsePort(tt.input)
4 if tt.wantErr {
5 if err == nil {
6 t.Fatalf("ParsePort(%q) error = nil, want error", tt.input)
7 }
8 return
9 }
10
11 if err != nil {
12 t.Fatalf("ParsePort(%q) error = %v", tt.input, err)
13 }
14
15 if got != tt.want {
16 t.Fatalf("ParsePort(%q) = %d, want %d", tt.input, got, tt.want)
17 }
18 })
19}錯誤案例先處理並 return,成功案例再繼續檢查輸出。這讓測試流程和函式行為一樣清楚:失敗時不應再比較正常結果。
t.Run 讓案例有名字
t.Run 的核心作用是建立子測試,讓每個案例在測試輸出中有獨立名稱。當某個案例失敗時,工程師可以直接看到是哪一列資料出問題。
1t.Run(tt.name, func(t *testing.T) {
2 // case assertion
3})案例名稱應該描述情境,而不是描述編號。"empty input"、"negative port"、"trim spaces" 比 "case 1" 更有定位價值。
1tests := []struct {
2 name string
3 input string
4 want string
5}{
6 {name: "trim spaces", input: " Alice ", want: "alice"},
7 {name: "preserve hyphen", input: "Mary-Jane", want: "mary-jane"},
8}當測試失敗時,名稱會出現在 go test 輸出中。好的案例名稱能讓讀者先理解失敗情境,再去看 got/want 差異。
表格應集中在單一行為
table-driven test 的邊界是「同一個測試流程是否能自然描述所有案例」。如果某些案例需要完全不同的準備、執行或驗證方式,通常應該拆成不同測試。
1func TestParsePort(t *testing.T) {
2 // 測 ParsePort 的輸入輸出規則
3}
4
5func TestLoadConfig(t *testing.T) {
6 // 測 LoadConfig 的檔案讀取與解析流程
7}把不同行為硬塞進同一張表,會讓欄位越來越多,最後出現大量只在少數案例使用的欄位。這種表格看起來少了重複,實際上讓讀者更難理解每個案例。
好的表格應該短而集中。若你需要在測試迴圈裡寫很多 if tt.someMode,這通常是拆分測試的訊號。
比較複雜資料時使用合適工具
比較結果的核心原則是選擇能清楚表達差異的方式。基本型別可以直接用 !=,slice、map、struct 則常用 reflect.DeepEqual 或專門的比較工具。
1func SplitCSV(input string) []string {
2 if input == "" {
3 return nil
4 }
5
6 parts := strings.Split(input, ",")
7 for i := range parts {
8 parts[i] = strings.TrimSpace(parts[i])
9 }
10
11 return parts
12}測試 slice 時,不能直接用 got != want。
1func TestSplitCSV(t *testing.T) {
2 tests := []struct {
3 name string
4 input string
5 want []string
6 }{
7 {name: "empty", input: "", want: nil},
8 {name: "two values", input: "a, b", want: []string{"a", "b"}},
9 }
10
11 for _, tt := range tests {
12 t.Run(tt.name, func(t *testing.T) {
13 got := SplitCSV(tt.input)
14 if !reflect.DeepEqual(got, tt.want) {
15 t.Fatalf("SplitCSV(%q) = %#v, want %#v", tt.input, got, tt.want)
16 }
17 })
18 }
19}reflect.DeepEqual 適合入門與標準庫範例。大型專案可能使用第三方比較工具產生更好的 diff,但核心原則不變:失敗訊息要讓差異容易看懂。
下一章
下一章會把測試方法套到 HTTP handler,說明如何不用啟動真實 server 也能驗證請求與回應。