Backend testing
Status: Scaffolded 2026-09-24
vitest runs the suite, supertest drives the Express app returned by buildApp, and mongodb-memory-server provides a real MongoDB per run. No database mocks: schema validation, unique indexes, TTL fields, and 2dsphere queries all execute for real, so the suite proves the data layer and not a stand-in for it.
Layout
test/
helpers/
app.ts buildTestApp(): buildApp with a test env and a silent logger
db.ts start and stop the memory server, drop the database between files
auth.ts createUser(), asMember(app), asAdmin(app): register and return a Bearer token
routes/
auth.test.ts
places.test.ts
healthz.test.ts
stats.test.ts
services/
cursor.test.ts
crypto.test.ts
vitest.config.ts globalSetup starts one memory server, each test file gets its own database name
Route tests sit under test/routes/ and are named after the router file they cover. Pure functions in lib/ and services/ get unit tests under test/services/. Fixtures are built in the test through the API where possible (register a user, create a place) so a test exercises the same path a client would.
What every route test includes
| Case | Assert |
|---|---|
| Happy path | status, the envelope shape, and that the response contains only the fields the API documents |
| Validation error | 400, error.code === 'validation_error', and a details[].path naming the bad field |
| Unauthenticated | 401 unauthenticated with no token, and with an expired or garbage token |
| Wrong principal or role | a second user, or a member on an admin route: 403, or 404 where existence must not leak |
| Visibility filtering | create items the caller may and may not see, list, and assert the hidden ones are absent |
The last two cases are the permission boundary. A route test without them is incomplete regardless of what else it covers. For places specifically: a pending place never appears in GET /api/places for a member, does appear in GET /api/admin/places/pending for an admin, and returns 404 from GET /api/places/:slug until approved.
Example shape:
it('hides pending places from the public list', async () => {
const admin = await asAdmin(app);
const member = await asMember(app);
await member.post('/api/places').send(validPlace).expect(201);
const res = await request(app).get('/api/places?bbox=-119,33,-117,35').expect(200);
expect(res.body.items).toHaveLength(0);
await admin.get('/api/admin/places/pending').expect(200);
});
Running
npm test # the whole suite, what validate runs
npx vitest run test/routes/places.test.ts # one file
npx vitest test/routes/places # watch mode on a pattern
npx vitest run -t 'hides pending' # one test by name
Tests log nothing (LOG_LEVEL is forced to silent under NODE_ENV=test). Set LOG_LEVEL=debug explicitly when chasing a failure.
First run
mongodb-memory-server downloads a MongoDB binary (around 100 MB) into its cache directory the first time it runs on a machine, and again when the pinned version changes. On a slow connection the first test file can exceed the hook timeout; vitest.config.ts sets hookTimeout high enough for the download, and CI caches the binary directory keyed on the package lock so it downloads once per version. If the first run fails with a download error, run it again; a partial download is retried, not reused.
On Windows the binary is cached per user, and antivirus scanning of the extracted binary can add a few seconds to startup. Nothing in the tests depends on the platform.
What the scaffold suite covers
Auth (register, login, magic-link issue and verify, sessions list and revoke, me, delete), places (bbox list, get by slug, create pending, admin approve and reject), healthz, stats, and the cursor and crypto helpers. The 501 stubs have one test each asserting the status and code, so a route that is accidentally left unmounted fails.