Table-Driven Tests

A table-driven test is Go’s idiomatic way of testing a function against many inputs without writing a separate test function for each one. Instead of repeating the same assertion logic over and over, you describe every scenario as a row of data — a "table" — and loop over that table, running the same check against each row. This keeps tests short, makes adding a new case as easy as adding one line, and is so common in the Go standard library itself that recognizing the pattern is essential to reading real Go code.

Overview: How Table-Driven Tests Work

Go’s built-in testing support lives in the testing package. A test file is named xxx_test.go, sits next to the code it tests, and contains functions of the form func TestName(t *testing.T). The go test command finds these functions, runs them, and reports which ones failed. There is no separate assertion library required — you compare values yourself with plain Go if statements and report failures through the *testing.T value that Go passes in.

The table-driven pattern layers on top of that basic mechanism. Instead of writing TestAddPositive, TestAddNegative, and TestAddZero as three near-identical functions, you write one function, define a slice of small structs where each struct holds the inputs and the expected result for one scenario, and loop over the slice calling the function under test once per row. Each row typically also carries a name field describing the scenario in plain English, which becomes the label Go prints when that case fails.

Most table-driven tests also wrap each row in t.Run(name, func(t *testing.T) {...}). This creates a subtest: a named, independently tracked test scoped to that one row. Under the hood, t.Run builds a fresh *testing.T for the subtest, runs the given function on the current goroutine (blocking until it returns), and records whether that subtest passed, failed, or was skipped. Subtests give you three practical benefits: failures are attributed to the exact row that failed, one failing row does not stop the others from running, and you can re-run a single case from the command line with go test -run 'TestAdd/two_positives'. If a subtest calls t.Parallel(), it instead pauses immediately, hands control back to the loop so the next row can start, and Go runs all the paused parallel subtests concurrently once the parent test function returns — a detail that matters for one of the classic mistakes below.

Method Purpose
t.Errorf(format, args...) Marks the (sub)test as failed and logs a message, but keeps executing the rest of that function.
t.Fatalf(format, args...) Marks the (sub)test as failed, logs a message, and stops that goroutine immediately.
t.Run(name, fn) Runs fn as a named subtest with its own pass/fail status.
t.Parallel() Marks the current subtest to run concurrently with other parallel subtests.
t.Helper() Marks the current function as a test helper so failure line numbers point at the caller.

Syntax

The general shape of a table-driven test looks like this:

func TestName(t *testing.T) {
	tests := []struct {
		name  string
		input int
		want  int
	}{
		{"case one", 1, 2},
		{"case two", 2, 4},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := double(tt.input)
			if got != tt.want {
				t.Errorf("double(%d) = %d; want %d", tt.input, got, tt.want)
			}
		})
	}
}
  • Anonymous struct slice[]struct{...}{...} defines the table inline; you rarely need a named type for it.
  • name field — a short description of the scenario; becomes the subtest name (spaces are replaced with underscores in output).
  • input/want fields — whatever arguments the function under test needs, plus the value(s) you expect back.
  • for _, tt := range tests — iterates the table; tt is conventional shorthand for "test case".
  • t.Run(tt.name, func(t *testing.T) {...}) — runs the row as a named subtest.

Examples

Example 1: Testing a Simple Add Function

The example below is not a real _test.go file — it is a runnable program that mimics the table-driven shape with plain fmt.Printf output, so you can see the pattern execute end to end.

package main

import "fmt"

func Add(a, b int) int {
	return a + b
}

func main() {
	type testCase struct {
		name string
		a, b int
		want int
	}

	cases := []testCase{
		{"positive numbers", 2, 3, 5},
		{"negative numbers", -2, -3, -5},
		{"zero", 0, 0, 0},
	}

	for _, tc := range cases {
		got := Add(tc.a, tc.b)
		status := "PASS"
		if got != tc.want {
			status = "FAIL"
		}
		fmt.Printf("%s: %s(a=%d, b=%d) got=%d want=%d\n", status, tc.name, tc.a, tc.b, got, tc.want)
	}
}

Output:

