Day 16 — Table-driven tests
Day 16 — Table-driven tests
Stage II · ~3h (theory-heavy)
Goal: Write idiomatic table-driven tests with t.Run, shared helpers, clear failure messages, and the habits that make Stage II’s gate (and every later stage) regression-safe.
Go’s culture is tests next to code (foo_test.go), fast unit tests by default, and tables for combinatorial cases. You do not need a third-party assertion library—the stdlib testing package is enough.
Why this day exists
Untested validation and domain logic rots. Table-driven tests:
- Compress many cases into one structure
- Name cases for failures (
t.Run)
- Make new edge cases a one-line addition
- Document behavior better than prose alone
Day 16 is the testing floor; fuzzing and golden files come in Stage IV.
Theory 1 — Package layout for tests
validate/
email.go
email_test.go # package validate OR package validate_test
Package clause in _test.go |
Style |
|---|---|
package validate |
White-box: access unexported symbols |
package validate_test |
Black-box: only public API; import the package |
Both are idiomatic. Prefer black-box for public contracts; white-box for internal helpers.
go test ./...
go test -v ./validate
go test -run TestEmail ./validateTheory 2 — Basic test function
func TestAdd(t *testing.T) {
got := Add(1, 2)
want := 3
if got != want {
t.Fatalf("Add(1,2)=%d; want %d", got, want)
}
}| Method | Effect |
|---|---|
t.Error / t.Errorf |
Mark fail; continue |
t.Fatal / t.Fatalf |
Mark fail; stop this test function |
t.Log / t.Logf |
Print with -v |
t.Helper |
Mark caller as helper for line numbers |
t.Parallel |
Allow parallel with others that also call it |
t.Cleanup |
Register post-test cleanup |
t.TempDir |
Create temp directory (auto-cleaned) |
Theory 3 — Table-driven pattern
func TestAbs(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{name: "positive", in: 5, want: 5},
{name: "negative", in: -3, want: 3},
{name: "zero", in: 0, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Abs(tt.in)
if got != tt.want {
t.Fatalf("Abs(%d)=%d; want %d", tt.in, got, tt.want)
}
})
}
}Why t.Run?
- Isolates failures to a named case
- Allows
t.Parallel()per case carefully
- Improves
-vreadability
Naming cases
Prefer descriptive names: "empty email", "wraps ErrNotFound", not "test3".
Theory 4 — Error cases in tables
tests := []struct {
name string
in string
want int
wantErr error // sentinel or nil
}{
{name: "ok", in: "42", want: 42},
{name: "empty", in: "", wantErr: ErrEmpty},
{name: "not int", in: "x", wantErr: strconv.ErrSyntax}, // or check via errors.Is
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err=%v; want Is(%v)", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %d want %d", got, tt.want)
}
})
}Combine Day 13 (errors.Is/As) with tables—do not assert exact message strings unless the message is the API.
Theory 5 — Helpers
func assertNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
}
func assertEq[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v want %v", got, want)
}
}t.Helper() makes failure line numbers point at the call site in the test, not inside the helper.
cmp note
For complex structs, teams often use github.com/google/go-cmp. This volume prefers stdlib first; compare field-by-field or use maps for simplicity until Stage IV.
Theory 6 — Fixtures and files
func TestParseFile(t *testing.T) {
data, err := os.ReadFile("testdata/ok.txt")
assertNoErr(t, err)
// ...
}Convention: put fixtures under testdata/ (ignored by go build as a special name for tooling patterns).
dir := t.TempDir()
path := filepath.Join(dir, "out.txt")Prefer t.TempDir() over manual /tmp cleanup.
Theory 7 — What not to do
| Anti-pattern | Prefer |
|---|---|
| One mega test with 40 asserts | Table + t.Run |
| Sleeping for timing | Deterministic APIs; fake clocks later |
| Hitting real network by default | Interface + fake (Day 17) |
| Asserting log output formats loosely | Test return values |
| Sharing mutable global state across tests | Construct fresh subjects |
Worked examples bank
Example A — Validate email table
package validate_test
import (
"testing"
"example.com/day16/validate"
)
func TestEmail(t *testing.T) {
tests := []struct {
name string
in string
ok bool
}{
{"simple", "a@b.co", true},
{"empty", "", false},
{"no at", "ab.co", false},
{"spaces", "a @b.co", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Email(tt.in)
if tt.ok && err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !tt.ok && err == nil {
t.Fatal("expected error")
}
})
}
}Example B — Subtests with setup
func TestStore(t *testing.T) {
t.Run("add", func(t *testing.T) {
s := NewStore()
if err := s.Add("a", 1); err != nil {
t.Fatal(err)
}
})
t.Run("dup", func(t *testing.T) {
s := NewStore()
_ = s.Add("a", 1)
if err := s.Add("a", 1); err == nil {
t.Fatal("expected duplicate error")
}
})
}Each subtest gets a fresh store—no cross-talk.
Example C — Map of cases
cases := map[string]struct {
in string
want int
}{
"forty-two": {in: "42", want: 42},
"zero": {in: "0", want: 0},
}
for name, tt := range cases {
t.Run(name, func(t *testing.T) {
// ...
})
}Map iteration order is random—fine for independent cases; use slice if order matters for readability.
Example D — Helper with Helper()
func mustParse(t *testing.T, s string) int {
t.Helper()
n, err := strconv.Atoi(s)
if err != nil {
t.Fatalf("Atoi(%q): %v", s, err)
}
return n
}Example E — Run filter
go test -v -run 'TestEmail/empty' ./...
go test -count=1 ./... # disable cache when debugging flakesLabs
Suggested workspace: ~/lab/90daysofx/go/day16
Lab 1 — Test the Day 13 validators (or reimplement)
mkdir -p ~/lab/90daysofx/go/day16/validate
cd ~/lab/90daysofx/go/day16
go mod init example.com/day16Write table-driven tests for:
- Email-ish validation
- Password/min length
- Joined multi-error validation (
errors.Join) usingerrors.As/ field helpers
Lab 2 — Domain store tests
If you have inventory/todo types, test:
- Happy path add/get
- Missing key error (
errors.Is)
- Duplicate / insufficient quantity
Lab 3 — Coverage glance
go test -cover ./...
go test -coverprofile=cover.out ./...
go tool cover -func=cover.outDo not chase 100%. Cover the branches you care about. Note uncovered error paths and add one case.
Lab 4 — Black-box vs white-box
One test file as package validate_test. Confirm unexported helpers are inaccessible—test through exported API.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting t.Helper |
Line numbers mislead |
Asserting err.Error() strings |
Prefer Is/As |
| Shared mutable fixtures | Fresh per t.Run |
Not using -count=1 when chasing cache |
Disable cache |
| Huge tables without names | Always name field |
Testing through main only |
Export library API |
Checkpoint
- Table +
t.Runpattern from memory
- Error cases use
errors.Is/As
- Helper uses
t.Helper()
go test ./...green
- Saw
-coveroutput once
- Black-box test package tried
Commit
git add .
git commit -m "day16: table-driven tests for validate/domain"Tomorrow
Day 17 — Fakes vs mocks: hand-written fakes behind interfaces, when not to mock, and testing services without mock-generation frameworks.