Document the kinds of tests (pure unit, DB-gated integration, Stripe-mocked, HTTP handler/render, route/template lint, live e2e, OpenSpec validation), what each covers, how to run them, and the role of the test/ stack directory. Add a Testing subsection to the README pointing at it.
11 KiB
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
# Fast feedback — pure unit tests only (integration tests skip without a DB):
go test ./...
go vet ./...
# Full suite — needs a migrated Postgres (use the test stack):
cd test && ./bootstrap-stack.sh && docker compose up -d # one-time per worktree
set -a && . test/.env && set +a
TEST_DATABASE_URL="$MC_DB_DSN" go test ./... # from repo root
# Route/template integrity + render discipline:
go run . lint # 65 routes / URL refs resolve (cmd/lint.go)
make lint-templates # no handler bypasses SafeTemplates
go test ./... is always safe to run: every database- or Stripe-backed test
skips itself when TEST_DATABASE_URL is unset.
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 6) 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.yamlbrings up Postgres, Valkey, Keycloak, Temporal, and FedWiki, plus one-shot seeders (realm users, FedWiki identity, the Temporal default namespace). - Isolates each worktree.
./bootstrap-stack.shderives collision-free host ports and writestest/.env(COMPOSE_PROJECT_NAME, port assignments, and theMC_*config overrides), so several worktrees can run their own stacks concurrently without colliding. - Is where
TEST_DATABASE_URLcomes from. After bootstrap,test/.envdefinesMC_DB_DSN(Postgres on this worktree's port); integration tests point at it viaTEST_DATABASE_URL="$MC_DB_DSN". - Houses the end-to-end Go suites under
test/e2e/:plan-management/(in-processhttptestover the real handlers + a migrated DB) andoperator-walkthroughs/(Go tests that drive a browser against the running stack viaMC_BASE_URL;WALKTHROUGH_HEADFUL=1to watch). Both run undergo test(see type 6). - 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/), andteardown-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; 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/fulfillmentTestClassifyStatus,TestUnixToNullTime;internal/workflows/stripeTestCheckoutSessionPayloadParsing,TestInvoicePayloadParsing,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, migrated Postgres. 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.
- Gated on
TEST_DATABASE_URL— skip when unset, so they never block a driverlessgo test ./.... Driver is pgx (_ "github.com/jackc/pgx/v5/stdlib"). - 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/workflows/stripe(*_Integration, transition/product-catalog tests).
Two sub-patterns for the testDB(t) helper — know which one you're following:
| Sub-pattern | testDB behavior |
Used by |
|---|---|---|
| Self-migrating | calls db.RunMigrations(...) to build the schema from scratch |
provisioning, fulfillment, entitlements/materialize, workflows/entitlements, server/operator_plan_ladders |
| Assume-migrated | just sql.Open against an already-migrated DB |
workflows/stripe |
Migration-source order matters. Self-migrating tests must list migration sources in the same order as
cmd/migrate.go'smigrationSources().assembleMigrationsnumbers migrations by source position, so a different order assigns different global versions and tries to re-apply already-applied migrations (relation … already exists). Seedocs/database-management.md.
Fixtures: provisioning.AutoProvision(ctx, db, claims) bootstraps a fresh
org + default pool + billing account in one call — the cheapest way to get a
clean tenant. Layer the plan graph (entitlement set → product → price → ladder →
tier, plus stripe mappings) on top with the sqlc Create* helpers. Use unique
suffixes (uuid.New().String()[:8]) so repeated runs against the shared stack DB
don't collide; rows are left behind in the disposable test database.
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{Sub, List, Err}implementsstripe.Backend;stripetest.Install(m)swaps the global backend + a dummy key and returns a restore func (t.Cleanup(stripetest.Install(m))). - Cover:
fulfillment.ReconcileSubscriptionscenarios (empty-payload provisioning, idempotent replay, ordering, cancellation, multi-ladder guard) and the webhook→plan-transitionsend-to-end tests. - Gotcha:
Installmutates global stripe-go state, so these tests must not callt.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/decompress_test.go.
5. Route & template lint (static)
Not Go tests, but part of the green-bar definition:
go run . lint(cmd/lint.go) — operator-UI integrity: every registered route and templated URL reference resolves. Reports e.g.lint: ok (65 routes, 29 URL refs).make lint-templates— guards that no handler bypassesSafeTemplatesby calling.ExecuteTemplate(w, …)directly (CSP/render discipline; seeinternal/server/render.go).
6. End-to-end tests
Exercise whole flows against a running stack. Three flavors:
- Go HTTP-level e2e —
test/e2e/plan-management/: an in-processhttptestharness over the real handlers + a migrated DB (TEST_DATABASE_URL). Runs undergo 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 (readsMC_BASE_URLfromtest/.env; setWALKTHROUGH_HEADFUL=1to 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. - Manual / agent-driven live loop — the
chrome-devtoolsMCP (browser on127.0.0.1:9222viachrome-debug &), the Stripe MCP, andstripe listenwebhook 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 ofgo test.
Setup for all three lives in test/AGENTS.md (worktree stack
contract; the "Stripe webhook forwarding" section for the live loop).
7. 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.
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? → go run . lint / lint-templates (5)
Is it a whole user flow (HTTP or operator UI)? → e2e (6): a test/e2e/ Go suite
Is it a flow only real infra can prove (live Stripe)? → the live MCP loop (6), see test/AGENTS.md
Prefer the cheapest test that genuinely covers the behavior. Reach for an e2e suite or the live loop (6) 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
- Gate DB/Stripe tests on
TEST_DATABASE_URLandt.Skipwhen unset — keepgo test ./...green with no infrastructure. (This is the one convention; the legacyDB_DSN+postgres-driver variant has been retired.) - Match
cmd/migrate.gosource order in self-migrating tests (see above). - Don't trust webhook payloads in tests the way production doesn't: a Stripe
test should drive behavior through
stripetest.MockBackendreturning 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 ids for tests that write to the shared stack DB; tear the
stack down with
test/teardown-stack.shwhen you want a clean slate.
Related docs
test/AGENTS.md— the test stack (ports, webhook forwarding, demo seed).docs/database-management.md— migrations & sqlc.docs/stripe.md— the member-console ↔ Stripe responsibility split.