Table-Driven Tests
Table-driven tests keep every case in one slice and run each through t.Run, so a failure names the case that broke.
t.Run(name string, f func(t *testing.T)) boolMost functions need checking against several inputs, not one. Copying the same test body five times is tedious to read and easy to get wrong. The Go answer is a table: a slice of structs where each element is one case, and a loop that runs them all.
The shape
Declare the struct inline, in the test. It usually has a name, the inputs, and the expected result, which is called want by convention. Then loop over the slice and check each case the same way. Because the comparison code exists once, adding a case means adding one line.
The elements can be written two ways. {name: "below range", n: -5, want: 0} names each field. {"below range", -5, 0} gives the values in declaration order and means exactly the same thing. The keyed form survives someone adding a field in the middle, so prefer it.
Subtests
t.Run(name, func(t *testing.T) { ... }) runs a function as a test inside the test, called a subtest. Each case gets its own name in the output, and a failure says which one failed without you putting the input in every message. -run TestClamp/above_range then runs one case on its own.
The inner t is a different *testing.T from the outer one. t.Fatalf inside a subtest ends that subtest only; the remaining cases still run. Since Go 1.22 each loop iteration has its own copy of the loop variable, so the old tt := tt line inside the loop is no longer needed.
Example
// File: clamp_test.go — run it with: go test -v ./...
package clamp
import "testing"
// Clamp pushes n into the range low..high and returns the result.
func Clamp(n, low, high int) int {
if n < low {
return low
}
if n > high {
return high
}
return n
}
func TestClamp(t *testing.T) {
tests := []struct {
name string
n int
want int
}{
{name: "below range", n: -5, want: 0},
{name: "inside range", n: 4, want: 4},
{name: "above range", n: 99, want: 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Clamp(tt.n, 0, 10)
if got != tt.want {
t.Errorf("Clamp(%d, 0, 10) = %d, want %d", tt.n, got, tt.want)
}
})
}
}
// Output:
// === RUN TestClamp
// === RUN TestClamp/below_range
// === RUN TestClamp/inside_range
// === RUN TestClamp/above_range
// --- PASS: TestClamp (0.00s)
// --- PASS: TestClamp/below_range (0.00s)
// --- PASS: TestClamp/inside_range (0.00s)
// --- PASS: TestClamp/above_range (0.00s)
// PASS
When to use it
- Check a validation function against every input shape you accept and reject, one line per shape.
- Reproduce a bug by adding one row to an existing table instead of writing a whole new test.
- Name each case so a continuous integration failure tells you which input broke without reading the diff.
More examples
A table with an expected error
A wantErr field lets the success and failure cases live in one table. Leaving wantErr out of a case sets it to nil, the zero value, and errors.Is(nil, nil) is true, so the happy cases need no special handling. strconv.Atoi wraps strconv.ErrSyntax, which is why errors.Is matches it and == would not.
package netutil
import (
"errors"
"strconv"
"testing"
)
var ErrOutOfRange = errors.New("port out of range")
func ParsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n < 1 || n > 65535 {
return 0, ErrOutOfRange
}
return n, nil
}
func TestParsePort(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr error
}{
{name: "plain number", in: "8080", want: 8080},
{name: "not a number", in: "http", wantErr: strconv.ErrSyntax},
{name: "too large", in: "99999", wantErr: ErrOutOfRange},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePort(tt.in)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ParsePort(%q) error = %v, want %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParsePort(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}A map as the table
A map makes the name the key, so duplicate names become a compile error instead of two identically named subtests. The trade is that Go visits map keys in an unpredictable order, so the cases run in a different order each time. That is fine when the cases are independent, and a warning sign when they are not.
func TestClampMap(t *testing.T) {
tests := map[string]struct {
n int
want int
}{
"below range": {n: -5, want: 0},
"inside range": {n: 4, want: 4},
"above range": {n: 99, want: 10},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := Clamp(tt.n, 0, 10); got != tt.want {
t.Errorf("Clamp(%d, 0, 10) = %d, want %d", tt.n, got, tt.want)
}
})
}
}Running the cases at the same time
t.Parallel marks a subtest as safe to run alongside its siblings, which shortens a slow suite. Each iteration of the loop has its own c from Go 1.22 onward, so the older c := c line is no longer required. A subtest that calls t.Parallel cannot also call t.Setenv; that combination panics.
func TestClampParallel(t *testing.T) {
cases := []struct {
name string
n int
want int
}{
{name: "low", n: -1, want: 0},
{name: "high", n: 50, want: 10},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := Clamp(c.n, 0, 10); got != c.want {
t.Errorf("Clamp(%d, 0, 10) = %d, want %d", c.n, got, c.want)
}
})
}
}
Discussion