4e433158cd
---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---
141 lines
6.7 KiB
Markdown
141 lines
6.7 KiB
Markdown
# 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.
|
|
|
|
```go
|
|
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.
|
|
|
|
```go
|
|
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.
|
|
|
|
```go
|
|
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.
|
|
|
|
```bash
|
|
# 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.
|
|
|
|
```go
|
|
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.
|
|
|
|
```go
|
|
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.md` — `t.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. |