PASS: positive numbers(a=2, b=3) got=5 want=5
PASS: negative numbers(a=-2, b=-3) got=-5 want=-5
PASS: zero(a=0, b=0) got=0 want=0

Each row of cases supplies its own inputs and expected result. The loop body is identical for every row — only the data changes. That is the entire point of the pattern: the assertion logic is written once, and correctness for new inputs is added by appending a new struct literal, not new code.

Example 2: Testing a Function That Returns an Error

Real Go functions often return (value, error). A table-driven test handles this by adding a wantErr field to the row.

package main

import (
	"errors"
	"fmt"
)

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	type divCase struct {
		name    string
		a, b    int
		want    int
		wantErr bool
	}

	cases := []divCase{
		{"even division", 10, 2, 5, false},
		{"division by zero", 5, 0, 0, true},
		{"negative result", -9, 3, -3, false},
	}

	for _, tc := range cases {
		got, err := Divide(tc.a, tc.b)
		switch {
		case tc.wantErr && err == nil:
			fmt.Printf("FAIL: %s: expected error, got none\n", tc.name)
		case tc.wantErr && err != nil:
			fmt.Printf("PASS: %s: got expected error: %v\n", tc.name, err)
		case !tc.wantErr && err != nil:
			fmt.Printf("FAIL: %s: unexpected error: %v\n", tc.name, err)
		case got != tc.want:
			fmt.Printf("FAIL: %s: got=%d want=%d\n", tc.name, got, tc.want)
		default:
			fmt.Printf("PASS: %s: got=%d\n", tc.name, got)
		}
	}
}

Output:

PASS: even division: got=5
PASS: division by zero: got expected error: division by zero
PASS: negative result: got=-3

The wantErr field lets one table cover both the success path and the failure path of Divide. Notice that when an error is expected, the test does not also check got — the zero value returned alongside an error is not meaningful, so comparing it would only make the test brittle.

Example 3: The Real Idiom — testing.T and t.Run

In an actual project, the code above would be split into two files in the same package: add.go defines Add, and add_test.go tests it using the real testing package and named subtests.

package mathutil

import "testing"

func TestAdd(t *testing.T) {
	tests := []struct {
		name string
		a, b int
		want int
	}{
		{"two positives", 2, 3, 5},
		{"two negatives", -2, -3, -5},
		{"mixed signs", -2, 5, 3},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Add(tt.a, tt.b)
			if got != tt.want {
				t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
			}
		})
	}
}

Running go test -v against this file prints:

--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/two_positives (0.00s)
    --- PASS: TestAdd/two_negatives (0.00s)
    --- PASS: TestAdd/mixed_signs (0.00s)
PASS
ok  	mathutil	0.002s

Go turns each row’s name field into a subtest path, replacing spaces with underscores ("two positives" becomes two_positives). This is what lets you target one case directly with go test -run 'TestAdd/mixed_signs' instead of re-running the whole suite while debugging a single failure.

How It Works Step by Step

  1. go test compiles the package together with every _test.go file into a temporary test binary and runs it.
  2. Go finds every exported func TestXxx(t *testing.T) and calls it.
  3. Inside TestAdd, the tests slice literal is built — this is ordinary Go code, evaluated once, before the loop starts.
  4. The for range loop begins; for each row it calls t.Run(tt.name, fn).
  5. t.Run creates a child *testing.T scoped to that subtest and calls fn synchronously — the loop does not advance to the next row until the subtest function returns, unless that function calls t.Parallel().
  6. Inside the subtest, got := Add(tt.a, tt.b) runs the real code under test, and the result is compared against tt.want.
  7. A mismatch calls t.Errorf, which records the failure and the formatted message but lets the rest of the subtest function finish running.
  8. Once every subtest has run, the parent TestAdd is reported as failed if any subtest failed, and go test prints a final PASS or FAIL summary for the whole package.

Common Mistakes

Mistake 1: Capturing the Loop Variable with t.Parallel()

