Day 16 — Table-driven tests

Updated

July 30, 2026

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.

Note

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 ./validate

Theory 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 -v readability

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 flakes

Labs

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/day16

Write table-driven tests for:

  1. Email-ish validation
  2. Password/min length
  3. Joined multi-error validation (errors.Join) using errors.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.out

Do 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.Run pattern from memory
  • Error cases use errors.Is / As
  • Helper uses t.Helper()
  • go test ./... green
  • Saw -cover output 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.