5.4 table-driven test 的設計邊界
Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例,但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。
本章目標
學完本章後,你將能夠:
- 判斷什麼行為適合 table-driven test
- 設計欄位少、意圖清楚的測試表
- 發現 table test 膨脹成迷你框架的訊號
- 拆分 validation、repository error、integration flow
- 寫出能定位失敗情境的子測試名稱
【觀察】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 bool | setup 不一致 | 拆成不同測試 |
很多 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 隱藏主要判斷,都是拆表訊號。好的測試表讓案例更清楚,而不是把測試變成迷你框架。