What is a table-driven test, and why is it idiomatic in Go?
Quick Answer
A table-driven test defines a slice (or map) of test cases — typically a struct with an input, an expected output, and a descriptive name — then loops over it, running the same test logic against every case. It's idiomatic in Go because the language has no built-in parameterized test annotations (unlike JUnit's @ParameterizedTest), and a table plus a loop achieves the same result with plain Go syntax, no test framework required beyond the standard testing package. This pattern keeps test logic in one place, makes adding a new case as simple as adding one more struct literal to the slice, and pairs naturally with t.Run to give each case its own named subtest, so a single failing input is easy to identify without hunting through a wall of near-identical test functions.
Detailed Answer
Table-driven tests are the default way most Go code gets tested, precisely because the language provides no separate parameterized-test mechanism.
The basic shape
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"zero", 0, 5, 5},
{"mixed sign", -2, 5, 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := Add(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, result, tc.expected)
}
})
}
}
Why this beats one function per case
// The alternative: repetitive, and easy to forget updating consistently
func TestAddPositive(t *testing.T) { ... }
func TestAddNegative(t *testing.T) { ... }
func TestAddZero(t *testing.T) { ... }
Adding a new scenario in the table-driven version means adding one line to the slice literal. In the one-function-per-case version, it means writing (and remembering to write correctly) an entirely new test function, with all the same boilerplate repeated.
Testing error cases in the same table
cases := []struct {
name string
input string
want int
wantErr bool
}{
{"valid number", "42", 42, false},
{"invalid input", "abc", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseNumber(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("unexpected error state: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
Including a wantErr field lets the same table cover both success and failure cases, keeping all related scenarios for one function in a single, readable test.