Guide 8 of 12

Testing and CI

Where tests live

Beside the code they test, *_test.go, stdlib testing only: no factories, no fixtures, no spec/ directory. A generator writes the test file next to the file it tests every time it writes one.

bogie test
bogie t
BLOG_ENV=test go test -race -cover ./...

Either runs the whole suite with the race detector and coverage, in the test environment.

Store tests

A store test opens the real, test database, migrates it, and truncates every table (CASCADE, across the foreign keys between them) before the test runs, so each one starts from nothing. There is no in-memory substitute on purpose: a store test that never touches the database tests nothing. That means store tests need Postgres up first:

make up
bogie test

goose's version tables and River's schema are left alone by the truncation, so migration and job state survive between tests.

Controller tests

With a fake that satisfies the small interface declared beside the controller (see Resources, controllers and routing): no database, no real store, just a struct that records what it was asked for and answers with what the test chose. A test mounts the controller alone on gin.New(), so it sees the controller and nothing else: no middleware, no other routes.

Domain and service tests

A domain test is a plain function call: Validate and whatever rules live in app/domain, nothing to set up. A service test fakes the store interface the service itself declared, the same pattern as a controller test.

Adding a query

There is no where builder; a query is SQL with a name, in db/queries/<table>.sql:

-- name: ListCommentsForPost :many
SELECT * FROM comments WHERE post_id = $1 ORDER BY created_at DESC;

then make sqlc, which writes ListCommentsForPost into app/models/comments.sql.go with typed parameters, or fails if a column is misspelled. A store method in app/models/comments.go then calls it and converts the rows, as the generated methods do. Annotations: :one, :many, :exec, :execrows. The generated file is committed; bin/ci fails if it is stale.

bin/ci

The whole pipeline, run locally rather than on a hosted runner, the way Rails 8.1's bin/ci does. Every step runs even if an earlier one failed, so one run reports everything:

bin/ci
bogie ci
make ci
  1. Setup: modules. Downloads and verifies the module graph.
  2. Setup: database. Brings up the compose stack, drops the test database, and prepares it fresh from the migrations.
  3. Schema: down and up. Migrates all the way down to zero and back up, so a migration with a broken Down is caught before anyone else hits it.
  4. Schema: models current. Runs sqlc diff: a generated model that does not match its query is a failure here, not a silent drift.
  5. Style: gofmt. Fails if any file is not gofmt-clean.
  6. Style: go vet.
  7. Style: golangci-lint, against .golangci.yml.
  8. Build. go build ./....
  9. Tests. go test -race -cover ./....
  10. Worker: boots and drains (apps made with --jobs only). Builds the binary and runs it as worker for six seconds, to prove it starts.

A green run on a clean tree ends with one more step, Signoff: gh signoff records a status on the commit, if the basecamp/gh-signoff extension is installed; without it, the run is still green but says so. A failing run never signs off, and never should be merged or deployed.

bogie lint

bogie lint

gofmt check, go vet, then golangci-lint, the three style steps of bin/ci on their own, useful while iterating without waiting for the database steps.

bogie doctor

bogie doctor

Checks the things the generators rely on, since Rails never needed to check them (it autoloads):

It prints one line per check and exits non-zero if anything failed, which is also how a generator test for a removed marker is written: expect the named error, never a partial write.