This document defines the testing approach for this project. All agents writing or modifying tests MUST follow these conventions.
| Layer | Directory | Framework | Database? | Purpose |
|---|---|---|---|---|
| Server | tests/server/ |
Jest + Supertest | Yes (test DB) | API routes, middleware, services — real HTTP requests against Express |
| Database | tests/db/ |
Jest + Prisma | Yes (test DB) | Migrations, constraints, raw queries, JSONB, triggers |
| Client | tests/client/ |
Vitest + RTL | No | Component rendering, hooks, state logic |
| E2E | tests/e2e/ |
Playwright | Yes (dev DB) | Full user flows through a real browser |
npm test # Shows available suites
npm run test:db # Database layer
npm run test:server # Backend API layer
npm run test:client # Frontend components
npm run test:e2e # End-to-end browser testsOAuth flows (GitHub, Google) cannot be exercised in automated tests. The server exposes a test-only login endpoint that injects a fake authenticated user into the session, bypassing the OAuth redirect flow.
POST /api/auth/test-login
- Available only when
NODE_ENV === 'test'(or aENABLE_TEST_AUTHenv var is set). The route MUST NOT be registered in production. - Request body:
{ "provider": "github", "id": "test-user-1", "displayName": "Test User", "email": "test@example.com", "role": "student" } - Behaviour: Calls
req.login(user, ...)to establish a Passport session identical to what a real OAuth callback would produce. - Response:
200 { success: true, user: { ... } }
Server tests need to exercise authenticated API routes (profile CRUD, academic plans, chat, admin endpoints) without spinning up a real OAuth provider. By logging in through this endpoint first, subsequent Supertest requests carry a valid session cookie.
import request from 'supertest';
process.env.NODE_ENV = 'test';
import app from '../../server/src/app';
describe('authenticated API', () => {
let agent: request.SuperAgentTest;
beforeAll(async () => {
agent = request.agent(app); // maintains cookies across requests
await agent
.post('/api/auth/test-login')
.send({
provider: 'github',
id: 'test-user-1',
displayName: 'Test User',
email: 'test@example.com',
})
.expect(200);
});
it('returns the current user', async () => {
const res = await agent.get('/api/auth/me');
expect(res.status).toBe(200);
expect(res.body.displayName).toBe('Test User');
});
});Key detail: use request.agent(app) (not request(app)) so the session
cookie persists across requests within a test suite.
For admin-protected routes, tests should use the existing admin login:
await agent
.post('/api/admin/login')
.send({ password: process.env.ADMIN_PASSWORD })
.expect(200);Set ADMIN_PASSWORD in the test environment before importing the app.
- Every API route gets at least one happy-path test and one error/validation test.
- Tests make real HTTP requests via Supertest against the Express app.
- Tests that modify the database MUST use a test database (see §6) and clean up after themselves.
- Test both authenticated and unauthenticated access for protected routes.
import request from 'supertest';
process.env.NODE_ENV = 'test';
import app from '../../server/src/app';
describe('GET /api/some-resource', () => {
it('returns 401 when not authenticated', async () => {
const res = await request(app).get('/api/some-resource');
expect(res.status).toBe(401);
});
it('returns data when authenticated', async () => {
const agent = request.agent(app);
await agent.post('/api/auth/test-login').send({ ... }).expect(200);
const res = await agent.get('/api/some-resource');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
});
});- JSON APIs:
.send({ key: 'value' })— Supertest sets Content-Type automatically. - Form-encoded:
.send('key=value').type('form')— for any endpoint expectingapplication/x-www-form-urlencoded. - File uploads: use
.attach('file', buffer, 'filename.txt').
When a route modifies the database, assert the change:
it('creates a new record', async () => {
await agent.post('/api/items').send({ name: 'Test' }).expect(201);
// Verify the database was actually modified
const item = await prisma.item.findFirst({ where: { name: 'Test' } });
expect(item).not.toBeNull();
});- Test Prisma migrations apply cleanly.
- Test constraints (unique, foreign key, check) by attempting violations.
- Test JSONB queries, indexes, and raw SQL when used.
- Run against the test database with migrations applied in a
globalSetupscript.
- Use Vitest + React Testing Library.
- Test component rendering, user interactions, and state changes.
- Mock API calls — do not hit the real server.
- Located in
tests/client/, not co-located with source files.
Playwright drives a real browser against the running application.
E2E tests require the full stack running (server + client + database).
Use npm run dev or npm run dev:docker before running E2E tests.
E2E tests use the same test-login endpoint (§2) via Playwright's
request API to establish a session, then inject the cookie into the
browser context:
const context = await browser.newContext();
const apiContext = context.request;
await apiContext.post('/api/auth/test-login', {
data: { provider: 'github', id: 'e2e-user', displayName: 'E2E User' }
});
// Session cookie is now set — page navigations will be authenticated
const page = await context.newPage();
await page.goto('/dashboard');- Critical user flows: sign up, onboarding, questionnaire completion, plan generation.
- Navigation and routing.
- Form submissions that hit the API and display results.
- Error states visible in the UI.
The MCP server (POST /api/mcp) can be tested via Supertest like any
other API endpoint. Set the MCP_DEFAULT_TOKEN environment variable
and send requests with the Authorization: Bearer <token> header:
const res = await request(app)
.post('/api/mcp')
.set('Authorization', `Bearer ${process.env.MCP_DEFAULT_TOKEN}`)
.send({ /* MCP request body */ });MCP tools use the same ServiceRegistry as the web UI, so service-level
logic is testable independently of the MCP transport layer.
Tests that require a database use a separate test database. The
connection string is set via DATABASE_URL in the test environment.
The dev docker-compose.yml already provides a Postgres instance.
Tests can use either:
- The same Postgres instance with a different database name
(e.g.,
appname_test) - A dedicated test container
- Before suite: Run
prisma migrate deployagainst the test DB. - Between tests: Truncate tables or wrap each test in a transaction that rolls back.
- After suite: Drop the test database (optional; leaving it speeds up re-runs).
Import the Prisma client from the service module. Since tests run in
CJS mode via ts-jest, and the Prisma client is ESM, the lazy-init
pattern in server/src/services/prisma.ts handles this. Call
initPrisma() in a beforeAll block when tests need database access:
import { prisma, initPrisma } from '../../server/src/services/prisma';
beforeAll(async () => {
await initPrisma();
});
afterAll(async () => {
await prisma.$disconnect();
});- Follow the layer structure — put server tests in
tests/server/, not inserver/src/. - Every new API route MUST have corresponding tests.
- Use the test-login endpoint for authenticated route tests — never mock the session middleware directly.
- Assert both the HTTP response AND the database state when applicable.
- Use descriptive test names:
it('returns 403 when non-admin accesses admin route')notit('works').
- Run
npm run test:serverafter any backend change. - Run
npm run test:clientafter any frontend change. - Run
npm run test:e2ebefore marking a ticket as done (if E2E tests exist for that feature). - All tests must pass before a ticket can be marked done.
- Server:
tests/server/<feature>.test.ts - Database:
tests/db/<feature>.test.ts - Client:
tests/client/<Component>.test.tsx - E2E:
tests/e2e/<flow>.spec.ts