Code of the Day
IntermediatePackages & testing

Table-driven tests

Write concise, comprehensive tests with the table-driven pattern — subtests, t.Run, t.Parallel, and benchmarks.

GoIntermediate11 min read
Recommended first
By the end of this lesson you will be able to:
  • Write a table-driven test using a slice of struct test cases
  • Run subtests with t.Run to get individual named results
  • Mark subtests safe to run in parallel with t.Parallel
  • Write a BenchmarkXxx function and interpret go test -bench output
  • Explain what b.N is in a benchmark loop

The most common complaint about test code is duplication: you write the same assertion logic ten times with ten different inputs. Go's answer is the — a single test function driven by a slice of cases. It is so prevalent in the Go standard library and ecosystem that it is considered idiomatic. Once you adopt it, you will rarely write repetitive tests again.

The table-driven pattern

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"positive", 2, 3, 5},
        {"zero", 0, 0, 0},
        {"negative", -1, -2, -3},
    }

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

Key points:

  • The table is an anonymous struct slice. Fields match what the test needs.
  • t.Run(name, func) creates a subtest — a named, individually reportable unit.
  • Adding a new case is one line in the table, not a new test function.

Reading subtest output

With -v, each subtest prints its name:

--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/positive (0.00s)
    --- PASS: TestAdd/zero (0.00s)
    --- PASS: TestAdd/negative (0.00s)

You can run a single subtest by name:

go test -run TestAdd/positive ./...

The / separator works as a matcher between the test name and the subtest name. This is invaluable when debugging a specific failing case.

t.Parallel — running subtests concurrently

Mark a subtest as safe to run in parallel with other parallel subtests:

for _, tc := range tests {
    tc := tc   // capture range variable — essential!
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // ... test body ...
    })
}

The tc := tc line captures the loop variable inside the closure. Without it, all subtests share the same tc reference and will likely operate on the last element in the table by the time they run. This is one of Go's most common concurrency bugs in test code. (In Go 1.22+ the loop variable semantics changed, but capturing explicitly is still the safe habit.)

Parallel subtests speed up long test suites significantly when the test cases are independent. Don't use t.Parallel() when subtests share mutable state.

Benchmarks

A benchmark function starts with Benchmark and takes *testing.B:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

b.N is chosen automatically by the testing framework: it increases the number until the benchmark runs long enough for a stable measurement.

Run benchmarks explicitly (they are skipped by default):

go test -bench=. ./...
go test -bench=BenchmarkAdd -benchmem ./...

Typical output:

BenchmarkAdd-8    1000000000    0.308 ns/op
  • 1000000000 — how many iterations ran.
  • 0.308 ns/op — average time per iteration.
  • -benchmem adds B/op (bytes allocated per operation) and allocs/op.

Benchmarks measure relative performance. The absolute numbers vary by machine. What matters is the ratio before and after an optimisation, run on the same hardware. Always run benchmarks multiple times and check for variance.

Resetting the timer

If your benchmark has setup that should not count toward the time:

func BenchmarkProcess(b *testing.B) {
    data := generateLargeDataset()   // setup
    b.ResetTimer()                    // start timing from here
    for i := 0; i < b.N; i++ {
        Process(data)
    }
}

b.ResetTimer() discards the elapsed time up to that point so setup does not skew results.

Check your understanding

Knowledge check

  1. 1.
    In a range loop over test cases before Go 1.22, why do you write tc := tc inside the loop before calling t.Run?
  2. 2.
    In a benchmark loop (for i := 0; i < b.N; i++), what determines the value of b.N?
  3. 3.
    Benchmark functions run automatically when you run go test ./...

Do it yourself

Write a table-driven test for a Contains(s, substr string) bool function with at least four cases (empty string, match at start, match at end, no match). Run with -v to see the subtest names, then add one more case:

go test -v -run TestContains ./...

Where to go next

The lab consolidates package visibility, module versioning, table test structure, and benchmark interpretation through scenario questions.

Finished reading? Mark it complete to track your progress.

On this page