--- name: writing-gotest-tests description: Use when writing, fixing, reviewing, or restructuring tests in a Go repository that uses the gotest framework (github.com/mvrahden/go-test) — `*TestSuite` structs, `gotest.T`, `BeforeEach`/`AfterAll` hooks, `*Fixture` types, or the `gotest` CLI — when migrating testify or stdlib tests to gotest, and when setting up or fixing CI for a gotest repository. --- # Writing gotest tests gotest inverts habits learned from stdlib/testify. Follow this file's rules; consult `reference/` only when the task touches that area. **Version check (do this FIRST):** run `go tool gotest version` (after Bootstrap below); `dev (replace directive)` / `dev (source checkout)` mean a source build — assume current behavior. Before Bootstrap, read go.mod instead, remembering that a `replace` line overrides the `require` version. This skill describes **v1.32**. On an older release, read `reference/versions.md` before writing anything: it lists what each older release lacks or does differently, including rules below that fail there (on v1.25.x, rules 1, 3 and 4). A rule tagged **v1.27+** (or later) needs that release; skip it on older ones. ## Bootstrap The repo has the *library*; the CLI runs via Go's tool directive (requires Go ≥ 1.25; v1.30+ requires Go ≥ 1.26). One-time: ```sh go get -tool github.com/mvrahden/go-test/cmd/gotest@$(go list -m -f '{{.Version}}' github.com/mvrahden/go-test) go tool gotest version ``` Keep the `@version`: without it `go get -tool` upgrades the library pin, which setup must never do (`@latest` only in a project with no pin yet). In a vendored module (`vendor/modules.txt`) the go.mod edit breaks every build until `go mod vendor` reruns, and gotest cannot run vendored at all (the overlay's `pkg/gotestruntime` import is never vendored) — report, do not work around. Then every command is `go tool gotest `; if Go reports the short name ambiguous, use `go tool github.com/mvrahden/go-test/cmd/gotest`. Never a global `go install` binary: it drifts from the pin and from the module's Go, and releases after v1.28.1 refuse to run on either drift (`FAIL:` naming the version or the Go it was built with, exit 2). That refusal is never a fault in the tests; switch to `go tool gotest` instead of bumping the pin. (Bare `go run github.com/mvrahden/go-test/cmd/gotest` fails on fresh consumers: module pruning leaves the CLI's deps out of go.sum.) In a `go.work` workspace the tool declared in any `use` module works from the root and inside each module, at the highest pin. `./...` is rejected at the root, so name each module (`go tool gotest ./svc/... ./lib/...`) or run inside one. The `go.work` go line must be at least the modules' go lines. ## The Two Runners — a complete run is BOTH commands `go test` ignores gotest suites (signature incompatibility, by design). `gotest` runs suites and never runs stdlib tests — it prints `[no suites]` plus a stderr note when it skips them. Running only one command silently misses tests. Always finish with: ```sh go tool gotest ./... go test ./... ``` Add `-race` to both before calling anything done. ## The write loop Write → lint → both runners → fix: ```sh go tool gotest lint -fix ./... go get -tool golang.org/x/tools/cmd/goimports go tool goimports -l . ``` `lint -fix` applies suggested fixes as TEXTUAL edits — no formatting pass runs. A fix that strands or misses an import leaves the file uncompilable: install goimports once via the tool directive (as above — plain `go run` of it fails on missing go.sum entries), then always run `go tool goimports -w .` after `-fix` and re-run the loop. The linter catches direct misuse (`t.T()` escapes even inside closures, outer-`t` in poll callbacks, testify idioms, focus leftovers, and `fail-guard`: any `if cond { gotest.Fail(t, …) }` or `if err != nil { t.Fatal(err) }` guard — assertions halt on failure, so state them directly: `gotest.NoError(t, err)`, never a guarded fail). Fixes can compose — a rewritten guard may itself be simplifiable — so re-run `lint -fix` until it reports nothing. When two findings share a construct the linter reports only the stronger one (integrity over style); fix what it says before expecting style suggestions there. Suppression follows rule tiers: integrity rules (`poll-scope`, `suite-lifecycle`, `focus`, …) accept only per-line `//nolint:`; style and migration rules can also be skipped project-wide via `.gotest.yml` `lint.skip` or `-skip-` flags. The `stdlib-test` rule flags every stdlib `TestXxx(*testing.T)` — when a stdlib test is intentional (assertion-layer tests, benchmarks-adjacent code), mark its package clause with `//nolint:stdlib-test`, or the lint run fails. The linter does NOT catch: plain `defer` cleanup, shared mutable state in parallel suites, or structural problems — those are your job, below. ## Core rules 1. **Suites are structs, naming is the API.** `type XxxTestSuite struct{}`, exported, methods `func (s *X) TestBehavior(t *gotest.T)`. No `TestMain`, no registration — the CLI generates the harness invisibly (never commit the `gotest_psuite_test.go`/`gotest_pxsuite_test.go` files `gotest generate` writes; `gotest clean` removes them). `F_`/`X_` prefixes focus/exclude; `Test*Async(t, done)` declares async tests. 2. **Lifecycle hooks own resources.** Setup in `BeforeEach` (or `BeforeAll` for expensive read-only state), teardown in `AfterEach`/`AfterAll` as suite fields — NEVER `defer` or `t.T().Cleanup` in a test method. A plain `defer` lints clean and is still wrong: it skips teardown verification and blocks parallelization. 3. **Do not imitate existing tests blindly.** Observed failure: agents copy a repo's existing `SuiteConfig()` verbatim, propagating anti-patterns. A `SuiteConfig()` marker states *intent*: omit it entirely for defaults. A duration the marker leaves at zero gets the default, as if the marker were absent; `gotest.NoDeadline` disables one. On v1.26–v1.29 a zero/omitted duration meant NO deadline instead — compose onto a preset there. 4. **Ask "why is this suite NOT parallel?"** Observed failure: agents never parallelize unprompted, even when asked to improve tests. The recipe: ```go func (s *ShopTestSuite) SuiteConfig() gotest.SuiteConfig { cfg := gotest.DefaultSuiteConfig() cfg.Parallel = true return cfg } type shopCtx struct{ inv *Inventory } func (s *ShopTestSuite) BeforeEach(t *gotest.T) *shopCtx { return &shopCtx{inv: NewInventory()} } ``` Every test method then takes `(t *gotest.T, ctx *shopCtx)` — the generator enforces this. Legitimate reasons to stay sequential: `Setenv` (panics in parallel tests), shared live resources without per-test keys/schemas (`-race` is process-local and cannot see datastore contention — it is necessary, not sufficient). 5. **Poll, never sleep.** `gotest.Eventually(t, waitFor, tick, func(poll *gotest.R) { ... })` — assert a stable fixed point, not a transient state. The callback's `poll` handle has only `Errorf`/`FailNow`/ `Failed`/`Message`; pass `poll` (not `t`) to assertions inside it. 6. **Never call `t.T().Helper()`** — call sites resolve automatically; the linter flags it. Reach for `t.T()` only when nothing on `gotest.T` (`It`, `When`, `Context`, `TempDir`, `Setenv`, `Skipf`, `Errorf`, `FailNow`) covers the need. 7. **Ask "why is this wall-clock-asserting suite NOT Exclusive?" (v1.27+)** — the counterpart to rule 4. A suite whose assertions or timeout budgets measure elapsed time (latency bounds, timing budgets, contended ports/containers) cannot share a saturated machine: mark it `SuiteConfig{Exclusive: true}` (statically parsed like `Parallel` — boolean literal only; see `reference/config.md`) and it dispatches strictly alone after all other suites finish. A budget verdict taken under load is not a verdict you can act on. 8. **Write the condition, not the connective (v1.29+).** `t.When("email is valid")`, never `t.When("when email is valid")`; `t.It("creates the user")`, never `t.It("it creates the user")`. The spec renders every `When` label as `when ` on every surface (terminal, JSON, discovery, the editor's tree and Spec View) and the ✓/✗ glyph plays the role of "it", so the word in the source is said twice. A `When` that opens with its own connective (`with …`, `given …`, `after …`, `if …`, …) is rendered as written. The `behavior-wording` rule flags the redundant word and `lint -fix` drops it. Subtest *names* never carry the connective — `-run` filters and snapshot keys are unaffected. 9. **Fuzz targets are suite methods, and they assert a property (v1.29+).** `func (s *XTestSuite) FuzzParse(f *gotest.F)`: `f.Add` typed seeds first, then `f.Fuzz(func(t *gotest.T, in …) { … })` whose body asserts something the input must satisfy (round-trip, idempotence, no panic is not enough — `fuzz-no-oracle`). Never a top-level `func FuzzX(*testing.F)`: gotest ignores it on every version. Struct and named-type arguments are fine and fan out per field; the refused shapes and the crasher loop are in `reference/fuzzing.md`. Search with `go tool gotest fuzz --for=30s ./...`; a crasher becomes a seed through `gotest fuzz promote`, never a hand-committed corpus file for a struct target. 10. **Gate on the environment with `SuiteGuard`, never a skip.** A suite that needs what a machine may lack (a `DATABASE_URL`, Docker, a credential) declares `func (s *X) SuiteGuard() string`: empty runs the suite, anything else skips it with that reason. It runs before the suite's fixtures, config and `BeforeAll`, so the suite's own setup never starts; `t.T().Skip()` in `BeforeAll` runs after setup has begun. Read the environment only: fixture fields are still nil in the guard. ## Restructuring existing suites (the blue phase) Enter only at a green pause point when: setup is duplicated across tests, a third similar test is being added, a touched suite already smells, or you were asked to clean up. Follow `reference/refactoring.md` for the ladder and smells list. Non-negotiable safety invariants (tests protect nothing — observed failure: agents restructure with no case accounting): 1. Capture the executed case LIST before and after, into separate files, and diff them: ```sh go tool gotest -json ./... | grep -o '"Package":"[^"]*","Test":"[^"]*"' | sort -u > cases-before.txt test -s cases-before.txt ``` Capture the Package+Test PAIR — bare `Test` names collapse identically named suites across packages, hiding whole-package deletions. After the refactor, capture `cases-after.txt` the same way and run `diff cases-before.txt cases-after.txt`. Enumerate every rename/merge BEFORE editing; every diff line must map to that list. Coverage may only grow. 2. Both runners + `-race` green before AND after. 3. Never delete or weaken an assertion without saying so in your report. 4. Never touch production code during a test refactor — a test that resists restructuring is a design finding to report. 5. Consider rule 4 (parallelization) part of every improvement pass. ## Minimal complete suite ```go package shop_test import ( "example.com/shop" "github.com/mvrahden/go-test/pkg/gotest" ) type CartTestSuite struct { cart *shop.Cart } func (s *CartTestSuite) BeforeEach(t *gotest.T) { s.cart = shop.NewCart() } func (s *CartTestSuite) TestTotalsItems(t *gotest.T) { s.cart.Add("apple", 2) gotest.Equal(t, 2, s.cart.Count("apple")) } ``` ## References - `reference/assertions.md` — full assertion surface, `Nil`/`NotNil` type guards, snapshot testing - `reference/config.md` — literal config semantics, presets, compose form - `reference/fixtures.md` — fixture DAG, shared fixtures, hooks - `reference/cli.md` — the full CLI surface and flags - `reference/refactoring.md` — restructuring ladder, smells → moves - `reference/ci.md` — CI workflow shape, the gotest action, linter coexistence - `reference/migration.md` — testify/stdlib → gotest - `reference/fuzzing.md` — fuzz targets, seeds, struct arguments, the crasher loop - `reference/versions.md` — what older releases lack or do differently