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 也能驗證請求與回應。