Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例,但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。

本章目標

學完本章後,你將能夠:

  1. 判斷什麼行為適合 table-driven test
  2. 設計欄位少、意圖清楚的測試表
  3. 發現 table test 膨脹成迷你框架的訊號
  4. 拆分 validation、repository error、integration flow
  5. 寫出能定位失敗情境的子測試名稱

【觀察】table-driven test 很容易被濫用

Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、WebSocket client、expected log、expected event,測試就會變成難懂的迷你框架。

失控表格示意:

 1tests := []struct {
 2    name          string
 3    method        string
 4    body          string
 5    setupRepo     bool
 6    setupClient   bool
 7    queueFull     bool
 8    wantStatus    int
 9    wantMessage   string
10    wantEvent     bool
11    wantLog       bool
12}{
13    // many unrelated cases
14}

這種表格看似統一,實際上混合了 HTTP validation、repository error、client queue full、event emission、log assertion。讀者必須同時理解多個系統層,才能看懂單一案例。

【判讀】好表格描述同一個行為維度

好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料,不改變測試流程。

 1func TestNormalizeTopic(t *testing.T) {
 2    tests := []struct {
 3        name  string
 4        input string
 5        want  string
 6    }{
 7        {name: "trim spaces", input: " alerts ", want: "alerts"},
 8        {name: "lowercase", input: "ALERTS", want: "alerts"},
 9        {name: "empty", input: "", want: ""},
10    }
11
12    for _, tt := range tests {
13        t.Run(tt.name, func(t *testing.T) {
14            got := NormalizeTopic(tt.input)
15            if got != tt.want {
16                t.Fatalf("NormalizeTopic(%q) = %q, want %q", tt.input, got, tt.want)
17            }
18        })
19    }
20}

這張表只測 topic normalization。每個案例都只有 input 和 want,失敗時也能立刻看出是哪個 normalization 規則壞了。

【策略】表格欄位越多,越要懷疑測試邊界

Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。

拆分判斷:

現象問題建議
很多 setupX boolsetup 不一致拆成不同測試
很多 wantX bool斷言目標不一致拆成不同測試
loop 內大量 if tt...測試流程不一致拆表或改成具名測試
案例名稱很長仍說不清行為維度太多回到單一行為
helper 隱藏主要斷言可讀性下降讓斷言留在測試本文

表格不是越通用越好。測試的第一責任是讓失敗可定位,不是消除所有重複。

【執行】validation 適合 table test

Validation 的核心特徵是輸入和輸出形狀一致,因此很適合 table-driven test。

 1func TestValidateSubscribeRequest(t *testing.T) {
 2    tests := []struct {
 3        name    string
 4        request SubscribeTopicRequest
 5        wantErr bool
 6    }{
 7        {
 8            name:    "valid topic",
 9            request: SubscribeTopicRequest{Topic: "alerts"},
10            wantErr: false,
11        },
12        {
13            name:    "empty topic",
14            request: SubscribeTopicRequest{Topic: ""},
15            wantErr: true,
16        },
17        {
18            name:    "blank topic",
19            request: SubscribeTopicRequest{Topic: "   "},
20            wantErr: true,
21        },
22    }
23
24    for _, tt := range tests {
25        t.Run(tt.name, func(t *testing.T) {
26            err := ValidateSubscribeRequest(tt.request)
27            if (err != nil) != tt.wantErr {
28                t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
29            }
30        })
31    }
32}

這張表只問一件事:request 是否有效。它不測 WebSocket connection、不測 hub、不測 repository,因此案例可以保持簡潔。

【執行】狀態轉移也適合 table test

