Testing
This page tells you which tests to run before a pull request, which kind of test to write for a change, and what CI checks for you.
Before a pull request
just validate
just alone runs the same recipe. It checks formatting, runs Clippy and ESLint,
looks for unused code and dependencies, type-checks Rust and TypeScript, runs the
Rust unit tests and the TypeScript SDK’s tests, builds the Rust docs, runs the doc
tests and audits the dependencies. It needs no database and no browser.
Then run the layers your change can affect: just db test when it touches SQL,
migrations or code that reads and writes the database, and the browser suite when
it changes a page, a script or a sign-in flow.
While you work, run only the tests for what you changed, such as
just test -p auth-service.
The test layers
Write a test at the lowest layer that can show the behaviour: a unit test if one can, a database test if the behaviour lives in SQL or a transaction, a browser test only when it needs a real browser.
Rust unit tests
just test # the whole workspace
just test -p auth-service -E 'test(step_up)'
just test runs the workspace’s tests with
nextest, and passes its arguments on. Unit tests live in a
tests.rs beside their module, as the
Rust conventions describe.
They need no network. Passkey ceremonies use a software authenticator
(SoftPasskey), GitHub sign-in a local HTTP server that stands in for GitHub’s
API, and Google sign-in ID tokens signed with keys the test generates.
Database tests
just db test
Tests that need PostgreSQL are marked
#[ignore = "requires TEST_DATABASE_URL"], so just test skips them.
just db test starts a throwaway PostgreSQL 18 cluster that listens only on a
Unix socket, runs every test of auth-service and sourceorigin-repository in it,
ignored ones included, and deletes it. It then runs just db prepare-check,
which fails if the committed query cache doesn’t match the migrated schema
exactly.
To use a database of your own instead, set TEST_DATABASE_URL to it. The tests
create accounts in it and leave it migrated, so name only a database you can
throw away, never one that holds real accounts. A TCP URL must include
sslmode=disable or sslmode=verify-full.
A new database test connects through its crate’s fixture:
Database::test_pool() in crates/database, or Replicas::database() in
apps/auth-service/src/tests.rs.
SDK test vectors
The Rust and TypeScript SDKs run the same cases, in
tests/sdk-vectors,
so both accept and refuse the same tokens, key sets, callbacks and cookies. The
cases are generated by crates/common/tests/sdk_vectors.rs. When an SDK
behaviour must be the same in both languages, add a case there, then:
just sdk-vectors
The vectors’ README describes the case format.
TypeScript SDK tests
just web test # unit tests, every vector included, with coverage
just web smoke # the built package in Node, Bun and workerd
just web test runs packages/auth/test/*.test.ts with Node’s test runner.
just web smoke builds the package, then validates one vector token with it in
Node, Bun and workerd, the Cloudflare Workers runtime. It needs
bun installed.
Browser tests
just web test-browser
The browser suite runs Chromium against a running deployment of auth over HTTPS: CI’s, or a preview of your checkout. Write one for what only a browser shows: a page’s layout or script, a passkey ceremony, a sign-in that crosses origins. The browser tests’ README says how to run them.
Generated files
Ordinary tests compare each committed generated file with what the code
generates now, so just test fails while one is stale. The query cache is checked
when the code compiles, and by just db prepare-check.
Generated files lists each one and the recipe
that regenerates it.
Write a focused test
- Test one behaviour, and name the test for what it proves, such as
google_key_fetch_is_a_client_span_of_its_request. Its doc comment says why the behaviour matters. - Assert the outcome, not that something ran. A test that only checks a page loads or a command exits 0 proves little and still has to be maintained.
- Don’t test that a removed feature stays removed. Once the code is gone, there is nothing left to break.
- Keep browser tests few. Each spec says at its top what it covers. Auth’s pages share one request budget a minute, which the whole suite spends, so test what a lower layer can’t.
Recipes
Test recipes pass extra arguments on to nextest or Playwright, quoted filter expressions included.
| Task | Recipe |
|---|---|
| Formatting, lints, unused code, type checks, unit tests, Rust docs, doc tests and audits | just validate |
| Format, or check formatting | just fmt / just fmt-check |
Clippy and ESLint with warnings denied, and publint and attw on the built TypeScript SDK | just lint (just rust lint / just web lint) |
| Unused Rust dependencies (cargo-machete), and unused web files, exports and dependencies (knip) | just unused (just rust unused / just web unused) |
| Type-check Rust, and TypeScript with the pages’ scripts | just check (just rust check / just web check) |
| Rust unit tests, or one package’s | just test / just test -p auth-service |
| Rust docs with warnings denied, and the doc tests | just doc / just doc-test |
| TypeScript SDK tests with coverage, and the runtime smoke test | just web test / just web smoke |
| Database tests and the query cache check, in a scratch cluster | just db test |
Query cache check alone, against a migrated TEST_DATABASE_URL | just db prepare-check |
Coverage per crate of every Rust test, database tests included, into target/lcov.info | just coverage |
| Any command against a scratch cluster | just db scratch -- psql |
| Browser tests, against a running deployment | just web test-browser |
Register the browser tests’ clients with auth on localhost | just web browser-client |
| Dependency audits (cargo-deny and pnpm) | just audit (just rust audit / just web audit) |
| Regenerate the query cache, the SDK vectors, the TypeScript SDK’s wire types, or the generated documentation | just db prepare / just sdk-vectors / just sdk-types / just docs reference |
| Build the book and check its links | just docs |
What CI runs
CI runs the same recipes, in these jobs:
| Job | What it runs |
|---|---|
checks | just rust fmt-check, just rust unused, and just rust audit when dependencies change |
web | just web fmt-check, check, lint, unused, test and smoke, and just web audit when dependencies change |
rust | just rust lint, just coverage against a PostgreSQL 18 service, just db prepare-check, just doc and just doc-test |
e2e | The browser suite, against the debug binary behind Caddy, with Mailpit receiving auth’s email and sourceorigin.dev’s web server on its own host |
docker | Builds the auth, web and docs images |
docs | just docs |
A pull request runs only the jobs its changes can affect, by the paths it changes:
Rust jobs for Rust code, the web job for files Prettier, ESLint or tsc can see,
and e2e and docker for anything the service, its images, the TypeScript SDK
or the browser suite is built from. Changes to Markdown alone run only docs, which runs on every
pull request, since a change anywhere can break a link to it. The changes job
in ci.yml
has the exact filters.
The merge queue, pushes to main and manual runs run every job. The ci check
sums the others up: it fails if any job failed, and passes when the rest
succeeded or were skipped. It is the one check the merge queue waits for.
Coverage is reported, never enforced. rust prints it per crate and uploads
target/lcov.info as the rust-coverage artifact, and just web test prints
the TypeScript SDK’s. A separate workflow audits the dependencies every Monday,
so new advisories surface without a change.
CI doesn’t run:
just docs screenshots, which retakes the documentation’s screenshots on a preview; see screenshots.- Sign-in with live Google or GitHub accounts, or Apple’s and Google’s password managers syncing passkeys between real devices. Try those by hand on a preview when your change touches them.