Day 7 — Testing: Table-Driven, Fakes & Stage II Gate

Updated

July 30, 2025

Day 7 — Testing: Table-Driven, Fakes & Stage II Gate

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.


Day 17 — Fakes vs mocks

Stage II · ~3h (theory-heavy)
Goal: Drive tests with hand-written fakes behind small interfaces—distinguish fakes, stubs, mocks, and spies—and know when a real in-memory implementation is better than a mock framework.

Note

This volume prefers stdlib + hand fakes. Mock-generation tools exist; you do not need them to write excellent Go tests. Interfaces (Day 11) are the seam.

Why this day exists

Unit tests that hit the network, real clocks, or production databases are slow and flaky. The Go way:

  1. Depend on interfaces at the boundary
  2. Supply a fake in tests
  3. Keep fakes stateful and simple, not hyper-configured mock DSLs

Day 17 builds taste so Day 18’s module ships with tests others trust.


Theory 1 — Vocabulary

Term Meaning Typical use
Stub Returns canned values; little behavior “Always return this user”
Fake Working simplified implementation In-memory DB, map store
Mock Pre-programmed expectations; fails if calls differ Verify interaction order
Spy Records calls for later assertions “Was Save called once?”

In Go conversations, people often say “mock” for any test double. Prefer fake for map-backed stores—you will write those most.


Theory 2 — Interface as seam (review with purpose)

type UserRepository interface {
    Find(ctx context.Context, id string) (User, error)
    Save(ctx context.Context, u User) error
}

type Service struct {
    users UserRepository
    // mail Mailer
}

func (s *Service) Rename(ctx context.Context, id, name string) error {
    u, err := s.users.Find(ctx, id)
    if err != nil {
        return err
    }
    u.Name = name
    return s.users.Save(ctx, u)
}

Production wires Postgres; tests wire FakeUsers.


Theory 3 — Writing a good fake

type FakeUsers struct {
    ByID map[string]User
    // optional behavior knobs:
    FindErr error
    SaveErr error
}

func NewFakeUsers(users ...User) *FakeUsers {
    f := &FakeUsers{ByID: make(map[string]User)}
    for _, u := range users {
        f.ByID[u.ID] = u
    }
    return f
}

func (f *FakeUsers) Find(_ context.Context, id string) (User, error) {
    if f.FindErr != nil {
        return User{}, f.FindErr
    }
    u, ok := f.ByID[id]
    if !ok {
        return User{}, ErrNotFound
    }
    return u, nil
}

func (f *FakeUsers) Save(_ context.Context, u User) error {
    if f.SaveErr != nil {
        return f.SaveErr
    }
    if f.ByID == nil {
        f.ByID = make(map[string]User)
    }
    f.ByID[u.ID] = u
    return nil
}

var _ UserRepository = (*FakeUsers)(nil)

Properties of good fakes

  • Implement the same interface as production
  • Stateful enough to exercise real logic paths
  • Failure injection via fields (SaveErr)
  • No network, no sleep
  • Not more complex than the code under test

Theory 4 — Spy: record interactions

type SpyMailer struct {
    Messages []string
    Err      error
}

func (s *SpyMailer) Send(_ context.Context, to, body string) error {
    if s.Err != nil {
        return s.Err
    }
    s.Messages = append(s.Messages, to+":"+body)
    return nil
}

Assert:

if len(mail.Messages) != 1 {
    t.Fatalf("got %d mails", len(mail.Messages))
}

Use spies when side effects matter and the fake would otherwise hide them.


Theory 5 — When mocks become harmful

Smell Problem
Expect exact call order of every dependency Brittle; refactors break tests
Mocking concrete structs with codegen Heavy; interfaces were the point
200-line mock setup Design may need smaller interfaces
Asserting implementation details Test behavior/outcomes

Prefer: arrange fakes → act on service → assert state and returned errors.


Theory 6 — What not to fake

Keep real Why
Pure functions No seam needed
Stdlib parsers (strconv, json for your types) Already deterministic
Your own in-memory algorithms You are testing them
Fake Why
DB / HTTP / disk (sometimes) Speed/isolation
Clocks Determinism (interface Now() time.Time)
Email / SMS No external side effects
Randomness Seed or inject source

Theory 7 — Context in signatures (light)

Even if you ignore cancellation today, matching production signatures with context.Context as first param keeps fakes honest:

Find(ctx context.Context, id string) (User, error)

Deep context usage is Stage III. Today: accept ctx, ignore or check ctx.Err() optionally.


Worked examples bank

Example A — Service test with fake

