19. Testing in Go: table-driven tests and subtests
19.1 The convention
Test files end in _test.go, sit beside the code they test, and test
functions take *testing.T:
// storage_test.go
package storage
import "testing"
func TestSet(t *testing.T) {
s := New()
s.Set("key", "value")
got, ok := s.Get("key")
if !ok || got != "value" {
t.Fatalf("got (%q, %v), want (%q, true)", got, ok, "value")
}
}
19.2 Table-driven tests
The idiomatic way to cover many cases without duplicating test logic:
func TestParseSize(t *testing.T) {
tests := []struct {
name string
input string
want int64
wantErr bool
}{
{"plain bytes", "100", 100, false},
{"kilobytes", "10KB", 10 * 1024, false},
{"empty input", "", 0, true},
{"garbage unit", "10XB", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSize(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
t.Run gives each case its own named subtest — failures report exactly
which case broke (TestParseSize/garbage_unit), and -run TestParseSize/kilo re-runs just that one.
19.3 Test helpers, t.Helper(), and fixtures
func newTestStore(t *testing.T) *Store {
t.Helper() // failures blame the caller's line, not this one
s := New()
t.Cleanup(func() { s.Close() }) // runs after the test, even on failure
return s
}
19.4 Real-world example
Day 10's table-driven tests for the storage interface (empty key, oversized value, concurrent reads, deleting a missing key) is exactly the shape of a real PR's test suite — one table, one assertion block, four edge cases documented as data instead of four near-duplicate test functions.