go-test-table-driven

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Go 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
if tt.shouldError
/
if tt.expectNotification
/
if tt.wantRedirect
— you've outgrown the table. Each branch is a different test pretending to share a structure.
如果你的循环体中包含
if tt.shouldError
/
if tt.expectNotification
/
if tt.wantRedirect
这类代码——说明你已经超出了表驱动测试的适用范围。每个分支都是一个伪装成共享结构的独立测试。

3. 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
字段取好名字

The
name
field appears in test output. Make it a short sentence that explains the scenario, not a label:
go
// ✅ 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"},
name
字段会出现在测试输出中。应该用简短的句子解释场景,而非简单的标签:
go
// ✅ 好的命名
{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

go
// ✅ 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
wantErrIs
field with a sentinel error, not just a boolean:
go
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)
    })
}
使用
wantErrIs
字段配合哨兵错误,而非仅用布尔值:
go
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
tt := tt
capture is unnecessary. For Go <1.22, you still need it:
go
// 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
t.Parallel()
in table tests when the function under test has no side effects and no shared mutable state.
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+版本中,循环变量的作用域是每次迭代,因此不再需要旧的
tt := tt
捕获写法。对于Go <1.22版本,仍然需要:
go
// 仅适用于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:
SymptomFix
Struct has 8+ fieldsSplit into multiple test functions by scenario
setupFunc
field in struct
Extract to separate subtests with explicit setup
if tt.shouldX
in loop body
Each branch is a different test — split it
Same 3 fields are identical in every caseMove to shared setup outside the table
Test name is the only way to understand the caseThe case is too complex for a table
Adding a case requires understanding all other casesTable has grown beyond its useful life
以下是测试表需要重构的信号:
症状修复方案
结构体包含8个以上字段按场景拆分为多个测试函数
结构体中包含
setupFunc
字段
提取为带有显式设置的独立子测试
循环体中包含
if tt.shouldX
每个分支都是独立测试——拆分出去
有3个字段在所有用例中值都相同移到测试表之外的共享设置中
必须通过测试名称才能理解用例该用例过于复杂,不适合表驱动测试
新增用例需要理解所有其他用例测试表已经超出其适用范围

Decision Flowchart

决策流程图

  1. Is the function pure (input → output, no side effects)? Yes → table test is probably ideal. Go to 2. No → consider explicit subtests first.
  2. Do all cases share the exact same assertion pattern? Yes → table test. Go to 3. No → explicit subtests.
  3. Can each case be expressed in ≤5 struct fields? Yes → table test. No → split by scenario into separate test functions.
  4. Is the loop body ≤10 lines? Yes → you're golden. No → the table is hiding complexity. Refactor.
  1. 被测函数是否为纯函数(输入→输出,无副作用)? 是 → 表驱动测试可能是理想选择。进入步骤2。 否 → 优先考虑显式子测试。
  2. 所有用例是否共享完全相同的断言模式? 是 → 使用表驱动测试。进入步骤3。 否 → 使用显式子测试。
  3. 每个用例是否可以用≤5个结构体字段表示? 是 → 使用表驱动测试。 否 → 按场景拆分为独立的测试函数。
  4. 循环体是否≤10行? 是 → 完美。 否 → 测试表隐藏了复杂性,需要重构。

Verification Checklist

验证检查清单

  1. Table struct has only fields that vary between cases
  2. Every case has a descriptive
    name
    field
  3. Loop body is ≤10 lines with no branching
  4. No
    setupFunc
    or
    mockFunc
    fields in the struct
  5. wantErr
    is a simple bool or sentinel, not a string match
  6. Cases cover: happy path, error path, edge cases (empty, nil, zero, max)
  7. t.Run
    wraps each case for named subtests
  8. t.Parallel()
    used only when function is side-effect-free
  1. 测试表结构体仅包含在不同用例中变化的字段
  2. 每个用例都有一个描述性的
    name
    字段
  3. 循环体≤10行且无分支逻辑
  4. 结构体中无
    setupFunc
    mockFunc
    字段
  5. wantErr
    是简单的布尔值或哨兵错误,而非字符串匹配
  6. 用例覆盖:正常路径、错误路径、边界情况(空值、nil、零值、最大值)
  7. 每个用例都通过
    t.Run
    包装为命名子测试
  8. 仅当函数无副作用时才使用
    t.Parallel()