Skip to content

Testing

Francisca Portugal edited this page Oct 27, 2026 · 1 revision

How the backend is tested: bun:test, the real Elysia app in-process, fake repositories instead of a database. Status: current.

Runner

cd apps/backend
bun run test            # bun test
bun run test:watch
bun run test:coverage
  • bun:test only. Never node:test or node:assert; use describe, it, expect.
  • bunfig.toml sets env = 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.

The core pattern

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.
  • Request is constructed in memory; app.handle() returns a Response.
  • No port is opened, so suites can run in parallel.

This works because the interface layer depends on ports, not Prisma — see Repositories.

Suites

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.

Shared helpers

support/catalog-test-utils.ts

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.

support/auth-test-utils.ts

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.

A catalog route test

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 suite pattern

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 and OpenAPI suites

  • Error handler: createApp() is extended with throwing routes (/thrown, /boom, /bad-response, …) and the suite asserts status, code and body. captureConsole swaps console.error to 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 shared ApiError schema, and that 302 responses have no content.

What is not tested

  • 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.tsx is 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.

Adding tests

  1. Put route tests under tests/interface/routes/<group>/; one file per resource.
  2. Reuse the support helpers. If the resource is new, add fixtures + a fake to catalog-test-utils.ts and default it in createCatalogApp().
  3. Call registerCatalogAuthGuard inside the describe for every protected path.
  4. Assert the exact body for success and errors — that catches stripped fields and wrong codes.
  5. Add a case for each failure the route can produce (422, 404, 401, domain codes).
  6. Run bun run test and bun run typecheck.

Pitfalls

  • 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:test or node: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 = false means 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.ts guards that.

See also


Last verified: 2026-10-27

Clone this wiki locally