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.

Syntaxt.Run(name string, f func(t *testing.T)) bool

Most 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

Example · go
// 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.

Example · go
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.

Example · go
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.

Example · go
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

  • Be the first to comment on this lesson.