Backend testing pipeline runbook
Developer-facing runbook for the backend CI test surface. Pairs the pipeline jobs with the test code they enforce, gives a copy-pasteable local-run path against the dev-stack mydev_postgres container, and includes two mini-cookbooks for adding new TEST_DSN-gated tests and new structural guards.
For the higher-level overview of Vetrix CI/CD see
../../user-docs/cicd/pipeline-reference.md. The
pipeline parser (intentionally strict) lives in
internal/cicd/parser/parser.go (vetrix backend repo) — its accepted-
key list is documented in CLAUDE.md (vetrix backend repo root; gitignored,
not tracked in version control) and at the head
of vetrix-ci.yml (vetrix backend repo root). Do not reintroduce
GitLab/GitHub-style keys; the parser will reject them with a structured
ParseError.
Why this doc exists
A SELECT projection / Go struct drift is a class of bug the backend
test suite only catches when it actually runs in the pipeline. The
go-test job runs go vet + go test ./...; the
go-integration-test job runs the DB-touching suites against an
ephemeral Postgres seeded via TEST_DSN. A static guard pattern
catches the projection / struct drift class at compile-time, and
per-method roundtrip reads catch field-order drift at runtime.
This doc is the operator / contributor entry point for that surface.
CI jobs at a glance
| CI job | Test layer | Code path |
|---|---|---|
go-test |
Pure-Go unit tests + go vet + structural guards (no TEST_DSN) |
internal/**/*_test.go — TEST_DSN-gated tests skip cleanly via openMRTestPool(t) |
go-integration-test |
DB-touching tests via openMRTestPool(t) + //go:build integration suites |
internal/**/*_test.go under -tags integration against ephemeral Postgres seeded with TEST_DSN |
Both jobs live in vetrix-ci.yml (vetrix backend repo root) under the
test stage and target golang:1.25 (matches the go.mod toolchain
directive). Neither job uses caching; the Go module cache is re-fetched
per run. That is documented as an acceptable tradeoff in the inline
comments at the top of each job and is a consequence of the strict
parser (cache: is not an accepted key — see
internal/cicd/parser/parser.go (vetrix backend repo)).
go-test
Snippet from vetrix-ci.yml (vetrix backend repo root):
go-test:
stage: test
image: golang:1.25
commands:
- go vet ./...
- go test -count=1 ./...
What this exercises:
go vet ./...— the standard static-analysis pass. Catches printf-format issues, struct-tag typos, lock-by-value, etc.go test -count=1 ./...— every*_test.gofile in the module. TEST_DSN-gated tests (~60 files at time of writing — search the tree withgrep -l TEST_DSN internal/**/*_test.go) callt.Skip(...)and do not consume CI time. Integration-tagged tests (~31 files,//go:build integration) are excluded by build tag.- Structural projection guards — see Cookbook: structural guards
below. These run as ordinary unit tests so they fail in
go-testbefore integration even attempts to bring up Postgres.
-count=1 disables the test-result cache so flaky-on-rebuild
regressions surface instead of being masked by a stale pass.
go-integration-test
Companion job that stands up an ephemeral Postgres in the runner's
docker daemon, applies db/migrations/ to head via the
cmd/vetrix-migrate/main.go (vetrix backend repo) entry point,
exports TEST_DSN, and runs go test -tags integration ./....
Why a dedicated cmd/vetrix-migrate rather than reusing
cmd/server: the server's main.go calls db.Migrate as a side
effect of startup, but the CI job needs to migrate without booting the
server (no port binding, no JWT_SECRET, no service-init side
effects). vetrix-migrate wraps the same internal/db.Migrate call
the server uses — same migration source-of-truth, zero new behaviour.
Full rationale lives at the top of
cmd/vetrix-migrate/main.go (vetrix backend repo).
Snippet (key lines from vetrix-ci.yml, vetrix backend repo root):
go-integration-test:
stage: test
image: golang:1.25
secret: true
variables:
POSTGRES_USER: "vetrix"
POSTGRES_PASSWORD: "vetrix"
POSTGRES_DB: "vetrix"
commands:
- apt-get update && apt-get install -y --no-install-recommends docker.io ca-certificates
- docker run -d --name vetrix-it-pg -e POSTGRES_USER=$POSTGRES_USER ... postgres:16-alpine
- PG_IP=$(docker inspect -f "{{.NetworkSettings.IPAddress}}" vetrix-it-pg)
- export TEST_DSN="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$PG_IP:5432/$POSTGRES_DB?sslmode=disable"
- go run ./cmd/vetrix-migrate -dsn "$TEST_DSN" -dir db/migrations
- go test -tags integration -count=1 ./...
Schema-compliance notes (the inline comments in vetrix-ci.yml go
into more depth):
- No
services:key. Vetrix has no concept of a job-level sidecar container. Postgres is started viadocker runagainst the runner- mounted host docker socket — same pattern asa11y-gateandweb-a11y-e2e. - No
cache:key. Re-fetching the Go module cache per run is the tradeoff. A future ticket can introduce a runner-side cache mount. secret: trueredacts all job-level variable values from the log stream.TEST_DSNagainst an ephemeral container is not a real secret, but redaction is cheap insurance against the DSN URL being echoed.- Cleanup discipline. The trailing
docker rm -fruns inside the same shell expression asgo test(theset +e/rccapture pattern) so the Postgres container is removed whether the tests pass or fail. Withoutafter_script:(parser-rejected) this is the only way to guarantee teardown.
Test-layer breakdown by suite
This list is not exhaustive but covers the suites known to depend on
TEST_DSN:
- Email-verification handler integration tests.
- Admin-pagination integration tests.
- Groups + role-templates handlers.
- Search consistency.
git.Storeroundtrip reads (internal/git/store_roundtrip_test.go(vetrix backend repo)).- MR-Open + Pull-Create suites.
Run locally
The dev stack already ships a Postgres container named
mydev_postgres (see
../../user-docs/getting-started.md). The integration
suite reaches it via container-bridge IP — the same pattern the CI job
uses, so a green local run is a strong signal the CI job will be green
too.
# 1. Discover the dev-stack Postgres IP on the docker bridge network.
PG_IP=$(docker inspect mydev_postgres -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
# 2. Export TEST_DSN. Default dev-stack creds are vetrix:q1w2e3r4 against
# the `vetrix` database — adjust if your dev stack overrides them.
export TEST_DSN="postgres://vetrix:q1w2e3r4@${PG_IP}:5432/vetrix?sslmode=disable"
# 3. Run the full backend test suite (unit + TEST_DSN-gated, no
# integration build tag).
go test -count=1 ./...
# 4. (Optional) include the //go:build integration suites — same
# invocation the CI go-integration-test job runs.
go test -tags integration -count=1 ./...
Notes on the local invocation:
-
No
cmd/vetrix-migratestep required. Themydev_forgeserver runs migrations at startup (seecmd/server/main.go(vetrix backend repo)), so the dev DB is already at head. Runvetrix-migrateonly when pointing at a fresh DB you have not booted the server against. -
Skip noise is expected. Many
*_test.gofiles print--- SKIP: TEST_DSN not setwhen run from a tree withoutTEST_DSNexported. WithTEST_DSNset those same tests run; with it unset they are no-ops. This is thego-testcontract. -
Single-package iteration. During development you usually want a narrower invocation:
go test -count=1 -run TestGetRepository_Roundtrip ./internal/git/ go test -tags integration -count=1 ./internal/api/ -
A clean DB matters. The integration tests use seed helpers that insert real rows and register
t.Cleanupto delete them, but a partial run can leave debris that breaks subsequent runs (PK collisions on stable seed IDs). If a previously-green test starts failing withduplicate key, drop the rows the helper inserts or reset the dev stack.
Cookbook: how to add a TEST_DSN-gated test
When you need a test that touches the database, follow the pattern
established by internal/git/store_roundtrip_test.go (vetrix backend repo):
func TestSomething_Roundtrip(t *testing.T) {
pool := openMRTestPool(t) // skips if TEST_DSN unset
store := NewStore(pool)
// 1. Seed via raw SQL so the test owns its fixture data.
owner, _, _, _ := seedRoundtripUser(t, pool)
id, name, _, _, _, _, _ := seedRoundtripRepo(t, pool, owner)
// 2. Read via the public store method under test.
got, err := store.GetRepositoryByID(context.Background(), id)
if err != nil {
t.Fatalf("GetRepositoryByID: %v", err)
}
// 3. Assert every populated field — this is what catches
// column-order drift that countTopLevelColumns can't see.
if got.Name != name {
t.Errorf("name: got %q, want %q", got.Name, name)
}
// ... assert every field that scanXxx populates ...
}
Three rules to keep this style honest:
-
Always use
openMRTestPool(t)(or anotherTEST_DSN-aware helper). The helper atinternal/git/merge_request_test.go(vetrix backend repo)t.Skips whenTEST_DSNis unset, so the test stays a no-op under thego-testjob and runs under thego-integration-testjob. New domains that need their own pool helper should follow the same skip pattern. -
Always register
t.Cleanupnext to the seed. Every seed helper instore_roundtrip_test.goregisters a delete viat.Cleanupin the same call that does the insert; the cleanup runs in LIFO order and is robust against test panics. Forgetting this is what produces theduplicate keyregressions called out in the "Run locally" section above. -
Insert via raw SQL, read via the public store method, assert every field. This is the value-add over the structural projection guards in
internal/git/store_columns_test.go(vetrix backend repo) — a column-count match with shuffled types would still scan successfully but populate the wrong fields. The roundtrip catches the order error.
Cookbook: how to add a structural guard
The pattern lives in
internal/git/store_repository_test.go (vetrix backend repo)
(TestRepositoryColumnsMatchStruct) and
internal/git/store_columns_test.go (vetrix backend repo)
(three additional guards: TestMergeRequestColumnsMatchStruct,
TestProtectedBranchColumnsMatchStruct, TestUserProfileColumnsMatchStruct).
The recipe:
-
Refactor the SELECT into a canonical projection constant. In
store.gothe constantsrepositoryColumns,mergeRequestColumns,protectedBranchColumns, anduserProfileColumnseach hold the single comma-separated projection list that every reader for that(struct, table)pair uses. EverySELECTfor that struct flows through the constant — column drift is a one-line fix. -
Add a Test that pairs the constant with the struct. Pattern:
func TestFooColumnsMatchStruct(t *testing.T) { got := countTopLevelColumns(fooColumns) want := reflect.TypeOf(Foo{}).NumField() if got != want { t.Fatalf("fooColumns has %d top-level expressions, "+ "Foo has %d fields — drift will break scanFoo", got, want) } }countTopLevelColumnslives instore_repository_test.goand counts comma-separated SQL expressions ignoring commas inside parentheses (so it correctly handlesCOALESCE(...)andCASE...ENDexpressions). -
Sanity-check the failure mode. Drop a column from the projection constant temporarily and confirm the test fails with a useful diagnostic. For example, dropping
closed_atfrommergeRequestColumnsmakesTestMergeRequestColumnsMatchStructfail with the expected14 top-level expressions vs 15 struct fieldsdiagnostic.
This guard runs in the go-test job — it is not
TEST_DSN-gated — so a projection / struct mismatch fails the
pipeline before integration even starts.
Known gaps
- Runner-config gap. The runner has two gaps: no workspace mount
and no CI env-var injection. Until it gains both, the
go-testandgo-integration-testjobs are syntactically valid against the parser but will not execute end-to-end on a real Vetrix runner. The runbook's local-run path still works (it bypasses the runner entirely). - No module cache. Both jobs re-fetch Go modules on every run. A
future ticket can introduce a runner-side cache mount once the
parser gains a
cache:key (or once a runner-side hook is wired up outside the YAML). - Test-result artifacts not collected.
go test -jsonoutput and coverage reports are not currently uploaded asartifacts.paths— the schema acceptsartifacts.pathsbut neither job emits a predictable file. A follow-up can add-coverprofile+ acoverage.outartifact path.
Cross-references
- ../../user-docs/cicd/pipeline-reference.md — full pipeline-schema reference.
- contributing.md — contributor entry point; this runbook is linked from its testing section.