Testing
What exists
| Package | Runner | What it covers |
|---|---|---|
@crag-topo/shared | Vitest (Node) | Grade normalisation, ID minting, slugs, cursor encoding |
@crag-topo/api | Vitest in workerd, against a real D1 | Endpoints, sync pull/push, conflict and rejection paths |
@crag-topo/mobile | — | Nothing. See gaps |
@crag-topo/docs | The build | Broken links fail it |
pnpm test # everything
pnpm --filter @crag-topo/api test # just the API
pnpm --filter @crag-topo/shared test:watch
API tests run against a real database
apps/api/vitest.config.ts uses @cloudflare/vitest-pool-workers to run the tests inside
workerd with a real D1 instance, migrated from the same migration files production uses.
const migrations = await readD1Migrations(path.join(import.meta.dirname, 'migrations'));
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: './wrangler.jsonc' },
miniflare: {
bindings: { TEST_MIGRATIONS: migrations, ENVIRONMENT: 'local', GIT_SHA: 'test' },
d1Databases: { DB: 'test-db' },
},
}),
],
test: { setupFiles: ['./test/setup.ts'] },
});
This is not incidental. The bugs worth catching in the sync layer are pagination bugs — a cursor that skips a record when two share a millisecond, a tombstone that never reaches the client. A mocked database returns whatever you told it to and would happily confirm a broken cursor works.
It also means the migrations themselves are exercised on every test run: a migration that no longer parses fails the test suite, not production.
What the API tests actually assert
Worth reading apps/api/test/api.test.ts — but the ones carrying weight:
- A pull replayed with the returned cursors yields nothing new. This is the whole correctness claim of delta sync in one test.
- A malformed cursor is a 400. Not a silent fall back to a full table download.
- A stale
baseUpdatedAtproducesconflict, with the server's record attached. Not a silent overwrite. - A mutation for another user's record is
rejected. Not a 500, not applied. - Production refuses to serve sync without
ALLOW_UNAUTHENTICATED. There is no auth yet (ADR-0007), and this is the test that stops open writes reaching real users by accident. /healthreports the git revision. The deploy workflows assert on this, so a change to its shape must break a test.
The gaps
Written down so nobody assumes coverage that is not there.
No mobile tests at all
pnpm --filter @crag-topo/mobile test prints a message and exits zero. That is honest but it is
not testing.
The things most worth testing first, in order:
sync.ts— the outbox loop, applying a change set, cursor persistence. This is the most intricate logic in the app and the most expensive to get wrong.db.tsquery helpers — against an in-memory SQLite, checking that soft-deleted rows are filtered and ordering is bysort_index.topo-overlay.tsx'stoPath()— pure geometry, trivially testable, and a wrong curve means route lines that do not match the rock.
expo-sqlite has a Node-compatible path, so 1 and 2 do not need a simulator.
No end-to-end tests
Nothing drives the real app against the real API. Maestro is the usual answer for Expo. The PR preview partly substitutes — a human with a phone is a slow but thorough E2E test — but it is manual.
Sync cases not covered
- Tombstone propagation: a deleted route reaching a client that was offline
- Multi-page pulls with
hasMore - The same-millisecond tie the composite cursor exists to handle — the case most likely to break and the one with no test
No load or limit testing
D1 has row-read and query-duration limits. Nothing measures how close a large crag's
/bundle response gets to them.
Conventions
- Test the seam, not the implementation. The API tests go through
app.fetchwith realRequestobjects, so a handler can be rewritten without touching a test. - Seed inside the test.
beforeEachclears every table (children first, because of the foreign keys) and each test seeds what it needs. No shared fixture state. - Assert on failure modes. A test that a 404 has
code: 'not_found'is more valuable than a third happy-path test. - No snapshot tests of API responses. They fail on every additive change and teach people to re-record without reading.
Adding tests to a package that has none
// package.json
"scripts": { "test": "vitest run" },
"devDependencies": { "vitest": "4.1.10" }
pnpm test at the root runs -r --parallel, so a new package is picked up automatically.
Related
- Offline-first — what the sync tests are protecting
- CI/CD — where these run