Files
member-console/docs/testing.md
T
cgalo5758 8c0fb4ae42 Archive the suspended-plans and reconcile pool-lock changes
reconcile-takes-pool-lock (#193) and suspended-plans-stay-held (#148)
move to openspec/changes/archive/ with every task ticked, and their
deltas are synced into the main specs:

- plan-transitions: 1 requirement modified (#193).
- member-dashboard, member-entitlement-view, member-offer: 1 modified
  each; member-plan-upgrade: 1 renamed ("active" to "live" ladder
  attachments) and 1 modified; member-invoice-history: 2 added;
  member-product-discovery, stripe-subscription-creation: 1 added each;
  plan-enrollment-administration: 1 added, 3 modified;
  stripe-subscription-management: 1 added, 2 modified (#148).

What #148's research and sandbox check taught, written into the pages
that own each topic:

- docs/models/plan-ladders-transitions.md: the member position's readers,
  all eight, each holding a suspended position as the organization's plan.
- docs/models/payments-billing.md: pausing collection is not the paused
  status, so the plan keeps delivering.
- docs/stripe.md: an expired pending upgrade's invoice is voided; a
  period-end cancellation stops retrying only that subscription's open
  invoice; only a subscription made outside the console reaches paused;
  paying on Stripe's page leaves the subscription's own default card in
  place; an uncollectible invoice's page still takes payment.
- docs/testing.md: what a break record's wanted message must be, and how
  to drive a failed renewal in the Stripe sandbox.
- status/design-feedback.md: the data model delivers through a past_due
  grace period, the console suspends at once (#255).
- The archived design gains three reasons: why the immediate-cancel
  refusal comes first, the https-only redirect, and the paused badge's
  tone.
2026-10-26 15:45:27 -05:00

622 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Testing"
audience: [developer]
summary: "The test taxonomy (unit, DB-gated integration, Stripe-mocked, HTTP render, lint, e2e, OpenSpec validation), the test kit, how to run each, and conventions for new tests."
---
# Testing guide
How testing works in member-console: the kinds of tests, what each covers, how
to run them, and the conventions to follow when writing new ones.
## TL;DR
```bash
# The whole suite: lint, a reset of the test databases, then every package
# one at a time (needs the test stack up):
cd test && ./bootstrap-stack.sh && docker compose up -d # one-time per worktree
make test # from repo root
# One package while iterating, on the same footing as make test:
(cd test && ./reset-test-db.sh)
set -a && . test/.env && set +a
go test -count=1 ./internal/<package>/
# Static checks and render discipline:
make lint # every static check (type 5 lists them)
make lint-templates # no handler bypasses SafeTemplates
```
`go test ./...` with no database configured is always safe to run: every
database- or Stripe-backed test **skips itself** when `TEST_DATABASE_URL` is
unset. With it set, a database that `reset-test-db.sh` has not migrated fails
the test instead of passing on a stale schema.
## The `test/` directory — the local stack
Most Go test files sit next to the code they cover (`internal/.../<name>_test.go`).
`test/` is the exception, and serves two roles: it's the **integration/e2e
environment** — a Dockerized stack of every backing service the app needs
(Postgres, Valkey, Keycloak/OIDC, Temporal, FedWiki) that the database-gated
tests (types 2–3) and the e2e suites (type 7) run against — **and** it houses the
higher-level **end-to-end Go test suites** under `test/e2e/`.
Its role:
- **Provides the backing services.** `test/compose.yaml` brings up Postgres,
Valkey, Keycloak, Temporal, and FedWiki, plus one-shot seeders (realm users,
FedWiki identity, the Temporal default namespace).
- **Isolates each worktree.** `./bootstrap-stack.sh` derives collision-free host
ports and writes `test/.env` (`COMPOSE_PROJECT_NAME`, port assignments, and the
`MC_*` config overrides), so several worktrees can run their own stacks
concurrently without colliding.
- **Is where the test databases come from.** `./reset-test-db.sh` drops,
recreates and migrates `member_console_test` (`TEST_DATABASE_URL`) and
`member_console_e2e` (`E2E_DATABASE_URL`); `make test` runs it first.
`test/.env` holds both addresses beside `MC_DB_DSN` and `MC_DB_MIGRATE_DSN`,
the running app's database as its runtime user and as its migration user;
only the browser tests write it. `TEST_DATABASE_URL` stays the superuser.
- **Houses the end-to-end Go suites** under `test/e2e/`: `plan-management/`
(in-process `httptest` over the real handlers + a migrated DB) and
`operator-walkthroughs/` (Go tests that drive a browser against the running
stack via `MC_BASE_URL`; `WALKTHROUGH_HEADFUL=1` to watch). Both run under
`go test` (see type 7).
- **Holds the stack's run-only assets:** `mc-config.yaml`, `secrets/`
(gitignored Stripe keys + webhook secret), the opt-in demo seeder
(`./test/seed-demo.sh` + `seed/`), and `teardown-stack.sh` (compose down +
removes `.env`, `secrets/`, `testdata/`).
Don't edit `test/.env` by hand — re-run `bootstrap-stack.sh`. The full contract
(idempotency, the account-global Stripe webhook constraint, teardown) lives in
[`test/AGENTS.md`](../test/AGENTS.md); read it before running the stack.
## The kinds of tests
### 1. Pure unit tests — no external dependencies
Plain Go tests of pure logic: parsing, classification, formatting, guard
conditions. They run on every `go test` invocation, need nothing external, and
are fast.
- **Cover:** webhook payload parsing, status classification, value mapping,
small helpers, input guards.
- **Examples:** `internal/fulfillment` `TestClassifyStatus`,
`TestUnixToNullTime`; `internal/integrations/stripe/workflows` `TestCheckoutSessionPayloadParsing`,
`TestSubscriptionPayloadParsing`, `TestCardStringField_*`.
- **Write one when:** the behavior is a function of its inputs with no DB/HTTP/
Stripe dependency. Prefer pushing logic into such functions so it can be
tested this way.
### 2. Database-gated integration tests
Run against a real Postgres that `test/reset-test-db.sh` has migrated. They
exercise queries, constraints (e.g. the `pool_provision_ladders` GiST
exclusion, `chk_pool_provisions_source`), transactions, and cross-schema flows
that unit tests can't reach.
- **Cover:** the entitlement graph, provisioning, fulfillment reconcile, webhook
projection, materialization, operator queries.
- **Examples:** `internal/provisioning` (`TestAutoProvision…`), `internal/fulfillment`
(`TestReconcile_*`), `internal/entitlements/materialize_test.go`,
`internal/integrations/stripe/workflows` (`*_Integration`, transition/product-catalog tests).
**Opening the database.** Every test reaches the shared databases through
`dbtest` (`internal/testkit/dbtest`):
- `dbtest.Open(t)` opens `TEST_DATABASE_URL`, closes it when the test ends, and
skips the test when the variable is unset. It **never migrates**: the reset
script is the only migrator of the shared test database. It fails the test,
naming the script, when the core ledger lacks the highest version in
`db.BaseSources()`, so a stale database cannot pass a test. The driver is
pgx.
- `dbtest.OpenE2E(t)` does the same for `E2E_DATABASE_URL`, the database
`test/e2e/plan-management` commits its fixtures to. `dbtest.DSN(t)` returns
the address (and skips) for a test that needs it itself.
- A test whose subject is a migration or the migrator gets an empty database
from `dbtest.Scratch(t)`, created in the same cluster and dropped when the
test ends, and applies a stream to it with `dbtest.UpTo` or
`dbtest.ApplyVersion`, or all streams with `db.RunMigrations` on a
`dbtest.Connect` to its address. It skips where the login role cannot create
databases.
- `dbtest.Counted(t)` opens `TEST_DATABASE_URL` as `Open` does, on a handle
that records the name of every statement run on it (`ByName`, `Total`,
`Reset`; `Names` lists them in the order they ran, a transaction's `begin`,
`commit` and `rollback` included; `Note` puts a line of the test's own among
them; `Diff` prints what changed between two records). Hand it to the code
under test only and build fixtures on a handle from `Open`, so the record
holds the code's statements and nothing else.
- `dbtest.Q` runs statements and fails the test on any error (`Exec`, `Scan`,
`Int`, `String`, `Refuse` for a statement that must fail with a given
SQLSTATE; `Rows` prints a query's rows as text, in the order the query
names; a rows golden names the fixture's ids with the test's normaliser and
numbers the rest with `golden.Ordinals`). `dbtest.Tx(t, d)` opens a
transaction the test rolls back; `dbtest.On(t, x)` binds a statement runner to
a test. A `Q` belongs to the test or subtest that built it: a subtest that
uses its parent's transaction rebinds with `dbtest.On(t, q.Tx())`.
- `dbtest.AwaitLockWait` polls until a backend waits on a lock (`Waiters` for a
number of backends queued at once), for a test that holds one transaction
open while a second one blocks.
**Fixtures.** `world` (`internal/testkit/world`) builds the rows a test needs
through the functions the console itself calls. Its builders cover people,
organizations (`Org` also creates the default pool), members and pools; the
catalog: products and their states, prices, ladders and tiers; rules, through
the system's path (`Rule`) or a person's commit that settles the carrying pools
(`RuleChange`); grants, conferred, issued, extended or revoked, and the floor;
billing: accounts, subscriptions and their scheduled changes, invoices and
their lines, payments, and the Stripe mapping of each (the `...In` forms record
an environment, the others record none); and workspaces, usage and the
provider registration boot runs. Where no console act writes a row, the
builder writes it itself: `OrgType` inserts an organization type, `Draft` and
`Lifecycle` set a product's lifecycle status, and `Subscribe` sets a
subscription's commitment end when its options carry one. `world.New(t, db)`
takes any `*sql.DB` the migrations built, so one builder fills the test
database or the application database. Every method fails the test on any
error. Every organization, product, set, ladder name, subject and email carries
the World's suffix, so repeated runs against the shared database do not
collide, and nothing is deleted: rows stay in the disposable database until
the next reset. A World holds the `testing.TB` it was built with, so a subtest
builds its own with `world.New(t, db)`. The first sign-in's rows (user,
person, organization with its membership, workspace) come from
`provisioning.AutoProvision(ctx, db, claims)`.
A test of the schema itself writes its rows by SQL: a constraint, a trigger or a
migration is the subject, and a builder that went through the console's
functions would not exercise it.
**Privileges.** `TEST_DATABASE_URL` logs in as the cluster's superuser, which
passes every privilege check, so a test on it cannot see a grant the runtime
user lacks. A test whose subject is a grant or an enclosure, or a query the
runtime user must be able to run (a locking read of an enclosed table among
them), drops inside its transaction to the role the running console holds:
`SET LOCAL ROLE` to `db.RuntimeGroupRole` (`console_runtime`), as `asRole` in
`internal/db`'s tests does.
### 3. Stripe-mocked tests
A specialization of the integration tests: the Stripe SDK is pointed at an
in-process fake so fulfillment's "refetch from the Stripe API" path runs
deterministically and offline, with **no live Stripe account**.
- **Helper:** `internal/stripetest` — `MockBackend` implements `stripe.Backend`
and answers each read from its fields (`Sub`, `List`, `Invoices`, `Products`,
`Prices`, `Schedule` and others; each field's comment names the call it
answers). `Err` fails every call, `ObjectErrs` fails one Stripe id and
`PathErrs` fails one API path. `stripetest.Install(m)` swaps the global backend + a dummy
key and returns a restore func (`t.Cleanup(stripetest.Install(m))`).
`m.OnCall` sees each request as it is recorded, and `stripetest.Line(c)`
prints one as text, so
`m.OnCall = func(c stripetest.Call) { stmts.Note(stripetest.Line(c)) }` puts
each Stripe call among the statements of a `dbtest.Counted` handle.
`stripetest.KeyMode` answers the mode of the key the SDK holds when it is
called (`"test"` under `Install`); a test passes its result,
`stripetest.KeyMode()`, where the console passes the mode boot derived.
- **Cover:** `fulfillment.ReconcileSubscription` scenarios (empty-payload
provisioning, idempotent replay, ordering, cancellation, multi-ladder guard)
and the webhook→`plan-transitions` end-to-end tests.
- **Trace tests:** `TestReconcileTrace`, `TestScheduledChangeTrace` and
`TestSweepTrace` record a reconcile's or the sweep's statements in order with
the Stripe calls between them, its log lines and the rows it leaves, in
goldens under `internal/fulfillment/testdata`. `TestSweepTrace`'s cases run
on a scratch database each, because the sweep fires every due row in its
database.
- **Gotcha:** `Install` mutates **global** stripe-go state, so these tests must
**not** call `t.Parallel()`.
### 4. HTTP handler / render tests
Exercise handlers through `net/http/httptest` plus the real `SafeTemplates`
renderer, session manager (`scs`), and CSRF middleware.
- **Cover:** rendered output, template wiring, auth guards (e.g. 401 for
unauthenticated), CSRF behavior.
- **Examples:** `internal/server/member_upgrade_render_test.go`
(`TestMemberPlansUpgradeControlRendering`, `TestDashboardCheckoutBanner`,
`TestGetPlansUnauthenticatedReturns401`); `internal/middleware/tests/security_headers_test.go`.
- **Template sets come from the constructors production uses.** The server
package's test harness (`internal/server/harness_test.go`) builds them:
`OperatorSet(t)` is `web.OperatorTemplates` and `MemberSet(t)` is
`web.MemberTemplates`, both over `web.CoreTemplates()`, and `Render(t, set,
name, data)` executes one template. A test never builds a stand-in set or
registers template functions itself.
- **Query the output with `htmltest`** (`internal/testkit/htmltest`):
`htmltest.Parse(t, body)` parses a page or a fragment, and `One` (exactly
one match), `All`, `Count`, `None`, `Text`, `Attr`, `HasClass`, `Before` and
`InOrder` select and compare with CSS selectors, the vocabulary the browser
walkthroughs use. Select by role and by `aria-*`, `hx-*` and `data-*`
attributes (`nav[aria-label=Account]`, `[role="menu"] button[hx-post="/logout"]`);
a class that only styles an element is not what a test asserts. A failure
quotes the selector and a snippet of the markup. The harness adds
`ShellBar`, `ShellRail` and `LinkNamed` for the shell.
- **Sign in through `authtest`** (`internal/testkit/authtest`):
`authtest.NewSessionManager()` is the in-memory session manager, and
`authtest.SignIn(t, sm, signIn)` writes the session through `auth.PutSignIn`,
the function the callback handler calls, so a test session has the keys and
values a real sign-in produces. `authtest.Get` and `authtest.Post` run a
handler in process with that session on the request; `authtest.PathValue`
sets a path parameter as the mux would. `Get` and `Post` send no htmx
headers, so a handler answers as it does to a native request.
`authtest.Cookie` commits that session and returns its cookie, for a handler
wrapped in the session middleware, such as the whole router.
- **Compare with a golden file through `golden`** (`internal/testkit/golden`):
`golden.Compare(t, file, got)` fails unless `got` is the file's content, and
`go test -update` writes the file. A golden file is written once, with
`-update`, and read against its fixture before it is committed; after that it
changes only in a commit that names the change. A `golden.Normalizer` turns
the ids a fixture made into names (`Name`) and `golden.Ordinals` numbers the
ids and times an act made. A test package that defines its own `update` flag
cannot import `golden`, because the flag would be defined twice.
- A golden holds output in a defined order: its query ends on a unique key
and its Go code ranges over sorted map keys
([go-conventions.md](go-conventions.md), "Output order"). On the shared
test database it holds only the rows of the test's own fixture.
- When a change should move a golden, edit the expected lines into the file
by hand first; the test then passes without `-update`, which shows the
change was intended. Use `-update` to write a new golden, or read its diff
line by line.
- Pass `-update` one package at a time: a package that does not define the
flag fails to start with it, so `go test ./... -update` fails.
### 5. Static checks (`make lint`)
Not Go tests, but part of the green-bar definition. `make lint` runs five
checks, and `make lint-templates` is a sixth that runs on its own:
- **`go run . lint`** (`cmd/lint.go`) — operator-UI integrity. It parses each
template in the set its surface builds in production (`web.MemberTemplates`
and `web.OperatorTemplates`, with each integration's directory as a mount),
so a template it cannot parse fails it, naming the file, and so does a file
that no surface parses. Its rules, by the id each finding carries:
- routes and URLs: `dead-route`, `dead-swap-target`,
`interpolated-literal-url`, `retired-resource-key`;
- coverage: `walkthrough-coverage` (every operator mutation route has a
walkthrough claim) and `screen-coverage` (every GET page route has an
entry in `test/e2e/screens/manifest.go`);
- page anatomy (`design-system.md` §6): `page-without-header`,
`raw-page-title`, `raw-section-title`, `raw-badge`, `raw-empty-state`,
`raw-form-control`, `form-without-part`, `disabled-outside-part`,
`table-without-list-controls`, `actions-column`, `hx-confirm`,
`load-fetch`, `heading-skip`.
Templates not yet rebuilt on the parts are listed in
`internal/lint/anatomy_allowlist.txt`, which only shrinks:
`allowlist-stale` fails an entry that no longer matches, and
`allowlist-unreadable` a list it cannot read;
- copy: `em-dash-copy`, `filler-copy`;
- specs: `spec-purpose-placeholder`, a spec under `openspec/specs/` whose
Purpose is the archive's placeholder or empty;
- htmx: `htmx-2-compat`, `htmx-old-event`, `hx-disinherit`;
- error text: `raw-error-render`;
- reading: `unreadable-file`, a directory the lint cannot list or a file it
cannot read, so that nothing passes the lint unread.
- **`scripts/notebook-citations.sh`** — no tracked file cites a local notebook
path.
- **`scripts/golangci-lint.sh`** — golangci-lint with `.golangci.yml`: the
standard set (vet, staticcheck, unchecked errors with errcheck, dead
assignments with ineffassign, unused code with unused), plus error
comparison (errorlint), an error caught and then returned as nil (nilerr),
`rows` and `sql` handling (rowserrcheck, sqlclosecheck), unused parameters
(unparam), duplicated code (dupl),
function complexity (gocognit, limit 30) and forbidden identifiers
(forbidigo), among them the rod waits the browser tests refuse (type 7).
- **`scripts/deadcode.sh`** — production functions no program reaches, except
those listed in `scripts/deadcode-testonly.txt`.
- **`go run ./scripts/sqlc-unused`** — generated queries with no caller outside
generated and test files.
- **`make lint-templates`** — guards that no handler bypasses `SafeTemplates` by
calling `.ExecuteTemplate(w, …)` directly (CSP/render discipline; see
`internal/server/render.go`).
`.golangci.yml` lists the findings a later change removes, one rule per
finding, pinned to the function's current score and commented with the issue
that removes it. A rule that stops matching fails the lint, so the list only
shrinks.
### 6. Security scanners (static, offline)
A security audit runs two passes, in this order: the deterministic scanners
(gosec, govulncheck, semgrep, staticcheck, gitleaks) run first and offline,
and a model then triages their hits. The order is deliberate — a model
adjudicating scanner output cuts semgrep's false-positive rate from about
0.42 to about 0.05 at a cost of about 3% relative recall, while a model
reading code unaided over-reports. The method the audit follows is
[`docs/agent-runner.md`](agent-runner.md).
### 7. End-to-end tests
Exercise whole flows against a running stack. Three flavors:
- **Go HTTP-level e2e** — `test/e2e/plan-management/`: an in-process `httptest`
harness over the real handlers + a migrated DB (`E2E_DATABASE_URL`), with
fixtures from `world` and requests from `authtest`. Runs under `go test`;
covers operator plan/ladder/grant flows end-to-end at the HTTP layer.
- **Go browser walkthroughs** — `test/e2e/operator-walkthroughs/`: Go tests that
drive a real browser against the running stack (reads `MC_BASE_URL` from
`test/.env`; set `WALKTHROUGH_HEADFUL=1` to watch). One walkthrough per operator
route group, each asserting the four-bullet UI contract (empty/invalid → 422,
valid → success + DOM update, …). Skips unless the stack is up and seeded.
The browser tests (the walkthroughs, `test/e2e/screens` and
`test/e2e/merchant-disclosure`) share `test/e2e/browsertest`, whose rules are:
- **Waits are boolean predicates.** `WaitTrue`, `TryWaitTrue`, `WaitLoaded` and
`WaitReady` poll a JavaScript function that returns a boolean, and a
missing or destroyed execution context counts as "not yet". `Eventually`
polls a Go probe that returns a boolean, and rod's own `MustWait` takes a
JavaScript predicate the same way.
golangci-lint refuses rod's `WaitLoad` and `WaitStable` (`MustWaitLoad`,
`MustWaitStable`) on a page or element, with the message "wait on a
predicate that returns a boolean (browsertest)". The one wait without a
predicate is rod's `WaitDOMStable`, for a page that may never stop changing
and is captured as it stands.
- **`ClickAndLand` for an answer that is a new document** (an `HX-Redirect`, a
native redirect): it anchors the wait to the new document, not to a
lifecycle event. **`AwaitSwap` for an htmx swap**: it runs the action and
waits for htmx to settle.
- **`Recover`.** Each walkthrough defers `browsertest.Recover(t)` first, so a
rod panic fails that walkthrough with its stack and the package's other
walkthroughs still run.
- **`AppDB`** opens the running app's database as the runtime user
(`MC_DB_DSN` in `test/.env`), never migrates it, and skips when the key is
missing. **`AppAdminDB`** opens it as the migration user
(`MC_DB_MIGRATE_DSN`) for work that needs a superuser, such as the screens'
timestamp pinning, and skips when that key is missing.
- **Free functions that take `t`**, and no helper stores it, so a subtest
never calls a parent's.
- **A long walkthrough is phases**: named functions, or methods on a struct
that holds the walkthrough's state, called in order.
- **Manual / agent-driven live loop** — the `chrome-devtools` MCP (browser on
`127.0.0.1:9222`, started with `--remote-debugging-port=9222`), the Stripe MCP, and `stripe listen`
webhook forwarding, for flows not yet codified — e.g. a real Stripe Checkout →
`checkout.session.completed` → reconcile → the member UI reflecting the new
plan. Not part of `go test`. A failed renewal is driven by attaching Stripe's
`pm_card_chargeCustomerFail` test card to the customer, setting it as the
subscription's `default_payment_method`, and starting a new period at once
(`stripe subscriptions update <id> --billing-cycle-anchor now
--proration-behavior none`); the subscription then reads `past_due`, and
Stripe accepts the update on a subscription that a console schedule
manages. Every CLI call passes `--stripe-version 2025-02-24.acacia`, the
version the console pins. A state that needs a period to end or the retries
to run out (a cancellation taking effect, an invoice marked uncollectible,
an expired pending update) needs a test-clock customer, which the console
never maps, so it is checked from the CLI alone.
Setup for all three lives in [`test/AGENTS.md`](../test/AGENTS.md) (worktree stack
contract; the "Stripe webhook forwarding" section for the live loop).
### 8. Live DNS verification (domain claims, manual)
Some of the domains registry can only be proven against a real zone: that a
published challenge record activates a claim, and that publishing the challenge
*prefix* with the wrong token latches `evidence_at` (exempting the claim from the
abandonment ledger) without verifying. DB tests cover the branching; only real DNS
covers the resolver path.
You need a **disposable zone you control** — any domain whose records you can add
and remove, at any registrar or DNS host with an API. Keep it separate from
anything the deployment actually serves, and never commit credentials for it;
supply them from the environment at the moment you use them. If your harness blocks
outbound HTTP from the shell, drive the DNS provider's API from a sandboxed script
instead.
The loop is: start a claim in the console, read its token, publish
`_member-console-challenge.<name>` as TXT, wait for a probe, assert, then delete the
record. Two cases are worth proving because they diverge — a TXT carrying the
challenge **prefix with a wrong token** must latch evidence *without* activating the
claim, while the **correct token** must activate it.
**The gotcha that will cost you ten minutes.** If the zone has a **wildcard record**
(`*.example` CNAME/ALIAS/TXT), resolvers synthesise it for any challenge name that
has no explicit record — so the probe reports "Found, but doesn't match" against
whatever the wildcard points at, long before you did anything wrong. Publishing an
explicit record stops the synthesis (RFC 4592 closest-encloser), but any cached
wildcard answer still wins until its TTL expires, so probes keep reporting
`mismatch` for minutes after DNS is already correct. Before concluding the prober is
broken, compare authoritative against cached:
```bash
dig +short @<authoritative-ns> TXT _member-console-challenge.<name> # what the zone says
dig +noall +answer TXT _member-console-challenge.<name> # what the console sees
```
Clean up the records you added, and leave alone any challenge record that backs a
claim you intend to keep verified.
### 9. OpenSpec validation (change workflow)
`openspec validate <change>` is not a Go test but is part of the definition of
done for a spec-driven change: it checks delta specs are well-formed (MODIFIED
requirement headers match the main spec, every requirement has a `####` scenario,
etc.). Run it before archiving a change.
## The test kit
`internal/testkit` and its sub-packages hold what tests share. Each package
imports only what its row allows, so any package's tests can use it without an
import cycle; `internal/testkit/imports_test.go` lists the dependencies of each
with `go list -deps` and fails when one reaches a package it forbids.
This page says what each package is for and names the helpers a new test
reaches for first. `go doc -all ./internal/testkit/<package>` lists every
exported name with its comment, and `go doc -all ./internal/stripetest` does
the same for the Stripe mock. A helper joins the kit with its first caller and
a test of its own.
| Package | Holds | Imports |
|---|---|---|
| `testkit` | `Close`, which closes an `io.Closer` and fails the test on its error | no console package |
| `testkit/dbtest` | opening the test databases, scratch databases, counting the statements a request runs, `Q`, `AwaitLockWait` | `internal/db` and `internal/testkit` only (`internal/migrate` reaches nearly every package) |
| `testkit/htmltest` | parsing HTML and selecting from it with CSS | no console package |
| `testkit/golden` | comparing a test's output with a file under testdata, `-update` writing it, and naming the ids and times a run makes | no console package |
| `testkit/replay` | replaying a package's recorded Temporal histories against its workflows, and recording them | no console package |
| `testkit/world` | the fixture builder, plan and billing fixtures and the grant, tier and subscription acts included | the console's data packages; never `internal/migrate`, `internal/server`, `internal/integrations`, an integration's `web` or `workflows`, or `internal/domains` |
| `testkit/authtest` | signed-in sessions, their cookies, and in-process requests | as `world` |
| `integrations/fedwiki/fedwikitest` | FedWiki's fixtures and fake farm, built on `dbtest` and `world` | never `internal/migrate`, `internal/server`, or FedWiki's own module, `web` and `workflows` packages |
**Recorded workflow histories.** A package whose workflows are replayed keeps recorded histories under `testdata/replay` (today claim verification, the desired-state push and FedWiki's workflows), and its `TestWorkflowHistoriesReplay` replays each against the workflows the worker registers (`replay.Run`). A replay fails when a change would break a run in flight at deploy. It compares the commands a workflow issues, not their inputs, the timer durations or the result, so a trace or golden test pins those beside it. The package's `TestRecordReplayHistories` writes the histories with stand-in activities when `TEMPORAL_REPLAY_RECORD` names a temporal CLI binary (a private dev server) or a running server's host:port. It writes only the files that do not exist; `TEMPORAL_REPLAY_ONLY=<prefix>` records the files with that prefix again. Record from the code a release shipped, never from edited code.
## Choosing what to write
```
Is the behavior a pure function of inputs? → unit test (1)
Does it touch the DB / constraints / multi-table? → DB integration test (2)
Does it call the Stripe API? → add a stripetest mock (3)
Is it an HTTP handler / rendered page? → httptest render test (4)
Is it route/template wiring or render discipline? → make lint / lint-templates (5)
Is it a whole user flow (HTTP or operator UI)? → e2e (7): a test/e2e/ Go suite
Is it a flow only real infra can prove (live Stripe)? → the live MCP loop (7), see test/AGENTS.md
Does it depend on what a resolver actually returns? → live DNS on a zone you control (8)
```
Prefer the cheapest test that genuinely covers the behavior. Reach for an e2e
suite or the live loop (7) to *prove a flow works end-to-end*, then capture the
regression-proofing parts as (2)/(3) so they run in CI without a live account.
## Conventions & gotchas
- **Characterize before a refactor.** A change that reorganizes code without
changing what it does first commits tests that pin what the code does today:
a golden of the view model and the HTML, the statements a request runs
(`dbtest.Counted`), a table of what each failed read answers. Those tests pin
today's defects too, and the commit message names them. The refactor
commits that follow change no golden, screen, test name or result; a change
in any of them is a finding to explain, not a file to update. A behaviour
fix found on the way lands afterwards as its own commit with its own test,
and names the golden it moves.
- **Tests meet the same complexity limit as production code.** gocognit counts a
closure passed to `t.Run` toward the function that encloses it, so splitting a
test into subtests does not lower its score. A test over the limit splits into
named functions, runs its table rows through a named function, or uses the
kit's helpers.
- **Open the database through `dbtest`** (`Open`, `OpenE2E`), which skips the
test when `TEST_DATABASE_URL` or `E2E_DATABASE_URL` is unset, so `go test ./...`
stays green with no infrastructure. No test migrates the shared databases;
`test/reset-test-db.sh` does, and `dbtest.Open` refuses one it has not.
- **A test of a migration** applies it to a `dbtest.Scratch` database, never to
the shared one.
- **Prove a test with deliberate breaks.** A new test, a test whose name or
comment states a value, and a check that moves onto the kit (a substring
onto `htmltest`, a hand-written wait onto `browsertest`, a fixture onto
`world`) each get one break per fact they claim: a change to the code that
violates what the assertion says it checks, not its incidental markup, and
makes the test fail with that message. `scripts/mutation-check` runs a JSON
list of breaks (file, exact text to replace, replacement, package, test
pattern, wanted message): it runs each test unbroken first, applies one
break at a time, restores the file, and reports each break as CAUGHT,
MISSED, WRONG-MESSAGE, NOT-FOUND (the text does not occur exactly once),
SKIPPED (the test skipped) or CONTROL-FAILED (the unbroken run failed). The
wanted message is text the failing assertion prints, in the form it prints
it (a body checked as HTML carries an apostrophe as `&#39;`), never a test
or subtest name, which `go test -v` prints on a pass too; a table whose
runner prints no row name gives every row's break the same text, so the
runner prints it. A break of a sqlc query edits the generated `.sql.go`
file, whose constant the test runs, not the `.sql` file. A record replaces
one text, so a break that moves code from one place to another is emulated
by one edit with the effect a caller would see, and the commit message
calls it emulated. For a converted check, run it with `-side old` on a
checkout of the parent commit and with `-side new` on yours, so a break the
old test missed shows up as a weaker assertion; a new test has only a new
side. A list for browser tests carries a `prepare` command that resets and restarts the app before
each break, and a command record covers a break that only the lint shows.
`scripts/mutation-check` runs its tests without `-race`, so a break that
only the race detector catches is a command record too:
`go test -race -p 1 -count=1 -run '^TestX$' ./pkg`, exit 1, with
`WARNING: DATA RACE` as the wanted text, since a command record without one
reports WRONG-MESSAGE. The header of `scripts/mutation-check/main.go` gives
the format. List the breaks in the commit message.
- **Read a MISSED break before acting on it.** It means one of three things:
the assertion is weaker than its message (tighten it, or name what it does
not check); the break changes nothing a caller can observe, so no test can
catch it (say so, with the reason); or the harness cannot see the effect,
as a mobile-width screen cannot see a page wider than its viewport. Only the
first is a weak test.
- **Tests of locks and of order are deterministic.** A test that holds one
transaction open while a second blocks waits for the block with
`dbtest.AwaitLockWait`, never with a sleep. A test of an order feeds its
input in more than one order, because input that is already sorted cannot
fail. A test of a loop that waits on timers runs inside `testing/synctest`
and calls `synctest.Wait` before it acts, because a loop blocked in its
wait does nothing a test can observe (`internal/retryloop`'s tests).
- **`make test` does not run the race detector.** A change that adds
goroutines or state they share runs `go test -race -p 1 -count=1` over the
packages it touches, after `test/reset-test-db.sh`. `-p 1` is needed for
the reason `make test` uses it: the packages share one test database, and
count-based tests in packages that run in parallel claim each other's rows.
- **A test worker never polls the console's task queue on the stack's
Temporal.** The app running on the same worktree's stack polls that queue
(`queues.Main`), so a test's worker could take the app's tasks or hand it
the test's. A worker a test runs there takes a queue of its own, as
`testkit/replay` does; a test of the console's own Temporal connection
injects the dial and the worker (`cmd/temporal_connect_test.go`), and one
that needs an address that never connects names a listener that accepts
and closes each connection, because a closed port records no dial and a
check for a stray dial could then never fail (`cmd/serve_db_test.go`).
- **A flaky test is a suspected defect until a diagnosis says otherwise.** Two
flaky tests in this repository turned out to be a race in `core.confer` and
a query with no unique sort key. Rerun a failing test alone and record the
result; never update its expected output to match one run.
- **A database test passes twice in a row without a reset**, and never
asserts on an unfiltered list: other tests' rows share the database (#192),
so a test filters to the rows its own fixture made.
- **Don't trust webhook payloads in tests** the way production doesn't: a Stripe
test should drive behavior through `stripetest.MockBackend` returning canned
*API* state, not by hand-building the event payload's fields.
- **No `t.Parallel()` in Stripe-mocked tests** — they share global SDK state.
- **Unique fixture names** for tests that write to the shared test databases:
`world` suffixes every name it writes, and `dbtest.Name` returns a unique
lowercase name for the rest. `make test` resets the databases; tear the stack
down with `test/teardown-stack.sh` when you want a clean slate.
- **A wildcard CNAME shadows challenge lookups** — see (8). A domain-verification
probe reporting `mismatch` against a record you just published is usually a
cached wildcard, not a defect.
- **Accumulated rows can break the browser walkthroughs.** The DB-gated suites
write to `member_console_test` and `member_console_e2e` and never clean up, but
`make test` resets both, and no package test writes the app database. The
walkthroughs read the app database, so rows that pile up there
(`core.plan_ladders`, `core.org_types`) come from the walkthroughs themselves
or from manual use.
Check row counts before believing a walkthrough regression.
## Lint and screens (the UI quality gate)
A UX walk cannot hold the console to one shape: a page that differs from its
siblings costs the walker nothing they report, so the walk's severity scale
rates a consistency finding minor and triage tracks it. Sameness holds only
when the build fails on it and the maintainer reviews a capture of every
screen, which is what these two commands are for.
- **`make lint`** holds every page to one shape through the project lint's
page-anatomy, copy and screen-coverage rules; type 5 lists every rule and
the other checks `make lint` runs. `make test` runs it first.
- **`make screens`** first restores the application database from a demo
snapshot (`test/reset-app-db.sh`; the one deliberate exception to "nothing
under `test/` touches `member_console`"). The snapshot is built once:
migrate, `seed-demo --with-operator`, boot data state, row clocks pinned by
`test/pin-timestamps.sql` to 2026-01-01 09:00 UTC plus one second per
distinct original instant in the column's own order (a dense rank), so
distinct instants stay distinct, each table keeps the chronology its rows
were written in, and ties stay ties (the two rows one conferral act writes
keep one instant, which `core.superseded_ends` pairs them by; the ledger query's
id tiebreak orders inside a tie);
every reset recreates the
application database from it, so rows and their IDs are identical between
captures and a page nobody touched never reads as changed (only relative
ages tick, once a day). The snapshot rebuilds itself when the migrations,
the seed, or the clock pin change (their fingerprint is the snapshot's
database comment); `./reset-app-db.sh --rebuild` forces it. `make screens`
then captures every manifest entry at 1600×1000 and 390×844
(rod, logged in as alice for the operator surface and bob for the member
surface, one incognito context each) into `test/screens/out/`, writes
`operator.html` and `member.html` contact sheets in rail order, and
compares each PNG with `test/screens/baseline/`, listing changed and new
screens in `changes.txt` with the differing region's size and position. Parameterised routes resolve their instance from
the seeded list page (the first record link, or the one a manifest entry
prefers by its link text) and report "no instance" when the stack has none.
`make screens-accept` promotes the output to the baseline. Both directories
are gitignored; the stack in `test/.env` must be up and seeded. A change
touching templates or `app.css` is not done until its sheet is reviewed
(`first-contact-ux-process.md`, "Definition of done").
## Related docs
- [`test/AGENTS.md`](../test/AGENTS.md) — the test stack (ports, webhook forwarding, demo seed).
- [`docs/database-management.md`](database-management.md) — migrations & sqlc.
- [`docs/stripe.md`](stripe.md) — the member-console ↔ Stripe responsibility split.