60. Table-driven tests
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 variablett. - The struct is anonymous — declared inline, because it's used once.
- The first field is
name, and it goes tot.Run. - Field names
in/want(orargs/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.Fatalinside 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.