The copy-paste trap
You write a function to calculate shipping costs. You write a test. It passes. Then you realize you need to test free shipping over fifty dollars. You copy the test function, paste it, and change the input. Then you test international rates. Then you test negative weights. You now have six test functions that look identical except for the numbers.
Changing the assertion logic means editing six places. Adding a new edge case means copy-pasting again. This is the copy-paste trap. It turns test suites into maintenance nightmares. Table-driven tests solve this by separating test data from test logic. You define the data once, loop over it, and run the same logic against every case.
Data drives the test
A table-driven test treats test cases like rows in a spreadsheet. The function under test is the formula. The test code is the engine that runs the formula against every row. You define a struct to hold the inputs and expected outputs. You create a slice of those structs. You loop over the slice and call t.Run for each case.
The testing package handles the iteration. Each t.Run call creates a subtest. Subtests are isolated. If one fails, the others keep running. The test output lists the name of the failing subtest. You see exactly which input caused the failure without digging through logs.
This pattern is the standard in Go. It keeps tests readable. It makes adding new cases trivial. It encourages writing many small tests instead of few large ones.
The minimal skeleton
Here's the simplest table-driven test. It defines a struct, populates a slice, loops over it, and asserts the result.
package main
import (
"fmt"
"testing"
)
// Add returns the sum of two integers.
func Add(a, b int) int {
return a + b
}
// TestAdd runs the same logic against multiple inputs.
func TestAdd(t *testing.T) {
// Define the shape of a test case.
// Fields hold inputs and expected outputs.
type testCase struct {
a, b, want int
}
// Populate the table with concrete data.
// Each struct literal is one row of test data.
tests := []testCase{
{1, 2, 3},
{0, 0, 0},
{-1, 1, 0},
}
// Iterate over every row in the table.
// The loop variable tt holds one test case per iteration.
for _, tt := range tests {
// t.Run creates a named subtest.
// The name appears in test output for debugging.
t.Run(fmt.Sprintf("%d+%d", tt.a, tt.b), func(t *testing.T) {
// Call the function under test.
got := Add(tt.a, tt.b)
// Compare result against expected value.
// t.Errorf logs the failure and marks the subtest failed.
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Data drives the test. Logic stays dry.
How subtests work
When you call t.Run, the testing package spawns a subtest. The subtest gets its own *testing.T instance. This instance is independent of the parent test. You can call t.FailNow inside a subtest without stopping other subtests. The parent test waits for all subtests to finish before reporting the final result.
The name you pass to t.Run is critical. It appears in the test output. A good name describes the case. t.Run("negative inputs", ...) is better than t.Run("case 1", ...). If you use fmt.Sprintf to generate names, keep them short. Long names clutter the terminal output.
Subtests also support parallel execution. You can call t.Parallel() inside the subtest function. This allows multiple subtests to run concurrently. Parallel subtests speed up test suites significantly. They also expose race conditions that sequential tests hide.
Realistic test with errors
Real functions return errors. Table-driven tests handle errors cleanly by including a wantErr field in the struct. The convention is to name the field wantErr or wantError. The community prefers wantErr. You check if the error presence matches the expectation, not the error value itself.
Here's a test for a function that parses a discount code. It handles valid codes, invalid codes, and empty input.
package main
import (
"errors"
"testing"
)
// ErrInvalidCode indicates a malformed discount code.
var ErrInvalidCode = errors.New("invalid discount code")
// ApplyDiscount calculates the final price after applying a code.
func ApplyDiscount(price float64, code string) (float64, error) {
if code == "" {
return 0, ErrInvalidCode
}
if code == "SAVE10" {
return price * 0.9, nil
}
return price, nil
}
// TestApplyDiscount validates discount logic across multiple scenarios.
func TestApplyDiscount(t *testing.T) {
// Define the test case structure.
// wantErr indicates if an error is expected, not the error value.
type testCase struct {
name string
price float64
code string
want float64
wantErr bool
}
// Populate cases covering success, failure, and edge cases.
tests := []testCase{
{
name: "valid discount code",
price: 100.0,
code: "SAVE10",
want: 90.0,
wantErr: false,
},
{
name: "empty code returns error",
price: 50.0,
code: "",
want: 0,
wantErr: true,
},
{
name: "unknown code returns original price",
price: 200.0,
code: "UNKNOWN",
want: 200.0,
wantErr: false,
},
}
// Loop over cases and run each as a subtest.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mark subtest as parallel.
// Allows concurrent execution of independent cases.
t.Parallel()
// Call the function under test.
got, err := ApplyDiscount(tt.price, tt.code)
// Check error presence against expectation.
// This pattern handles nil vs non-nil correctly.
if (err != nil) != tt.wantErr {
t.Errorf("ApplyDiscount() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Compare numeric result.
// Floating point comparison uses exact equality here for simplicity.
if got != tt.want {
t.Errorf("ApplyDiscount() = %v, want %v", got, tt.want)
}
})
}
}
Parallel tests run fast. Shared state breaks them.
Pitfalls and conventions
Table-driven tests are powerful, but they have traps. The most common trap is shared state. If the test function modifies a global variable or the struct itself, subtests interfere with each other. Each subtest must operate on independent data. Copy the input data inside the subtest if the function mutates it.
Another trap is the loop variable capture. In Go versions before 1.22, the loop variable tt was shared across all iterations. Closures inside t.Run would capture the same variable. By the time the subtest ran, the loop had finished, and tt held the last value. This caused subtle bugs where every subtest tested the same data. Go 1.22 fixed this by creating a new variable per iteration. If you use an older version, the compiler rejects the code with loop variable captured by func literal. You must manually copy the variable: tt := tt inside the loop.
Naming conventions matter for readability. Always include a name field in the test struct. Use it for t.Run. This keeps names descriptive and decoupled from the data. Use want for expected values. Use wantErr for error expectations. Use got for actual results. These names are instantly recognizable to any Go developer.
Error checking follows a standard pattern. Compare the boolean presence of the error, not the error value. Use if (err != nil) != tt.wantErr. This handles nil correctly. If you need to check the error message, use errors.Is or errors.As inside the subtest.
The t.Helper() function is useful when you wrap assertions in helper functions. Calling t.Helper() tells the testing package to skip the helper in stack traces. Errors point to the line in the test case, not the helper function. This makes debugging faster.
Convention asides: The community accepts verbose error messages in tests. t.Errorf calls are long by design. They print inputs, outputs, and expectations. This verbosity is a feature. It eliminates guesswork when a test fails. Trust the output.
Decision matrix
Use a table-driven test when you have multiple inputs for the same logic. Use a single test function when the setup is unique and complex. Use t.Run with manual cases when you need distinct setup or teardown per case that does not fit a struct. Use golden file tests when the output is large text or HTML.