Adding t.Parallel() inside a table-driven subtest speeds up the suite by running rows concurrently — but it changes when the closure actually reads tt. t.Parallel() pauses the subtest and returns control to the loop immediately, so the loop can reach its next iteration before the paused subtest body has read tt.a, tt.b, or tt.want. On Go versions before 1.22, tt is one shared variable reused every iteration, so by the time the parallel subtests actually run, they may all observe the row from the last iteration.

func TestAddBuggy(t *testing.T) {
	tests := []struct {
		name       string
		a, b, want int
	}{
		{"one plus one", 1, 1, 2},
		{"two plus two", 2, 2, 4},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := Add(tt.a, tt.b)
			if got != tt.want {
				t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
			}
		})
	}
}

On affected Go versions, both subtests can end up testing {2, 2, 4}, silently skipping the first case entirely. The fix is to shadow tt with a fresh, per-iteration copy before the closure is created, which works correctly on every Go version, including 1.22+ where it is only defensive rather than strictly required:

func TestAddFixed(t *testing.T) {
	tests := []struct {
		name       string
		a, b, want int
	}{
		{"one plus one", 1, 1, 2},
		{"two plus two", 2, 2, 4},
	}

	for _, tt := range tests {
		tt := tt // per-iteration copy; needed for correctness on Go < 1.22
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := Add(tt.a, tt.b)
			if got != tt.want {
				t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
			}
		})
	}
}

Mistake 2: Comparing Slices or Structs with ==

Go's == operator only works on comparable types. Slices, maps, and structs that contain slice or map fields are not comparable, so trying to compare test output directly with == is a compile-time error, not a runtime surprise:

func TestGetNames(t *testing.T) {
	got := GetNames()
	want := []string{"ann", "bob"}
	if got == want {
		t.Errorf("GetNames() = %v; want %v", got, want)
	}
}

This fails to build with invalid operation: got == want (slice can only be compared to nil). The fix is reflect.DeepEqual, which recursively compares the elements of slices, maps, and structs:

func TestGetNames(t *testing.T) {
	got := GetNames()
	want := []string{"ann", "bob"}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("GetNames() = %v; want %v", got, want)
	}
}

Best Practices

  • Give every row a descriptive name — it becomes the subtest label in go test -v output and the string you pass to -run when debugging one case.
  • Prefer t.Errorf over t.Fatalf inside the loop so one failing row does not prevent the rest of the table from being checked in the same run.
  • Wrap each row in t.Run so failures are attributed to the exact case, and so cases can be re-run individually.
  • Shadow the loop variable (tt := tt) whenever a subtest calls t.Parallel(), even on Go 1.22+, for clarity and portability across versions.
  • Use reflect.DeepEqual (or a purpose-built diff helper) for slices, maps, and structs — never ==.
  • Pull repeated assertion logic into a small helper that calls t.Helper() first, so failures report the line in the table loop rather than inside the helper.
  • Keep table construction free of side effects; build any fixtures or mocks the test needs inside the t.Run closure so rows cannot interfere with each other.

Practice Exercises

  1. Write a table-driven test for a function IsEven(n int) bool. Cover a positive even number, a positive odd number, a negative even number, and zero.
  2. Extend the Divide example from Example 2 with a new row where a is 0 and b is a nonzero number. Work out by hand what want and wantErr should be before running it.
  3. Take the buggy TestAddBuggy function from Common Mistakes, add a third row, and rewrite it correctly with a per-iteration variable copy so every row is tested independently under t.Parallel().

Summary

  • Table-driven tests replace many near-identical test functions with one function and a slice of struct literals describing each scenario.
  • t.Run(name, func(t *testing.T) {...}) turns each row into a named, independently reported subtest.
  • t.Run runs subtests synchronously by default; t.Parallel() defers execution until the parent test function returns.
  • Combining t.Parallel() with a shared loop variable is a classic bug on Go versions before 1.22 — shadow the variable per iteration to stay safe on every version.
  • Slices, maps, and structs containing them cannot be compared with ==; use reflect.DeepEqual instead.
  • t.Errorf records a failure and continues; t.Fatalf records a failure and stops that goroutine immediately.