Testing¶
Tests are the executable specification of a system's behavior. They MUST be deterministic, isolatable, and runnable on a developer laptop without a privileged backend. This chapter prescribes the Go-side test idioms, the integration-test plumbing, the browser-level end-to-end runner, and the CI artifact format.
TL;DR¶
- Go tests MUST be table-driven: one outer test function, one slice
of named cases, one
t.Run(name, func(t *testing.T) { ... })per case, andt.Parallel()inside each subtest. - Integration tests MUST exercise a real PostgreSQL via testcontainers-go; per-test database isolation MUST be provided by pgtestdb using template-clone snapshots.
- Mocking at the database boundary MUST NOT happen. The query layer MUST be exercised against a real Postgres in integration tests; only layers ABOVE the query interface MAY be unit-tested with fakes.
- Browser-level end-to-end tests MUST use
Playwright. The configuration file MUST
be checked in; auth state MUST be captured via a setup project and
reused via
storageState. - CI output MUST be JUnit XML produced by
gotestsumso the CI platform's test-report widget can render per-test results. - Unit tests and integration tests MUST live in separate build-tag
partitions (
//go:build integrationfor integration);make testruns unit,make test-integrationruns integration.
Why this choice¶
Three forces shape the slate.
- The DB boundary is the most defect-prone layer of a Go service. Mocking it hides the bugs that motivate integration testing in the first place. Real-Postgres integration tests catch type coercions, constraint violations, transaction-isolation surprises, and SQL syntax errors that mocks paper over.
- Isolation cost MUST be amortized. A naive "spin up a container
per test" model is too slow to run on every save.
testcontainersprovides the container;pgtestdbprovides per-test template clones from a single warmed schema; together they amortise the container-start cost across the entire suite. - CI artifacts MUST be machine-readable. Plain
go testoutput is opaque to GitHub Actions' "Tests" tab and to most reporting tools. JUnit XML is the lingua franca.
External anchors:
- Go Test Documentation —
t.Run,t.Parallel,t.Cleanup,testing.Msemantics. - testcontainers-go documentation — the canonical container-management library.
- pgtestdb README — the template-clone pattern.
- Playwright Documentation — fixtures, projects, dependencies, retries.
gotestsumREADME — JUnit XML and live-output formats.
Prescriptive¶
Table-driven tests¶
- Every test function that exercises more than one input case MUST use the table-driven form:
func TestParse(t *testing.T) {
cases := []struct {
name string
input string
want Value
wantErr bool
}{
{name: "empty input", input: "", wantErr: true},
{name: "well-formed", input: "ok", want: Value{...}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Parse(tc.input)
if tc.wantErr {
if err == nil { t.Fatalf("want err, got nil") }
return
}
if err != nil { t.Fatalf("unexpected err: %v", err) }
if got != tc.want { t.Fatalf("got %v, want %v", got, tc.want) }
})
}
}
t.Parallel()MUST be the first call inside every subtest body unless the subtest specifically depends on serial state (a writable filesystem path, an environment variable). The Go 1.22 loop-variable semantics make capturingtcsafe; earlier Go releases MUST capture withtc := tcand a// keep test cases independentcomment.- Each case MUST have a stable, descriptive
namefield. Thet.Runname becomes the JUnit subtest name and the-runfilter; magic strings produce unfilterable failures. t.CleanupMUST be used in preference todeferinside test helpers;Cleanupruns even whent.FailNowwas called.- Helper functions that call
t.FatalfMUST callt.Helper()first so the failure line points at the caller, not the helper.
testcontainers + pgtestdb for real-DB integration tests¶
- Integration tests MUST stand up a real PostgreSQL container. The
canonical wiring is
testcontainers-go/modules/postgreswith theWithDatabase,WithUsername, andWithPasswordoptions. - The container MUST be started once per test process, not once per
test. The recommended pattern is a
TestMainthat starts the container, captures the connection string, and passes it through a shared package-level variable topgtestdb.New(t, ...). - Per-test database isolation MUST be provided by
pgtestdb. pgtestdb uses the container's template-database mechanism to clone a freshly migrated database for each test; clones complete in single-digit milliseconds because Postgres copies pages from the template. - Each test MUST call
pgtestdb.New(t, conn, migrator)and receive a fresh*sql.DB. Shared mutable state across tests MUST NOT happen. - The migrator function passed to
pgtestdb.NewMUST be the same gooseMigrateimplementation production uses. Forking the migration logic for tests defeats the value of integration testing. - The container image tag MUST match the production Postgres version
to the minor. Cross-minor differences in default
pg_hba.confand in error messages are a recurring source of pass-on-laptop / fail-in-CI flake.
No mocks for the DB boundary¶
- The query layer (the sqlc-generated
Queriesinterface and any hand-written wrappers aroundpgx) MUST be exercised against a real Postgres in integration tests. A mock implementation of the query interface MUST NOT be used in integration coverage. - Layers ABOVE the query interface (handlers, business-logic
services) MAY accept a
Queriesinterface and be unit-tested with a hand-written fake. The fake MUST live in_test.gofiles; it MUST NOT be exported. - The reason for this rule is empirical: mocked
Queriesfakes drift from the real query semantics (NULL handling, type coercion, error shape) and accumulate "passes in CI, fails in prod" defects. The cost of a real-Postgres test (single-digit milliseconds per template clone) is far below the cost of debugging a mock-divergence defect. - Repository tests (the layer that wraps
Querieswith retry, caching, or transaction-management logic) MUST run in the integration partition with real Postgres. Stubbing the underlyingQueriesinterface in repository tests MUST NOT happen.
Playwright for browser E2E¶
- Browser-level end-to-end tests MUST use Playwright. The
configuration file (
web/playwright.config.tsor equivalent) MUST be checked in. - The Playwright config MUST set
testDirandoutputDirexplicitly; defaulting to the working directory creates surprises when CI's working directory shifts. fullyParallelSHOULD default tofalseuntil the suite is proven order-independent; settingfullyParallel: trueon a suite that mutates shared state introduces flake. Tests MUST be written to be order-independent and the team MUST flipfullyParallel: trueonce that is true.retriesMUST be zero in PR builds. Retries hide flake; flake is a defect. CI MAY setretries: 1on themainbranch as a safety net, but new failures MUST be triaged within the same SLA as test failures.- Auth state MUST be captured by a setup project that logs in once
and writes
storageStateto a known path (for example,e2e/.auth/admin.json). Subsequent projects MUST list the setup project underdependenciesand reference the savedstorageStateviause.storageState. trace: "retain-on-failure",screenshot: "only-on-failure", andvideo: "retain-on-failure"MUST be set. The CI artifact bundle MUST uploadplaywright-report/and theoutputDirso a reviewer can replay a failed run from the PR.- The
baseURLMUST be configurable via an environment variable with a sensible localhost default (for example,process.env.URL ?? "http://localhost:8080"). Hardcoding the URL forecloses running the suite against a staging environment.
gotestsum for JUnit XML¶
make testandmake test-integrationMUST shell out togotestsumrather than togo testdirectly. The canonical invocation isgotestsum --format pkgname --junitfile test-results.xml -- -race -count=1 ./....- The JUnit XML output MUST be uploaded as a CI artifact and MUST be
fed into the CI platform's test-report widget (GitHub Actions'
dorny/test-reporter, GitLab's JUnit ingestion, or equivalent). gotestsum --format pkgnameMUST be used for the live console output; the verbosego test -voutput is unreadable on a multi-package suite.-raceMUST be set on every test run. Race detector overhead is acceptable on the dev laptop and required for catching the concurrency defects this slate's architecture invites.-count=1MUST be set to disable Go's test result cache; a cached pass on stale source defeats the CI gate.
Build-tag separation of unit and integration¶
- Integration test files MUST carry a
//go:build integrationbuild tag in the first line of the file:
make testMUST run unit tests only (go test -race -count=1 ./...). It MUST complete in tens of seconds on a developer laptop without Docker running.make test-integrationMUST run integration tests (go test -tags integration -race -count=1 ./...). It MUST be skippable in PR builds for branches that do not touch the query layer, and MUST be required on the merge-to-main gate.- The Docker daemon MUST NOT be a prerequisite for
make test. Engineers without Docker installed MUST still be able to run the unit suite. - The same package MAY contain both unit and integration test files; the build tag is the discriminant. The team MUST NOT split integration tests into a sibling directory because import paths and helper visibility diverge across directories.
Reference Implementation: Pioneer
The Pioneer donor codebase implements the browser E2E layer
above in /home/ubuntu/pioneer/web/playwright.config.ts. The
config sets testDir: "./e2e", outputDir: "./e2e-results",
fullyParallel: false, retries: 0, a 30-second test timeout
with a 10-second expect timeout, baseURL from
process.env.PIONEER_URL with a http://localhost:8080 default,
trace: "retain-on-failure", screenshot: "only-on-failure",
video: "retain-on-failure", and a two-project setup: a setup
project matching global-setup.ts and a smoke project that
depends on setup and loads storageState:
"e2e/.auth/admin.json". The shape is the canonical pattern for
a Playwright suite with auth-state reuse, and adopters SHOULD
mirror the projects/dependencies/storageState wiring.
The donor's go.mod also pins the testcontainers-go module at
a current minor (v0.42.0) and includes the postgres submodule
at the same version. Adopters SHOULD treat the testcontainers
version as informational — testcontainers-go is on a stable
v0.4x line with frequent minor bumps; tracking it within one
minor of upstream is the practical guidance.
Pinned versions¶
| Component | Version pinned | Rationale |
|---|---|---|
| Go toolchain | 1.26.1 | Loop-variable semantics simplify table-driven tests. |
github.com/testcontainers/testcontainers-go |
v0.42.x | Stable v0.4x line; Postgres module ships in lockstep. |
github.com/testcontainers/testcontainers-go/modules/postgres |
v0.42.x | Matches the testcontainers core minor. |
github.com/peterldowns/pgtestdb |
v0.1.x | Template-clone pattern; small surface, stable. |
gotestsum |
v1.12.x | JUnit XML output and live console format. |
@playwright/test |
^1.59.1 | Latest stable; matches the donor package.json baseline. |
| Postgres container image | postgres:16-alpine |
Matches the production minor; alpine for footprint. |
Pitfalls¶
- Mocking the DB. Mocks drift from real semantics. SHOULD run the query layer against a real Postgres in integration tests.
- Shared mutable test state. A package-level
*sql.DBreused across tests guarantees flake. Each test MUST own a fresh template-cloned database frompgtestdb. - Missing
t.Parallel. Serial tests artificially lengthen CI wall-clock. Every subtest body SHOULD callt.Parallel()unless it specifically depends on serial state. -count=1omitted from CI. The Go test cache MAY return a pass for a regressed source tree.-count=1disables the cache.- Setting
retries: 1in Playwright PR builds. A flake hidden by a retry stays in the codebase. SHOULD setretries: 0in PRs and triage every failure. fullyParallel: trueon an order-dependent suite. Flake appears as cross-test data leakage. SHOULD prove order independence first, then flip the flag.- Hardcoded
baseURLinplaywright.config.ts. Forecloses running against staging. Source fromprocess.envwith a localhost default. - Sharing the production migration logic only "in spirit" with pgtestdb. Two divergent migrators means tests no longer cover production behavior. SHOULD pass the same migrator to pgtestdb.
- Mixing unit and integration tests without the
//go:build integrationtag. Developers without Docker can no longer runmake test. Tag the integration files. - No JUnit upload from CI. Engineers cannot see which test
failed without scrolling raw logs. SHOULD pipe
gotestsum --junitfileand upload it. - Cross-minor Postgres mismatch (laptop Postgres 17, CI Postgres
16). Default
pg_hba.conf, error wording, and a handful of query plans differ across minors. Pin the test container to the production minor.
See also¶
- RFC 2119 keywords — every MUST/SHOULD/MAY in this chapter follows the canonical definitions.
- Go testing package —
t.Run,t.Parallel,t.Cleanup,t.Helper,TestMainsemantics. - testcontainers-go documentation.
- pgtestdb documentation.
- Playwright documentation.
gotestsumdocumentation.- Chapter
02-data.md— sqlc query interfaces; goose migrations; the migrator passed to pgtestdb. - Chapter
03-surface.md— Connect-RPC handler interfaces above the query layer; the boundary unit tests MAY mock. - Chapter
04-infra-tooling.md—make test/make test-integrationsplit; gotestsum invocation; Docker daemon prerequisite handling. - Chapter
06-security.md—gosec/govulncheckparticipate in the same CI run; failing security scans MUST block on the same gate as failing tests. - Future ADRs — when to flip
fullyParallel: trueon the Playwright suite is a candidate ADR; the unit/integration partition policy is a candidate ADR.