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
- Setup: modules. Downloads and verifies the module graph.
- Setup: database. Brings up the compose stack, drops the test database, and prepares it fresh from the migrations.
- 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.
- Schema: models current. Runs
sqlc diff: a generated model that does not match its query is a failure here, not a silent drift. - Style: gofmt. Fails if any file is not gofmt-clean.
- Style: go vet.
- Style: golangci-lint, against
.golangci.yml. - Build.
go build ./.... - Tests.
go test -race -cover ./.... - Worker: boots and drains (apps made with
--jobsonly). Builds the binary and runs it asworkerfor 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):
bogie.tomlexists.- Every marker (
bogie:controllers,bogie:wire,bogie:routes, andbogie:jobsif the app has jobs) is present exactly once, in the root files and in every namespace package's ownserver.goandroutes.go. - The pinned tools (goose, sqlc, credentials) are still under the
tooldirective ingo.mod. - The layout files generators depend on exist:
db/sqlc.yaml,db/db.go,bin/ci,docker-compose.yml,AGENTS.md,config/master.key. - Every controller it finds, plain or namespaced, is both constructed in
app/application.goand mounted in the rightroutes.go. A mount with no construction is a nil controller whose first request panics; a construction with no mount serves nothing, and both are caught here rather than at runtime.
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.