Repository navigation
Testing
How the backend is tested:
bun:test, the real Elysia app in-process, fake repositories instead of a database. Status: current.
cd apps/backend
bun run test # bun test
bun run test:watch
bun run test:coverage-
bun:testonly. Nevernode:testornode:assert; usedescribe,it,expect. -
bunfig.tomlsetsenv = false, so tests do not auto-load.env. Configuration is passed explicitly (createTestAuthConfig), which keeps a developer's local secrets out of test outcomes. - CI emits the Prisma contract first, then runs backend typecheck → backend tests → frontend and
SIGARRA typechecks → lint. The tests are the only step that boots
createApp(), so they catch wiring and module-resolution failures that typecheck cannot.
Every route suite drives the app through app.handle(request):
const { app, get } = createCatalogApp()
const { status, contentType, body } = await get('/v1/courses')
expect(status).toBe(200)
expect(contentType).toMatch(/^application\/json/)
expect(body).toEqual(COURSES)There is no listener and no database:
-
createApp({ repositories })receives fakes; the real Prisma client is never queried. -
Requestis constructed in memory;app.handle()returns aResponse. - No port is opened, so suites can run in parallel.
This works because the interface layer depends on ports, not Prisma — see Repositories.
| File | Covers |
|---|---|
tests/app/main.test.ts |
App composition, public vs protected route scoping |
tests/frontend-integration.test.ts |
GET /health, CORS allowlist, credentials |
tests/interface/middleware/error-handler.test.ts |
Every onError branch, logging, 5xx scrubbing |
tests/interface/plugins/openapi.test.ts |
Generated spec: tags, summaries, ApiError ref, bodyless 302s |
tests/interface/routes/auth/auth.routes.test.ts |
OIDC login/callback, state, /me, logout, dev login |
tests/interface/routes/auth/auth-compat.routes.test.ts |
Legacy aliases and their shapes |
tests/interface/routes/catalog/courses.test.ts |
List/filter/detail, 422, 404 |
tests/interface/routes/catalog/faculties.test.ts |
List/detail, faculty-before-courses 404 |
tests/interface/routes/catalog/course-units.test.ts |
Detail, occurrences, ordering |
tests/core/nmec.test.ts |
NMEC normalisation |
All suites use a top-level describe named after the unit ('courses routes') and it for cases.
| Export | Purpose |
|---|---|
COURSES, FACULTIES, COURSE_UNITS, OCCURRENCES
|
Fixtures, including a shared course and a unit with no occurrences |
createFakeCourseRepository / createFakeFacultyRepository / createFakeCourseUnitRepository / createFakeOccurrenceRepository
|
In-memory ports with the real filters and ordering |
createCatalogApp({ … }) |
Builds createApp() with the fakes and an always-signed-in session |
getCatalog(app, request, { authenticated }) |
Issues a GET with the real session cookie by default |
registerCatalogAuthGuard(app, paths) |
Generates the anonymous + forged-cookie 401 tests per path |
UNAUTHORIZED_BODY, VALIDATION_ERROR_BODY
|
Shared error bodies, so tests assert shapes not literals |
createCatalogApp returns { app, get }. get only issues GETs; for POST/PUT, call
app.handle(new Request(url, { method, … })) directly.
TEST_USER, ADMIN, TEST_SESSION_TOKEN, createTestAuthConfig, createFakeIdentityProvider,
createFakeUsers, createFakeSessions, createFakeDevAuthenticator, getCookie.
createFakeSessions is stateful: tok-1 is signed in, issue() mints tok-N, revoke() removes
the token. getCookie(response, name) extracts a Set-Cookie value without splitting on = inside
the value.
import { describe, expect, it } from 'bun:test'
import { COURSES, createCatalogApp, registerCatalogAuthGuard } from '../support/catalog-test-utils'
const { app, get } = createCatalogApp()
describe('courses routes', () => {
registerCatalogAuthGuard(app, ['/v1/courses', '/v1/courses/22841'])
it('lists every course', async () => {
const { status, contentType, body } = await get('/v1/courses')
expect(status).toBe(200)
expect(contentType).toMatch(/^application\/json/)
expect(body).toEqual(COURSES)
})
it('rejects a non-numeric id before the handler', async () => {
const { status } = await get('/v1/courses/abc')
expect(status).toBe(422)
})
it('answers 404 with a domain code for an unknown id', async () => {
const { status, body } = await get('/v1/courses/999999')
expect(status).toBe(404)
expect(body).toEqual({ error: 'Course 999999 not found', code: 'course-not-found' })
})
})Cover at least: happy shape, each filter, "unknown filter → []" where that is the contract, 422,
404, and the auth guard.
Auth tests mount authRoutes(deps) directly rather than the whole app, because they need different
configs per case:
function buildApp(config = createTestAuthConfig()) {
return authRoutes({
provider: createFakeIdentityProvider(ADMIN),
users: createFakeUsers(ADMIN),
sessions: createFakeSessions(ADMIN),
sigarraDirect: createFakeDevAuthenticator(),
config,
})
}The OIDC flow is exercised end to end: /login sets oidc_state, the callback checks it and sets
session, /me resolves it, /logout revokes it. getCookie reads the values from Set-Cookie.
-
Error handler:
createApp()is extended with throwing routes (/thrown,/boom,/bad-response, …) and the suite asserts status, code and body.captureConsoleswapsconsole.errorto prove the cause is logged and not leaked. -
OpenAPI: the full app is driven at
GET /openapi/json; the suite checks tags/summaries for the auth and health operations, the sharedApiErrorschema, and that 302 responses have nocontent.
- Database integration. No test runs a migration or queries PostgreSQL; repositories are faked.
- Real SIGARRA. The OIDC provider and dev authenticator are fakes; no network.
-
Frontend. There is no frontend runner;
apps/frontend/tests/App.test.tsxis a stale CRA leftover and CI only typechecks the SPA. - End-to-end / browser. No Playwright/Cypress.
- Catalog OpenAPI operations. The spec suite covers auth and health; catalog document entries are not asserted yet.
- Put route tests under
tests/interface/routes/<group>/; one file per resource. - Reuse the support helpers. If the resource is new, add fixtures + a fake to
catalog-test-utils.tsand default it increateCatalogApp(). - Call
registerCatalogAuthGuardinside thedescribefor every protected path. - Assert the exact body for success and errors — that catches stripped fields and wrong codes.
- Add a case for each failure the route can produce (
422,404,401, domain codes). - Run
bun run testandbun run typecheck.
-
Do not start a listener.
app.handle()is the whole point;app.listen()leaks ports and slows CI. - Do not touch the database. Without fakes, a catalog request will try to connect and fail.
- Do not copy fixtures into a test. Extend the shared support file; duplicates drift.
-
Do not use
node:testornode:assert. The runner and matchers are Bun's. - The fake must mirror ordering. Tests that pass with a different sort hide a production bug.
-
env = falsemeans no.env. Pass config explicitly; do not rely on local variables. -
The auth guard is scoped. A protected group must not 401 its siblings;
main.test.tsguards that.
- Repositories — the ports and fakes
- Error handling — the bodies the suites assert
- Adding an endpoint — the full route checklist
-
Request lifecycle — what
app.handle()drives - Directory tour 🚧 · CI 🚧
Last verified: 2026-10-27