Files
atelier/languages/go-testing.md
T
Jon Chery 4e433158cd docs(P03): complete language-derived extension — v0.4
---ci---
project: atelier
phase: 3
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.3
requirements:
  covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105]
  partial: []
---/ci---
2026-08-05 16:07:21 +00:00

6.7 KiB

Go Testing — Derived Application

Applies Atelier's domain principles to Go testing specifically. Derives from domains/ docs; introduces no new P-rules (D-063). See languages/go.md for the language first-principles stub.

Table-Driven Tests (Testing P1 Tests as Specification, C2 Clarity)

  • Table-driven is the Go idiom: cases := []struct{ name string; in X; want Y }{...}; loop with t.Run(c.name, ...). Each case is a subtest with its own name and failure output.
  • Test names read as a spec: {"rejects empty email", ...}, {"returns persisted id", ...}. A reader understands the unit from the subtest names (Testing P1).
  • No if got != want { t.Fatal() } shared across cases: each case asserts independently; a failure in case 3 does not skip cases 4 and 5.
  • t.Run enables -run filtering: go test -run TestCreateUser/rejects_empty_email runs one case. Essential for debugging a single failure.
func TestCreateUser(t *testing.T) {
    cases := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"rejects empty email", "", true},
        {"rejects missing @", "no-at-sign", true},
        {"accepts valid email", "a@b.co", false},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            _, err := CreateUser(c.email)
            if (err != nil) != c.wantErr {
                t.Fatalf("err=%v, wantErr=%v", err, c.wantErr)
            }
        })
    }
}

t.Parallel for Independence (Testing P2 Independence, Concurrency P10 Test for Race Conditions)

  • t.Parallel() for independent subtests: each subtest opts in; the runner executes them concurrently. A test that fails under Parallel has hidden state (Testing P2 Independence).
  • Capture loop variables: c := c inside the loop, or rely on Go 1.22+ per-iteration scoping. A parallel subtest sharing c races on the last value.
  • Applies concurrency/P10 (test for races): parallel tests are the first line of race detection; combine with -race for the full safety net.
for _, c := range cases {
    c := c  // capture for parallel
    t.Run(c.name, func(t *testing.T) {
        t.Parallel()
        _, err := CreateUser(c.email)
        if (err != nil) != c.wantErr {
            t.Fatalf("err=%v, wantErr=%v", err, c.wantErr)
        }
    })
}

t.Cleanup for Teardown (Testing P3 Determinism, Testing P2 Independence)

  • t.Cleanup(func() { ... }) for teardown: runs in LIFO order after the test (and its subtests) complete. Replaces defer in a helper that does not know when the test ends.
  • Per-test state, not shared: a setup(t) helper creates resources and registers cleanup; each test gets its own. A package-level var shared across tests is order coupling.
  • t.TempDir() for filesystem tests: creates a unique temp dir and cleans up automatically. No manual os.RemoveAll and no cross-test contamination.
  • Applies Testing P3 (determinism): cleanup is tied to the test lifecycle, not a global teardown that may run before or after depending on order.
func setupStore(t *testing.T) *Store {
    t.Parallel()
    dir := t.TempDir()  // auto-cleaned
    s, err := OpenStore(filepath.Join(dir, "db"))
    if err != nil { t.Fatal(err) }
    t.Cleanup(func() { s.Close() })
    return s
}

Race Detector (Testing P9 Edge Case Coverage, Concurrency P6 No Silent Races)

  • go test -race in CI, always: see go-tooling.md. The detector is the enforcement of concurrency/P6.
  • Tests must exercise the concurrent path: a serial test of a Mutex-protected map finds no race. Write tests with N goroutines.
  • -count=1 to disable result caching: by default, Go caches passing tests. -count=1 forces re-run; combine with -race and parallelism to surface heisenbugs.
  • Applies Testing P9 (edge case coverage): the race detector is the edge-case tool for concurrency — it finds the inputs the test author forgot to write.
# CI gate
go test -race -count=1 ./...

Time and Determinism (Testing P3 Determinism, Testing P9 Edge Case Coverage)

  • No time.Now() in code under test: inject a Clock interface. In tests, a fake clock advances deterministically.
  • time.Sleep in tests is a smell: a sleep waits for a real timer, flaky under load. Use a channel or Eventually-style polling with a timeout.
  • t.Deadline() aware helpers: a helper that may take long checks t.Deadline() and bails early. Prevents a slow test from timing out the suite.
type Clock interface { Now() time.Time }

type fakeClock struct{ t time.Time }
func (f *fakeClock) Now() time.Time { return f.t }

func TestUserHasCreatedAt(t *testing.T) {
    clk := &fakeClock{time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
    u, _ := CreateUserWithClock("a@b.co", clk)
    if u.CreatedAt.Year() != 2024 {
        t.Fatalf("year=%d, want 2024", u.CreatedAt.Year())
    }
}

Mocks and Interfaces (Testing P7 Realism, API P1 Contract Fidelity)

  • Mock at the interface, not the struct: type Store interface { Get(id string) (*User, error) } in production; type mockStore struct{ ... } in test. The interface is the contract (applies api/P1).
  • httptest for HTTP servers: httptest.NewServer gives a real server on a loopback port; no manual socket plumbing.
  • testify/mock or hand-written mocks: hand-written for one-off, testify for complex sequencing. Avoid mocking frameworks that generate code at runtime (reflection-heavy) — they hide failures behind stack traces.
  • Applies Testing P7 (realism): mock the boundary (HTTP, DB), not the unit. Mocking the unit under test tests the mock.
type mockStore struct {
    users map[string]*User
    got  []string
}
func (m *mockStore) Get(id string) (*User, error) {
    m.got = append(m.got, id)
    return m.users[id], nil
}

func TestGetUserLogs(t *testing.T) {
    s := &mockStore{users: map[string]*User{"abc": {}}}
    svc := NewService(s)
    svc.GetUser("abc")
    if len(s.got) != 1 || s.got[0] != "abc" {
        t.Fatalf("got=%v", s.got)
    }
}

Cross-References

  • domains/testing/pyramid.md — where unit/integration/race tests sit; the race job is its own layer.
  • domains/testing/fixtures.mdt.TempDir and t.Cleanup as the fixture discipline.
  • domains/testing/first-principles.md — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage.
  • domains/concurrency/first-principles.md — Concurrency P6 (race detector), P10 (test for races).
  • languages/go-types.md — the named types tests assert.
  • languages/go-concurrency.md — concurrent tests exercise the patterns from that doc.
  • languages/go-tooling.md — the go test flags (-race, -count, -run) detailed here.