go-test-table-driven
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGo Table-Driven Tests
Go 表驱动测试
Table-driven tests are a powerful Go idiom — when used correctly. The problem
is that most codebases either underuse them (writing 10 copy-paste tests) or
overuse them (jamming complex branching logic into a 200-line struct).
This skill covers the sweet spot.
表驱动测试是Go语言中一种强大的惯用写法——前提是正确使用。但问题在于,大多数代码库要么对其利用不足(编写10个复制粘贴的测试用例),要么过度使用(将复杂的分支逻辑塞进200行的结构体中)。本内容将介绍表驱动测试的最佳适用场景。
1. When Table-Driven Tests Shine
1. 表驱动测试的适用场景
Use table tests when ALL of these are true:
- Same function under test across all cases
- Same assertion pattern — input goes in, output comes out, compare
- Cases differ only in data, not in setup or verification logic
- 3+ cases — fewer than 3, explicit subtests are clearer
The canonical use case: pure functions, parsers, validators, formatters.
go
func TestFormatCurrency(t *testing.T) {
tests := []struct {
name string
cents int64
currency string
want string
}{
{
name: "USD whole dollars",
cents: 1000,
currency: "USD",
want: "$10.00",
},
{
name: "USD with cents",
cents: 1050,
currency: "USD",
want: "$10.50",
},
{
name: "EUR formatting",
cents: 999,
currency: "EUR",
want: "€9.99",
},
{
name: "zero amount",
cents: 0,
currency: "USD",
want: "$0.00",
},
{
name: "negative amount",
cents: -500,
currency: "USD",
want: "-$5.00",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatCurrency(tt.cents, tt.currency)
assert.Equal(t, tt.want, got)
})
}
}Why this works: every case has the same shape, the loop body is 2 lines,
and adding a new case is one struct literal. No branching, no conditionals.
当以下所有条件都满足时,适合使用表驱动测试:
- 所有测试用例针对同一个函数
- 断言模式一致——输入数据,得到输出,进行比较
- 用例仅在数据上存在差异,而非设置或验证逻辑
- 用例数量≥3个——少于3个时,显式子测试更清晰
典型适用场景:纯函数、解析器、验证器、格式化工具。
go
func TestFormatCurrency(t *testing.T) {
tests := []struct {
name string
cents int64
currency string
want string
}{
{
name: "USD whole dollars",
cents: 1000,
currency: "USD",
want: "$10.00",
},
{
name: "USD with cents",
cents: 1050,
currency: "USD",
want: "$10.50",
},
{
name: "EUR formatting",
cents: 999,
currency: "EUR",
want: "€9.99",
},
{
name: "zero amount",
cents: 0,
currency: "USD",
want: "$0.00",
},
{
name: "negative amount",
cents: -500,
currency: "USD",
want: "-$5.00",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatCurrency(tt.cents, tt.currency)
assert.Equal(t, tt.want, got)
})
}
}为何这种写法有效:每个用例结构一致,循环体仅2行代码,新增用例只需添加一个结构体字面量。没有分支,没有条件判断。
2. When NOT to Use Table-Driven Tests
2. 表驱动测试的避坑场景
Complex per-case setup
每个用例的设置逻辑复杂
If each case needs different mocks, different state, or different dependencies:
go
// ❌ Bad — table test with branching setup
tests := []struct {
name string
setupMock func(*mockStore) // each case wires differently
setupAuth func(*mockAuth) // more per-case wiring
input Request
wantStatus int
shouldNotify bool // branching assertion
}{...}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &mockStore{}
tt.setupMock(store) // hiding logic inside functions
auth := &mockAuth{}
tt.setupAuth(auth)
// ... 20 lines of conditional assertions
})
}This is a code smell. The table is just hiding complexity behind function
fields. Write separate subtests instead — they're longer but honest:
go
// ✅ Good — explicit subtests for different scenarios
func TestOrderHandler_Create(t *testing.T) {
t.Run("succeeds with valid order", func(t *testing.T) {
store := &mockStore{createFunc: func(...) (*Order, error) {
return &Order{ID: "1"}, nil
}}
handler := NewHandler(store)
// ... clear, readable, self-contained
})
t.Run("returns 401 when unauthenticated", func(t *testing.T) {
handler := NewHandler(&mockStore{})
// ... different setup, different assertions
})
}如果每个用例需要不同的Mock、不同的状态或不同的依赖:
go
// ❌ 错误示例——包含分支设置的表测试
tests := []struct {
name string
setupMock func(*mockStore) // 每个用例的设置逻辑不同
setupAuth func(*mockAuth) // 更多每个用例专属的设置
input Request
wantStatus int
shouldNotify bool // 分支断言
}{...}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &mockStore{}
tt.setupMock(store) // 将逻辑隐藏在函数字段中
auth := &mockAuth{}
tt.setupAuth(auth)
// ... 20行的条件断言代码
})
}这是一种代码坏味道。测试表只是将复杂性隐藏在函数字段之后。应该改为编写独立的子测试——虽然代码更长,但更清晰:
go
// ✅ 正确示例——针对不同场景的显式子测试
func TestOrderHandler_Create(t *testing.T) {
t.Run("succeeds with valid order", func(t *testing.T) {
store := &mockStore{createFunc: func(...) (*Order, error) {
return &Order{ID: "1"}, nil
}}
handler := NewHandler(store)
// ... 清晰、易读、自包含的测试逻辑
})
t.Run("returns 401 when unauthenticated", func(t *testing.T) {
handler := NewHandler(&mockStore{})
// ... 不同的设置逻辑,不同的断言
})
}Fewer than 3 cases
用例数量少于3个
Two cases don't need a table. The overhead of defining the struct
is more code than just writing two tests:
go
// ❌ Overkill for 2 cases
tests := []struct {
name string
input string
wantErr bool
}{
{"valid", "hello", false},
{"empty", "", true},
}
// ✅ Just write them
func TestValidate_AcceptsNonEmptyString(t *testing.T) {
require.NoError(t, Validate("hello"))
}
func TestValidate_RejectsEmptyString(t *testing.T) {
require.Error(t, Validate(""))
}两个用例无需使用表驱动测试。定义结构体的代码开销比直接编写两个测试更大:
go
// ❌ 针对2个用例的过度设计
tests := []struct {
name string
input string
wantErr bool
}{
{"valid", "hello", false},
{"empty", "", true},
}
// ✅ 直接编写测试
func TestValidate_AcceptsNonEmptyString(t *testing.T) {
require.NoError(t, Validate("hello"))
}
func TestValidate_RejectsEmptyString(t *testing.T) {
require.Error(t, Validate(""))
}Multiple branching paths
存在多个分支路径
If your loop body has / /
— you've outgrown the table. Each branch is a
different test pretending to share a structure.
if tt.shouldErrorif tt.expectNotificationif tt.wantRedirect如果你的循环体中包含//这类代码——说明你已经超出了表驱动测试的适用范围。每个分支都是一个伪装成共享结构的独立测试。
if tt.shouldErrorif tt.expectNotificationif tt.wantRedirect3. Struct Design
3. 结构体设计
Keep fields minimal
保持字段最小化
Every field should change between at least 2 cases. If a field has the
same value in all cases, it's not a variable — it's setup:
go
// ❌ Bad — userRole is "admin" in every case
tests := []struct {
name string
userRole string // always "admin"
input string
want string
}{
{"case1", "admin", "a", "A"},
{"case2", "admin", "b", "B"},
}
// ✅ Good — remove constants from the struct
func TestAdminFormatter(t *testing.T) {
ctx := contextWithRole("admin") // shared setup, outside table
tests := []struct {
name string
input string
want string
}{
{"case1", "a", "A"},
{"case2", "b", "B"},
}
// ...
}每个字段至少要在2个用例中有不同的值。如果某个字段在所有用例中值都相同,那它就不是变量——而是固定设置:
go
// ❌ 错误示例——userRole在所有用例中都是"admin"
tests := []struct {
name string
userRole string // 始终为"admin"
input string
want string
}{
{"case1", "admin", "a", "A"},
{"case2", "admin", "b", "B"},
}
// ✅ 正确示例——从结构体中移除常量
func TestAdminFormatter(t *testing.T) {
ctx := contextWithRole("admin") // 共享设置,放在测试表之外
tests := []struct {
name string
input string
want string
}{
{"case1", "a", "A"},
{"case2", "b", "B"},
}
// ...
}Name the name
field well
name为name
字段取好名字
nameThe field appears in test output. Make it a short sentence that
explains the scenario, not a label:
namego
// ✅ Good names
{name: "trims leading whitespace"},
{name: "returns error for negative amount"},
{name: "handles unicode characters"},
// ❌ Bad names
{name: "case1"},
{name: "success"},
{name: "test with special chars"},namego
// ✅ 好的命名
{name: "trims leading whitespace"},
{name: "returns error for negative amount"},
{name: "handles unicode characters"},
// ❌ 糟糕的命名
{name: "case1"},
{name: "success"},
{name: "test with special chars"},Use wantErr
correctly
wantErr正确使用wantErr
wantErrgo
// ✅ Good — simple boolean for "should it error?"
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "valid number", input: "42", want: 42},
{name: "empty string", input: "", wantErr: true},
{name: "not a number", input: "abc", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInt(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}go
// ✅ 正确示例——用简单的布尔值表示"是否应该报错"
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "valid number", input: "42", want: 42},
{name: "empty string", input: "", wantErr: true},
{name: "not a number", input: "abc", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInt(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}When you need to check specific errors
当需要检查特定错误时
Use a field with a sentinel error, not just a boolean:
wantErrIsgo
tests := []struct {
name string
id string
wantErrIs error // nil means no error expected
}{
{name: "valid id", id: "123"},
{name: "empty id", id: "", wantErrIs: ErrInvalidID},
{name: "not found", id: "999", wantErrIs: ErrNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := store.GetByID(ctx, tt.id)
if tt.wantErrIs != nil {
require.ErrorIs(t, err, tt.wantErrIs)
return
}
require.NoError(t, err)
})
}使用字段配合哨兵错误,而非仅用布尔值:
wantErrIsgo
tests := []struct {
name string
id string
wantErrIs error // nil表示预期无错误
}{
{name: "valid id", id: "123"},
{name: "empty id", id: "", wantErrIs: ErrInvalidID},
{name: "not found", id: "999", wantErrIs: ErrNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := store.GetByID(ctx, tt.id)
if tt.wantErrIs != nil {
require.ErrorIs(t, err, tt.wantErrIs)
return
}
require.NoError(t, err)
})
}4. The Loop Body Must Be Trivial
4. 循环体必须尽可能简洁
The entire point of a table test is that the execution logic is
identical for every case. If your loop body exceeds ~10 lines,
something is wrong.
go
// ✅ Good — loop body is 5 lines
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Process(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
// ❌ Bad — loop body has become a mini-program
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupDB {
db := setupDB(t)
defer db.Close()
}
svc := NewService()
if tt.withCache { svc.EnableCache() }
got, err := svc.Process(tt.input)
if tt.wantErr {
require.Error(t, err)
if tt.wantErrMsg != "" { assert.Contains(t, err.Error(), tt.wantErrMsg) }
return
}
// ... more conditionals, more branches
})
}If you see this, split into separate subtests or separate test functions.
表驱动测试的核心在于每个用例的执行逻辑完全一致。如果你的循环体超过约10行,说明存在问题。
go
// ✅ 正确示例——循环体仅5行
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Process(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
// ❌ 错误示例——循环体变成了一个小型程序
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupDB {
db := setupDB(t)
defer db.Close()
}
svc := NewService()
if tt.withCache { svc.EnableCache() }
got, err := svc.Process(tt.input)
if tt.wantErr {
require.Error(t, err)
if tt.wantErrMsg != "" { assert.Contains(t, err.Error(), tt.wantErrMsg) }
return
}
// ... 更多条件判断,更多分支
})
}如果遇到这种情况,应该拆分为独立的子测试或独立的测试函数。
5. Parallel Table Tests
5. 并行表测试
go
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Transform(tt.input)
assert.Equal(t, tt.want, got)
})
}In Go 1.22+, the loop variable is scoped per iteration, so the old
capture is unnecessary. For Go <1.22, you still need it:
tt := ttgo
// Go <1.22 only
for _, tt := range tests {
tt := tt // capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}Only use in table tests when the function under test
has no side effects and no shared mutable state.
t.Parallel()go
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Transform(tt.input)
assert.Equal(t, tt.want, got)
})
}在Go 1.22+版本中,循环变量的作用域是每次迭代,因此不再需要旧的捕获写法。对于Go <1.22版本,仍然需要:
tt := ttgo
// 仅适用于Go <1.22
for _, tt := range tests {
tt := tt // 捕获循环变量
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}仅当被测函数无副作用且无共享可变状态时,才在表驱动测试中使用。
t.Parallel()6. Readability Tricks
6. 可读性技巧
Align struct literals for scanning
对齐结构体字面量以便快速查看
go
tests := []struct {
name string
input string
want string
}{
{"lowercase", "hello", "hello"},
{"uppercase", "HELLO", "hello"},
{"mixed case", "HeLLo", "hello"},
{"with spaces", "Hello World", "hello world"},
{"already lowercase", "test", "test"},
}This works for simple cases. For complex structs, use the multi-line format:
go
tests := []struct {
name string
config Config
want string
}{
{
name: "default timeout",
config: Config{
Host: "localhost",
Timeout: 0, // should get default
},
want: "localhost:8080",
},
{
name: "custom port",
config: Config{
Host: "localhost",
Port: 9090,
},
want: "localhost:9090",
},
}go
tests := []struct {
name string
input string
want string
}{
{"lowercase", "hello", "hello"},
{"uppercase", "HELLO", "hello"},
{"mixed case", "HeLLo", "hello"},
{"with spaces", "Hello World", "hello world"},
{"already lowercase", "test", "test"},
}这种写法适用于简单场景。对于复杂结构体,使用多行格式:
go
tests := []struct {
name string
config Config
want string
}{
{
name: "default timeout",
config: Config{
Host: "localhost",
Timeout: 0, // 应使用默认值
},
want: "localhost:8080",
},
{
name: "custom port",
config: Config{
Host: "localhost",
Port: 9090,
},
want: "localhost:9090",
},
}Map-based tables for ultra-simple cases
超简单场景下使用基于Map的测试表
When the struct would just be :
{name, input, want}go
func TestStatusText(t *testing.T) {
cases := map[string]struct {
code int
want string
}{
"ok": {200, "OK"},
"not found": {404, "Not Found"},
"server error": {500, "Internal Server Error"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, StatusText(tc.code))
})
}
}Note: map iteration order is random, so this also stress-tests that
your cases are truly independent.
当结构体仅包含时:
{name, input, want}go
func TestStatusText(t *testing.T) {
cases := map[string]struct {
code int
want string
}{
"ok": {200, "OK"},
"not found": {404, "Not Found"},
"server error": {500, "Internal Server Error"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, StatusText(tc.code))
})
}
}注意:Map的迭代顺序是随机的,因此这也能验证你的用例是否真正独立。
7. Error-Only Tables
7. 仅验证错误的测试表
When you're testing a validator and only care about which inputs fail:
go
func TestValidateEmail(t *testing.T) {
valid := []string{
"user@example.com",
"user+tag@example.com",
"user@sub.domain.com",
}
for _, email := range valid {
t.Run("valid/"+email, func(t *testing.T) {
require.NoError(t, ValidateEmail(email))
})
}
invalid := []string{
"",
"@",
"user@",
"@domain.com",
"user space@example.com",
}
for _, email := range invalid {
t.Run("invalid/"+email, func(t *testing.T) {
require.Error(t, ValidateEmail(email))
})
}
}Two simple slices. No struct needed. The test name includes the input
value, so failures are self-documenting.
当你测试一个验证器,只关心哪些输入会失败时:
go
func TestValidateEmail(t *testing.T) {
valid := []string{
"user@example.com",
"user+tag@example.com",
"user@sub.domain.com",
}
for _, email := range valid {
t.Run("valid/"+email, func(t *testing.T) {
require.NoError(t, ValidateEmail(email))
})
}
invalid := []string{
"",
"@",
"user@",
"@domain.com",
"user space@example.com",
}
for _, email := range invalid {
t.Run("invalid/"+email, func(t *testing.T) {
require.Error(t, ValidateEmail(email))
})
}
}两个简单的切片,无需结构体。测试名称包含输入值,因此失败时会自动说明问题。
8. Refactoring Bloated Tables
8. 重构臃肿的测试表
Signs your table test needs refactoring:
| Symptom | Fix |
|---|---|
| Struct has 8+ fields | Split into multiple test functions by scenario |
| Extract to separate subtests with explicit setup |
| Each branch is a different test — split it |
| Same 3 fields are identical in every case | Move to shared setup outside the table |
| Test name is the only way to understand the case | The case is too complex for a table |
| Adding a case requires understanding all other cases | Table has grown beyond its useful life |
以下是测试表需要重构的信号:
| 症状 | 修复方案 |
|---|---|
| 结构体包含8个以上字段 | 按场景拆分为多个测试函数 |
结构体中包含 | 提取为带有显式设置的独立子测试 |
循环体中包含 | 每个分支都是独立测试——拆分出去 |
| 有3个字段在所有用例中值都相同 | 移到测试表之外的共享设置中 |
| 必须通过测试名称才能理解用例 | 该用例过于复杂,不适合表驱动测试 |
| 新增用例需要理解所有其他用例 | 测试表已经超出其适用范围 |
Decision Flowchart
决策流程图
-
Is the function pure (input → output, no side effects)? Yes → table test is probably ideal. Go to 2. No → consider explicit subtests first.
-
Do all cases share the exact same assertion pattern? Yes → table test. Go to 3. No → explicit subtests.
-
Can each case be expressed in ≤5 struct fields? Yes → table test. No → split by scenario into separate test functions.
-
Is the loop body ≤10 lines? Yes → you're golden. No → the table is hiding complexity. Refactor.
-
被测函数是否为纯函数(输入→输出,无副作用)? 是 → 表驱动测试可能是理想选择。进入步骤2。 否 → 优先考虑显式子测试。
-
所有用例是否共享完全相同的断言模式? 是 → 使用表驱动测试。进入步骤3。 否 → 使用显式子测试。
-
每个用例是否可以用≤5个结构体字段表示? 是 → 使用表驱动测试。 否 → 按场景拆分为独立的测试函数。
-
循环体是否≤10行? 是 → 完美。 否 → 测试表隐藏了复杂性,需要重构。
Verification Checklist
验证检查清单
- Table struct has only fields that vary between cases
- Every case has a descriptive field
name - Loop body is ≤10 lines with no branching
- No or
setupFuncfields in the structmockFunc - is a simple bool or sentinel, not a string match
wantErr - Cases cover: happy path, error path, edge cases (empty, nil, zero, max)
- wraps each case for named subtests
t.Run - used only when function is side-effect-free
t.Parallel()
- 测试表结构体仅包含在不同用例中变化的字段
- 每个用例都有一个描述性的字段
name - 循环体≤10行且无分支逻辑
- 结构体中无或
setupFunc字段mockFunc - 是简单的布尔值或哨兵错误,而非字符串匹配
wantErr - 用例覆盖:正常路径、错误路径、边界情况(空值、nil、零值、最大值)
- 每个用例都通过包装为命名子测试
t.Run - 仅当函数无副作用时才使用
t.Parallel()