Loading...
Loading...
Deep dive on table-driven tests in Go: when to use them, when to avoid them, struct design, subtest naming, advanced patterns like test matrices and shared setup, and refactoring bloated tables into clean ones. Use when writing table-driven tests, refactoring test tables, reviewing table test structure, or deciding whether table-driven is the right approach. Trigger examples: "table-driven test", "table test", "test cases struct", "test matrix", "parametrize tests", "data-driven test", "refactor test table". Do NOT use for general test strategy, mocking, golden files, or fuzz testing (use go-test-quality). Do NOT use for benchmarks (use go-performance-review).
npx skill4agent add eduardo-sl/go-agent-skills go-test-table-drivenfunc 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)
})
}
}// ❌ 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
})
}// ✅ 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
})
}// ❌ 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 tt.shouldErrorif tt.expectNotificationif tt.wantRedirect// ❌ 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"},
}
// ...
}namename// ✅ 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// ✅ 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)
})
}wantErrIstests := []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)
})
}// ✅ 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
})
}for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Transform(tt.input)
assert.Equal(t, tt.want, got)
})
}tt := tt// Go <1.22 only
for _, tt := range tests {
tt := tt // capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}t.Parallel()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"},
}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",
},
}{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))
})
}
}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))
})
}
}| 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 |
namesetupFuncmockFuncwantErrt.Runt.Parallel()