60. Table-driven tests

📖 Reading · 13 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

The dominant Go testing style, used throughout the standard library itself: put your cases in a slice of structs, loop over them, and run each as a subtest. Adding a case becomes one line.

The pattern

func TestSlugify(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {"simple", "Hello World", "hello-world"},
        {"already lower", "go lang", "go-lang"},
        {"punctuation", "Go, Fast!", "go-fast"},
        {"empty", "", ""},
        {"unicode", "Café Life", "café-life"},
    }

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

Five distinct behaviours, one assertion. That's the appeal: the logic of the test is written once, and each case is pure data.

Conventions worth copying exactly:

  • The slice is called tests, the loop variable tt.
  • The struct is anonymous — declared inline, because it's used once.
  • The first field is name, and it goes to t.Run.
  • Field names in/want (or args/want) so every reader recognises the shape.

Subtests with t.Run

t.Run(name, func(t *testing.T) { ... }) creates a named subtest, and that buys you real things:

$ go test -v -run TestSlugify
=== RUN   TestSlugify
=== RUN   TestSlugify/simple
=== RUN   TestSlugify/punctuation
--- FAIL: TestSlugify (0.00s)
    --- PASS: TestSlugify/simple (0.00s)
    --- FAIL: TestSlugify/punctuation (0.00s)
        slug_test.go:21: Slugify("Go, Fast!") = "go,-fast!"; want "go-fast"

$ go test -run 'TestSlugify/punctuation'    # run just the failing case
  • Each case reports individually, so one failure doesn't hide the others.
  • You can run one case by name from the command line.
  • A t.Fatal inside a subtest stops only that case.

Without t.Run, a t.Fatalf in the third case would skip cases four and five and you'd fix bugs one run at a time.

Testing errors in a table

Add a wantErr field:

func TestParseAge(t *testing.T) {
    tests := []struct {
        name    string
        in      string
        want    int
        wantErr bool
    }{
        {"valid", "36", 36, false},
        {"zero", "0", 0, false},
        {"not a number", "abc", 0, true},
        {"negative", "-5", 0, true},
        {"empty", "", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseAge(tt.in)

            if (err != nil) != tt.wantErr {
                t.Fatalf("ParseAge(%q) error = %v; wantErr %t", tt.in, err, tt.wantErr)
            }
            if err != nil {
                return          // expected failure; nothing more to check
            }
            if got != tt.want {
                t.Errorf("ParseAge(%q) = %d; want %d", tt.in, got, tt.want)
            }
        })
    }
}

(err != nil) != tt.wantErr reads awkwardly but is exactly right: it fails both when you got an unexpected error and when you didn't get an expected one.

When the kind of error matters, use a wantErr error field and errors.Is(err, tt.wantErr) instead of a bool.

Seeing it run

The lesson boxes can't run go test, so here's the same table with the assertions written out — the structure is identical to what you'd put in a _test.go file:

package main

import (
    "fmt"
    "strings"
)

func Slugify(s string) string {
    s = strings.ToLower(strings.TrimSpace(s))
    var b strings.Builder
    prevDash := false
    for _, r := range s {
        switch {
        case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
            b.WriteRune(r)
            prevDash = false
        case r == ' ' || r == '-' || r == '_':
            if !prevDash && b.Len() > 0 {
                b.WriteByte('-')
                prevDash = true
            }
        }
    }
    return strings.TrimSuffix(b.String(), "-")
}

func main() {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {"simple", "Hello World", "hello-world"},
        {"punctuation", "Go, Fast!", "go-fast"},
        {"extra spaces", "  spaced   out  ", "spaced-out"},
        {"empty", "", ""},
    }

    failed := 0
    for _, tt := range tests {
        got := Slugify(tt.in)
        if got != tt.want {
            failed++
            fmt.Printf("--- FAIL: %s\n    Slugify(%q) = %q; want %q\n", tt.name, tt.in, got, tt.want)
            continue
        }
        fmt.Printf("--- PASS: %s\n", tt.name)
    }
    fmt.Printf("%d passed, %d failed\n", len(tests)-failed, failed)
}

Try breaking Slugify — remove the TrimSuffix, say — and watch which cases report. That feedback loop is the whole point of a table.

Comparing structs and slices

== doesn't work on slices or maps, so tests reach for reflect.DeepEqual:

package main

import (
    "fmt"
    "reflect"
)

type User struct {
    Name string
    Tags []string
}

func main() {
    a := User{Name: "Ada", Tags: []string{"go", "math"}}
    b := User{Name: "Ada", Tags: []string{"go", "math"}}
    c := User{Name: "Ada", Tags: []string{"go"}}

    fmt.Println(reflect.DeepEqual(a, b))
    fmt.Println(reflect.DeepEqual(a, c))
    fmt.Println(reflect.DeepEqual([]int{1, 2}, []int{1, 2}))
    fmt.Println(reflect.DeepEqual(map[string]int{"a": 1}, map[string]int{"a": 1}))
}

In a test:

if !reflect.DeepEqual(got, tt.want) {
    t.Errorf("Parse(%q) = %+v; want %+v", tt.in, got, tt.want)
}

Two caveats: DeepEqual treats a nil slice and an empty slice as different, and it's slow. Many teams use github.com/google/go-cmp instead — cmp.Diff(want, got) prints a readable diff rather than a wall of two structs, which matters a lot when the struct is big.

Parallel subtests

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

t.Parallel() lets subtests run concurrently — useful when each is slow (network, sleeps). Two warnings: the cases must be genuinely independent, and on Go 1.21 and earlier you needed tt := tt before the closure or every subtest would see the last case. Go 1.22's per-iteration loop variables fixed that, but you'll see tt := tt in a lot of existing code.

Fuzzing, briefly

Go can generate inputs for you:

func FuzzSlugify(f *testing.F) {
    f.Add("Hello World")           // seed corpus
    f.Add("")

    f.Fuzz(func(t *testing.T, in string) {
        got := Slugify(in)
        if strings.Contains(got, " ") {
            t.Errorf("Slugify(%q) = %q; contains a space", in, got)
        }
    })
}
$ go test -fuzz FuzzSlugify

Instead of asserting exact outputs, you assert properties that must always hold — no spaces, never longer than the input, round-trips cleanly. Go mutates inputs looking for a violation and saves any crasher it finds as a regular test case. It's the fastest way to find panics in parsers.

Your turn

Complete the table so all four cases pass. Reverse("") is "" and Reverse("ab") is "ba":

--- PASS: simple
--- PASS: single
--- PASS: empty
--- PASS: palindrome
4 passed, 0 failed
package main

import "fmt"

func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

func main() {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {"simple", "hello", ""},
        {"single", "x", ""},
        {"empty", "", ""},
        {"palindrome", "racecar", ""},
    }
    // fill in the want values, then loop and report PASS/FAIL per case
}
package main

import "fmt"

func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

func main() {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {"simple", "hello", "olleh"},
        {"single", "x", "x"},
        {"empty", "", ""},
        {"palindrome", "racecar", "racecar"},
    }

    failed := 0
    for _, tt := range tests {
        got := Reverse(tt.in)
        if got != tt.want {
            failed++
            fmt.Printf("--- FAIL: %s\n    Reverse(%q) = %q; want %q\n", tt.name, tt.in, got, tt.want)
            continue
        }
        fmt.Printf("--- PASS: %s\n", tt.name)
    }
    fmt.Printf("%d passed, %d failed\n", len(tests)-failed, failed)
}

Next: measuring instead of guessing.