func TestRename(t *testing.T) {
    repo := NewFakeUsers(User{ID: "1", Name: "Ada"})
    svc := NewService(repo)

    if err := svc.Rename(context.Background(), "1", "Augusta"); err != nil {
        t.Fatal(err)
    }
    u, err := repo.Find(context.Background(), "1")
    if err != nil {
        t.Fatal(err)
    }
    if u.Name != "Augusta" {
        t.Fatalf("name=%q", u.Name)
    }
}

Example B — Error path injection

func TestRenameSaveFails(t *testing.T) {
    repo := NewFakeUsers(User{ID: "1", Name: "Ada"})
    repo.SaveErr = errors.New("disk full")
    svc := NewService(repo)

    err := svc.Rename(context.Background(), "1", "X")
    if err == nil {
        t.Fatal("expected error")
    }
}

Example C — Not found

func TestRenameMissing(t *testing.T) {
    svc := NewService(NewFakeUsers())
    err := svc.Rename(context.Background(), "nope", "X")
    if !errors.Is(err, ErrNotFound) {
        t.Fatalf("got %v", err)
    }
}

Example D — Clock injection

type Clock interface {
    Now() time.Time
}

type fixedClock struct{ t time.Time }

func (f fixedClock) Now() time.Time { return f.t }

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

Example E — Avoid over-mocking

// Weak test: only checks that Find was called — misses rename logic bugs.
// Strong test: checks saved user name and errors.Is paths.

Labs

Suggested workspace: ~/lab/90daysofx/go/day17

Lab 1 — Service + fake repository

mkdir -p ~/lab/90daysofx/go/day17
cd ~/lab/90daysofx/go/day17
go mod init example.com/day17

Build:

  1. Domain entity + repository interface
  2. Service methods that use the interface (at least two paths: success + error)
  3. FakeX with map storage + error injection
  4. Table-driven tests on the service

Lab 2 — Spy side effect

Add a Notifier interface. On successful rename (or create), notify. Spy records messages. Assert one notification on success and zero on failure.

Lab 3 — Compile-time check

var _ UserRepository = (*FakeUsers)(nil)
var _ UserRepository = (*PostgresUsers)(nil) // if you stub a type with panic methods

A skeleton Postgres type with panic("not implemented") methods is optional; the point is the interface contract.

Lab 4 — Judgment notes

In NOTES.md:

  • What you faked and why
  • What you kept real
  • One mock-style assertion you avoided

Common gotchas

Gotcha Fix
Fake that doesn’t implement full interface var _ I = (*Fake)(nil)
Shared fake state across subtests New fake per t.Run
Testing the fake more than the service Keep fakes thin
Interface too large to fake Split interface
Real sleep/network in unit tests Inject deps
Asserting call counts only Assert outcomes

Checkpoint

  • Fake implements production interface
  • Service tests use fake only
  • Error injection covered
  • Spy or notification assert optional but understood
  • Vocabulary: fake vs mock
  • Notes on judgment

Commit

git add .
git commit -m "day17: fakes spies service tests"

Tomorrow

Day 18 — Stage II gate: publishable-shaped module with public API, tests (tables + fakes), README, and idiomatic errors—ready to open Stage III concurrency.


Day 18 — Stage II gate

Stage II · ~3h (integration)
Goal: Ship a publishable-shaped Go module: clear public API, table-driven tests, at least one interface seam with a fake, idiomatic errors (Is/As/Join as needed), README, and go test ./... green on Go 1.26.

Important

Stage II gate is about library quality, not product breadth. A small well-tested package beats a sprawling untested app. CLI wrappers are optional glue.

Why this day exists

Stage II skills—interfaces, assertions, errors, modules, tests, fakes—only count when integrated:

  • Others can import your package
  • Tests document and protect behavior
  • Errors are inspectable
  • Layout matches module norms

Pass this gate before concurrency multiplies complexity.


Theory 1 — Definition of done

Requirement Evidence
Module path Sensible go.mod (example.com/day18/... fine for lab)
Public API Exported types/funcs with godoc comments
Tests go test ./... pass; tables for core logic
Seam Interface + fake or pure package easily tested without I/O
Errors Sentinels and/or typed errors; no panics for input
README Install/use/examples/exit or API notes
Toolchain Builds on Go 1.26

Explicit non-goals

  • No race-heavy concurrency requirement
  • No external cloud services
  • No mock codegen frameworks
  • No perfect semantic version publish to a real VCS host required (shape only)

Theory 2 — Project menu (pick one)

B — Domain service module

