29.2 Table-Driven Tests: Slices of Test Cases
Right, let’s talk about table-driven tests. If you’re still writing tests by copying and pasting a test function and changing one or two values, I’m going to ask you politely, yet firmly, to stop. You’re not just creating more code to maintain; you’re missing out on one of the most elegant and powerful patterns in the Go testing ecosystem. The idea is brilliantly simple: you separate your test logic from your test data. Your test function becomes a single, clean engine, and your test cases become a slice of data that engine processes. It’s the difference between hand-crafting each meatball and having a perfectly calibrated meatball-making machine.
Here’s the basic anatomy. You define a slice of structs, where each struct is a complete test case. The struct usually has at least an input field and an expected output field, plus a name string so you know which one blows up.
func TestSquare(t *testing.T) {
// Define your test table right here
tests := []struct {
name string
input int
expected int
}{
{"positive_number", 2, 4},
{"negative_number", -3, 9},
{"zero", 0, 0},
{"large_number", 100, 10000},
}
// The engine: iterate over the slice
for _, tt := range tests {
// Run each test case
result := Square(tt.input)
if result != tt.expected {
t.Errorf("%s: Square(%d) = %d; expected %d", tt.name, tt.input, result, tt.expected)
}
}
}
This is already a massive improvement. But we can do better. The problem with the above loop is that as soon as one test case fails, the entire test function fails and stops. You don’t get a report on the other cases. This is where t.Run() and subtests come in to save the day.
Wrapping Cases in Subtests
By wrapping each iteration in t.Run(), you turn each table entry into its own isolated subtest. The Go test runner will execute them all independently, even if one fails spectacularly. This is the professional move.
func TestSquareWithSubtests(t *testing.T) {
tests := []struct {
name string
input int
expected int
}{
{"positive_number", 2, 4},
{"negative_number", -3, 9},
{"zero", 0, 0},
{"large_number", 100, 10000},
}
for _, tt := range tests {
// tt is re-used for each iteration; we must capture it.
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Now you can run these babies in parallel!
result := Square(tt.input)
if result != tt.expected {
t.Errorf("Square(%d) = %d; expected %d", tt.input, result, tt.expected)
}
})
}
}
Notice the tt := tt line? This is a classic gotcha. The tt variable in the loop is reused for each iteration. When you use t.Run, which is asynchronous, the goroutine for each subtest will likely reference the same tt variable from the outer scope, which by then will have been updated to the last element in the slice. Capturing the loop variable by creating a new tt scoped to the loop body fixes this. It’s a bit weird looking, I know, but it’s idiomatic Go.
Handling More Complex Output and Errors
What if your function returns an error? Or a complex struct? Your test table needs to handle that. You can use interfaces{} for the expected value, but I prefer to be more explicit where possible.
func TestDivide(t *testing.T) {
tests := []struct {
name string
a int
b int
expected int
expectError bool
}{
{"happy_path", 10, 2, 5, false},
{"divide_by_zero", 10, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
if tt.expectError {
if err == nil {
t.Error("expected an error, but didn't get one")
}
return // If we expected an error, we don't check the result
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if result != tt.expected {
t.Errorf("Divide(%d, %d) = %d; expected %d", tt.a, tt.b, result, tt.expected)
}
})
}
}
The Power of Setup and Teardown (Within Reason)
Sometimes your test cases need a little setup. Maybe you need a temporary file or a database connection. You can do this inside your t.Run block. The key is to be judicious. If every test case needs the same expensive setup, do it once before the loop. If they need unique or lightweight setup, do it inside the subtest. This keeps your tests fast and isolated, which is the whole point.
The beauty of this pattern is its scalability. You can add a new test case by adding one line to the slice. It forces you to think clearly about your inputs and outputs. It makes your test file a comprehensive, readable specification of what your code is supposed to do. Stop writing one-off tests. Embrace the table. Your future self, desperately debugging at 2 AM, will thank you for it.