狀態轉移的核心特徵是輸入狀態、事件、期待輸出狀態。只要流程一致,就適合 table-driven test。

 1func TestJobTransition(t *testing.T) {
 2    tests := []struct {
 3        name    string
 4        current JobStatus
 5        event   EventType
 6        want    JobStatus
 7        wantErr bool
 8    }{
 9        {
10            name:    "pending starts",
11            current: JobPending,
12            event:   EventJobStarted,
13            want:    JobRunning,
14        },
15        {
16            name:    "running finishes",
17            current: JobRunning,
18            event:   EventJobFinished,
19            want:    JobSucceeded,
20        },
21        {
22            name:    "finished cannot start again",
23            current: JobSucceeded,
24            event:   EventJobStarted,
25            wantErr: true,
26        },
27    }
28
29    for _, tt := range tests {
30        t.Run(tt.name, func(t *testing.T) {
31            got, err := Transition(tt.current, tt.event)
32            if (err != nil) != tt.wantErr {
33                t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
34            }
35            if err == nil && got != tt.want {
36                t.Fatalf("status = %s, want %s", got, tt.want)
37            }
38        })
39    }
40}

這張表的欄位都服務同一個行為:job status transition。若未來要測 repository 寫入失敗,應另開測試,不要塞進這張表。

【判讀】不同 setup 應拆成不同測試

測試拆分的核心原則是 setup 不同,通常就不是同一張表。HTTP validation、repository error、client queue full 都需要不同環境。

較清楚的拆法:

 1func TestSubscribeValidation(t *testing.T) {
 2    // 只測 request validation
 3}
 4
 5func TestSubscribeAddsTopic(t *testing.T) {
 6    // 只測成功訂閱後 client state
 7}
 8
 9func TestSubscribeReturnsErrorWhenClientQueueFull(t *testing.T) {
10    // 只測 send buffer 滿載時的錯誤語意
11}

這些測試可能有少量重複,但每個測試的失敗原因更清楚。測試重複一點可以接受;測試意圖混在一起會讓維護成本更高。

【策略】helper 只包樣板,不包判斷

Test helper 的核心責任是降低重複 setup,不應隱藏主要斷言。讀者應能在測試本文看到這個測試到底在驗證什麼。

可以包的樣板:

1func mustJSON(t *testing.T, value any) json.RawMessage {
2    t.Helper()
3    data, err := json.Marshal(value)
4    if err != nil {
5        t.Fatalf("marshal json: %v", err)
6    }
7    return data
8}

不建議包成這樣:

1func assertSubscribeScenario(t *testing.T, tt subscribeScenario) {
2    // setup HTTP, setup WebSocket, setup repository,
3    // execute action, check response, check logs, check events
4}

後者把測試主要邏輯藏進 helper。表格看起來短,但讀者必須跳到 helper 才知道每個欄位如何影響流程。

【執行】子測試名稱要描述情境

子測試名稱的核心作用是讓失敗輸出可定位。名稱應描述情境,不應只寫編號或重複函式名稱。

1tests := []struct {
2    name string
3    // ...
4}{
5    {name: "missing topic"},
6    {name: "unknown action"},
7    {name: "queue full returns unavailable"},
8}

go test 輸出會包含 TestValidateSubscribeRequest/missing_topic 這類資訊。當 CI 失敗時,讀者能先知道哪個情境壞了,再看 got/want 差異。

命名應該描述輸入情境或規則,不需要寫成完整句子,也不要只寫 case 1

【測試】table test 本身也要保持可讀

Table-driven test 的核心完成標準是讀者能掃過表格就理解規則。若必須讀整個 loop 才懂欄位意義,表格設計就不夠清楚。

自檢問題:

  • 這張表是否只測一個行為?
  • 每個欄位是否幾乎每個案例都用得到?
  • 測試 loop 裡是否有大量條件分支?
  • 子測試名稱是否能定位失敗情境?
  • got/want 斷言是否直接留在測試本文?

任一題答否,先考慮拆測試,而不是加更多欄位。

本章不處理

本章先處理單一行為的多組資料案例;property-based、fuzz 與 snapshot 測試,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 test case 設計、handler boundary 與 command 驗證;如果你要先回看語言教材,可以讀:

小結

Table-driven test 適合同一個行為的多組資料,不適合混合多種 setup 與斷言。欄位膨脹、loop 裡大量 if tt...、helper 隱藏主要判斷,都是拆表訊號。好的測試表讓案例更清楚,而不是把測試變成迷你框架。