From go-agent-skills
Guides table-driven tests in Go: when to use/avoid, struct design, subtest naming, test matrices, shared setup, and refactoring bloated tables. Use for writing, reviewing, or refactoring them.
npx claudepluginhub eduardo-sl/go-agent-skills --plugin go-agent-skillsThis skill uses the workspace's default tool permissions.
Table-driven tests are a powerful Go idiom — when used correctly. The problem
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
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.
Use table tests when ALL of these are true:
The canonical use case: pure functions, parsers, validators, formatters.
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.
If each case needs different mocks, different state, or different dependencies:
// ❌ 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:
// ✅ 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
})
}
Two cases don't need a table. The overhead of defining the struct is more code than just writing two tests:
// ❌ 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(""))
}
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.
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:
// ❌ 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"},
}
// ...
}
name field wellThe name field appears in test output. Make it a short sentence that
explains the scenario, not a label:
// ✅ 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"},
wantErr correctly// ✅ 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)
})
}
Use a wantErrIs field with a sentinel error, not just a boolean:
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)
})
}
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.
// ✅ 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.
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 <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.
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:
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",
},
}
When the struct would just be {name, input, want}:
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.
When you're testing a validator and only care about which inputs fail:
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.
Signs your table test needs refactoring:
| Symptom | Fix |
|---|---|
| Struct has 8+ fields | Split 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 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 |
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.
name fieldsetupFunc or mockFunc fields in the structwantErr is a simple bool or sentinel, not a string matcht.Run wraps each case for named subtestst.Parallel() used only when function is side-effect-free