Vetrix Docs

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.go file in the module. TEST_DSN-gated tests (~60 files at time of writing — search the tree with grep -l TEST_DSN internal/**/*_test.go) call t.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-test before 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 via docker run against the runner- mounted host docker socket — same pattern as a11y-gate and web-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: true redacts all job-level variable values from the log stream. TEST_DSN against 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 -f runs inside the same shell expression as go test (the set +e / rc capture pattern) so the Postgres container is removed whether the tests pass or fail. Without after_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.Store roundtrip 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-migrate step required. The mydev_forge server runs migrations at startup (see cmd/server/main.go (vetrix backend repo)), so the dev DB is already at head. Run vetrix-migrate only when pointing at a fresh DB you have not booted the server against.

  • Skip noise is expected. Many *_test.go files print --- SKIP: TEST_DSN not set when run from a tree without TEST_DSN exported. With TEST_DSN set those same tests run; with it unset they are no-ops. This is the go-test contract.

  • 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.Cleanup to 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 with duplicate 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:

  1. Always use openMRTestPool(t) (or another TEST_DSN-aware helper). The helper at internal/git/merge_request_test.go (vetrix backend repo) t.Skips when TEST_DSN is unset, so the test stays a no-op under the go-test job and runs under the go-integration-test job. New domains that need their own pool helper should follow the same skip pattern.

  2. Always register t.Cleanup next to the seed. Every seed helper in store_roundtrip_test.go registers a delete via t.Cleanup in the same call that does the insert; the cleanup runs in LIFO order and is robust against test panics. Forgetting this is what produces the duplicate key regressions called out in the "Run locally" section above.

  3. 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:

  1. Refactor the SELECT into a canonical projection constant. In store.go the constants repositoryColumns, mergeRequestColumns, protectedBranchColumns, and userProfileColumns each hold the single comma-separated projection list that every reader for that (struct, table) pair uses. Every SELECT for that struct flows through the constant — column drift is a one-line fix.

  2. 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)
        }
    }
    

    countTopLevelColumns lives in store_repository_test.go and counts comma-separated SQL expressions ignoring commas inside parentheses (so it correctly handles COALESCE(...) and CASE...END expressions).

  3. 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_at from mergeRequestColumns makes TestMergeRequestColumnsMatchStruct fail with the expected 14 top-level expressions vs 15 struct fields diagnostic.

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-test and go-integration-test jobs 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 -json output and coverage reports are not currently uploaded as artifacts.paths — the schema accepts artifacts.paths but neither job emits a predictable file. A follow-up can add -coverprofile + a coverage.out artifact path.

Cross-references