| name | go-table-driven-tests |
| description | Write Go table-driven tests following Go community best practices and this repository's conventions. Use when writing or refactoring Go tests, especially when you notice repeated test patterns or copy-pasted test code. |
Go Table-Driven Tests
Overview
Table-driven tests are a Go testing idiom that reduces code duplication and makes tests more maintainable. Instead of writing separate test functions for each case, you define a table of test cases and iterate over it.
When to Use Table-Driven Tests
Use table-driven tests when:
- You find yourself copying and pasting test code
- You're testing the same function/behavior with multiple inputs
- You want to add more test cases without writing more test functions
- Edge cases and boundary conditions need systematic coverage
Do NOT use for: Completely unrelated test scenarios, or when each test requires substantially different setup/teardown logic.
Basic Template (Slice Pattern)
This is the most common pattern in this codebase:
func TestFunctionName(t *testing.T) {
cases := []struct {
name string
input string
want string
err error
}{
{
name: "simple case",
input: "a/b/c",
want: "a,b,c",
},
{
name: "empty input",
input: "",
want: "",
},
{
name: "invalid input",
input: "!!!",
want: "",
err: ErrInvalid,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got, err := FunctionName(tt.input)
if !errors.Is(err, tt.err) {
t.Errorf("FunctionName(%q) error = %v, want %v", tt.input, err, tt.err)
}
if got != tt.want {
t.Errorf("FunctionName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Map Pattern (For Non-Deterministic Test Ordering)
Use a map when you want to ensure test independence:
func TestFunctionName(t *testing.T) {
tests := map[string]struct {
input string
want string
}{
"simple case": {input: "a/b/c", want: "a,b,c"},
"empty input": {input: "", want: ""},
"trailing sep": {input: "a/b/c/", want: "a,b,c"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := FunctionName(tc.input)
if got != tc.want {
t.Fatalf("%s: expected %q, got %q", name, tc.want, got)
}
})
}
}
Repository-Specific Conventions
Variable Naming
This codebase consistently uses these variable names:
| Purpose | Variable Name | Example |
|---|
| Test cases slice | cases, tt, cs | cases := []struct{...} |
| Loop variable | tt, cs, tc | for _, tt := range cases |
| Input field | input, in, inp | input: "test" |
| Expected output | want, expected | want: "result" |
| Actual output | got, output | got := Function() |
| Error field | err, wantErr | err: ErrInvalid |
| Name field | name | name: "descriptive name" |
Struct Field Guidelines
cases := []struct {
name string
input Type
want Type
err error
wantErr bool
precondition func(*testing.T)
}{ ... }
Error Reporting
This repository uses these patterns:
if !errors.Is(err, tt.err) {
t.Errorf("error = %v, want %v", err, tt.err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
Advanced Patterns
With Precondition Functions
When tests need specific setup:
for _, tt := range []struct {
name string
precondition func(*testing.T)
input string
err error
}{
{
name: "with listener",
precondition: func(t *testing.T) {
ln, err := net.Listen("tcp", ":8081")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
},
input: "test",
err: nil,
},
} {
t.Run(tt.name, func(t *testing.T) {
if tt.precondition != nil {
tt.precondition(t)
}
})
}
Parallel Tests
For independent tests that can run in parallel:
func TestFunctionName(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := FunctionName(tt.input)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
Best Practices
- Always use
t.Run() for subtests - This is 100% consistent in this codebase
- Use descriptive test names - The
name field should clearly describe what is being tested
- Test one thing per case - Each table entry should test one specific behavior
- Include edge cases - Empty strings, nil values, maximum values, etc.
- Use
errors.Is for error comparison - Not == or reflect.DeepEqual
- Prefer
t.Errorf over t.Fatalf - See all failures before stopping
- Keep test data inline - External files only for large golden test sets
Common Pitfalls to Avoid
- Forgetting
t.Run() - Without subtests, all failures appear at the same line
- Using
Fatalf immediately - You won't see other test failures
- Not capturing range variable - In Go < 1.22, add
tt := tt before t.Run
- Anonymous structs - This codebase prefers named structs for clarity
- Inconsistent naming - Stick to the conventions (
cases, tt, want, got)
Comparison with Traditional Tests
| Traditional | Table-Driven |
|---|
func TestFoo(t *testing.T) { ... } | func TestFoo(t *testing.T) { cases := []struct{...}{...} } |
| One test function per case | Single function, many cases |
| Hard to add new cases | Just add a row to the table |
| Verbose boilerplate | Concise, DRY code |
go test -run TestFoo_SpecificCase | go test -run TestFoo/name |
Running Specific Tests
go test -run TestFunctionName
go test -run TestFunctionName/descriptive_name
go test -run TestFunctionName/.*/empty
go test -v
go test -race
References