Extract Day 10/11 inventory or todo into:

module/
  go.mod
  README.md
  todo/           # public API
  todo/fake/      # or todo/mem.go fake store
  cmd/todo/       # optional

Service tests use fakes.

C — Parsing library

Parse a small language: KEY=VALUE env files, or log lines KIND payload.

  • Exported Parse
  • Typed errors for syntax
  • Golden-ish tables of inputs

D — Retry / backoff helper (stdlib only)

func Do(ctx context.Context, attempts int, fn func() error) error

Fake clock optional; tests inject failing fn closures.


Theory 3 — Suggested layout

day18/
  go.mod
  README.md
  <lib>/
    doc.go          # package comment
    api.go
    errors.go
    api_test.go
  cmd/<tool>/main.go   # optional

Package comment

// Package validate implements small composable validators with inspectable errors.
package validate

Keep main thin

If CLI exists, it only maps flags → library → exit codes.


Theory 4 — API design checklist

  1. Minimal exports — unexport helpers
  2. Errors are part of API — document sentinels
  3. Accept interfaces, return concrete where it helps
  4. Context first if any I/O or cancellation
  5. No global mutable state
  6. Examples in README that match tests
// ErrNotFound is returned when an item does not exist.
var ErrNotFound = errors.New("not found")

Theory 5 — Test bar for the gate

Minimum:

  • ≥ 1 table-driven test with ≥ 4 cases
  • ≥ 1 error-path assertion using errors.Is or errors.As
  • Fresh subjects per subtest (no leaked state)
  • go test ./... clean

Stretch:

  • Fake behind interface with service test
  • -cover glance; note gaps
  • testdata/ fixture

Theory 6 — README shape

# module name

What problem it solves (3–5 sentences).

## Install

go get example.com/your/module@latest   # or clone path for lab

## Example

Short Go snippet using the public API.

## Errors

Which sentinels / types callers should check.

## Develop

go test ./...

For lab modules not published, document go test and local replace/go work if used.


Worked examples bank

Example A — Public validation API sketch

package validate

import "errors"

var ErrInvalid = errors.New("invalid")

type Result struct {
    // optional structured outcome
}

func Email(s string) error { /* ... */ }

func Check(email, password string) error {
    return errors.Join(Email(email), Password(password))
}

Example B — Test excerpt

func TestCheck(t *testing.T) {
    tests := []struct {
        name, email, pass string
        wantFields        []string
    }{
        {"ok", "a@b.co", "password1", nil},
        {"both bad", "", "x", []string{"email", "password"}},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := Check(tt.email, tt.pass)
            // assert fields via helper walking Join
            _ = err
        })
    }
}

Example C — Exit mapping CLI

func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        if errors.Is(err, validate.ErrInvalid) {
            os.Exit(1)
        }
        os.Exit(2)
    }
}

Example D — Self-review questions

  1. Can a stranger use the package from README alone?
  2. Are failure modes tested?
  3. Any panic left for user input?
  4. Is go.mod free of accidental replace?

Labs

Suggested workspace: ~/lab/90daysofx/go/day18

Lab 1 — Build the gate module

mkdir -p ~/lab/90daysofx/go/day18
cd ~/lab/90daysofx/go/day18
go mod init example.com/day18

Implement your chosen project to the definition of done.

Lab 2 — Test suite pass

go test ./...
go test -cover ./...

Fix until green. Add one case that failed during development (regression).

Lab 3 — README + example

Write README. If time allows, add ExampleCheck in _test.go for go test example output (optional func ExampleXxx()).

func ExampleEmail() {
    fmt.Println(Email("a@b.co") == nil)
    // Output:
    // true
}

Lab 4 — Gate retrospective

Write RETRO.md (10–15 lines):

  • What Stage II skill was hardest
  • What you would refactor before Stage III
  • One bug tests caught

Common gotchas

Gotcha Fix
Logic only in main Move to library package
Tests missing error paths Add Is/As cases
README lists features you did not ship Document reality
Left replace in go.mod Remove
Panic on bad input Return error
Interface without test double Fake or drop interface

Checkpoint

  • go test ./... green on 1.26
  • Public API documented
  • Table-driven tests present
  • Idiomatic errors
  • README usable
  • Retro written
  • Ready for goroutines without unfinished Stage II debt

Commit

git add .
git commit -m "day18: stage II gate module + tests"

Tomorrow

Stage III begins — Day 19 Goroutines: the concurrency model (G/M/P light), launching work safely, and why “just add go” is not a strategy—without channels yet until Day 20.