Testing¶
1. Overview¶
Tests use Jest 30 + ts-jest in ESM mode. Unit tests mock PrismaService and never touch a real database. E2E tests spin up the full NestJS application against a real (test) database and use Supertest for HTTP assertions.
ESM specs using jest.useFakeTimers, jest.spyOn, or other jest APIs must import jest from @jest/globals; the jest object is not an injected ESM global. See test/attendance-activities.e2e-spec.ts and test/attendance-combined.e2e-spec.ts for fake-Date setups that keep HTTP/DB timers real.
Resolve scoped Nest providers asynchronously with await app.resolve(...) in E2E setup. In particular, PinoLogger is transient-scoped and cannot be retrieved with app.get(PinoLogger). A helper constructing fresh service instances can reuse the logger resolved during setup; keep the service instances themselves fresh when testing recovery across instances.
Test-specific mock rule exceptions are configured in eslint.config.mjs. Do not add blanket eslint-disable directives to new test files; keep unrelated lint checks active.
2. Service Specs¶
Service tests use the provider dependencies of the service under test, with a manually crafted PrismaService mock. Stub only the delegates/methods actually called. The following is a partial wiring example; keep the constructor's other providers in the real suite.
const prisma = {
student: {
create: jest.fn().mockResolvedValue(mockStudent),
findMany: jest.fn(), findFirst: jest.fn(),
updateMany: jest.fn(), deleteMany: jest.fn(),
count: jest.fn().mockResolvedValue(0),
},
academicYear: { findFirst: jest.fn().mockResolvedValue({ id: 'year-1', status: 'ACTIVE' }) },
$transaction: jest.fn((fn: any) => fn(prisma)),
};
const module = await Test.createTestingModule({
providers: [
StudentsService,
{ provide: PrismaService, useValue: prisma },
{ provide: CustomFieldsService, useValue: createMockCustomFieldsService() },
],
}).compile();
Key points:
$transactionmock passes the sameprismaobject as the transaction client, so transactional calls resolve correctly.- Reset mocks in
beforeEachwithjest.clearAllMocks()to prevent state leaking between tests. - Only stub the Prisma delegates the service under test actually uses.
Canonical example: src/students/students.service.spec.ts
3. Controller Specs¶
Controller tests isolate routing using the overrideAuthGuards helper from src/common/testing/. This replaces JwtAuthGuard, ScopeGuard, ActionGuard, FieldWriteGuard, and FieldFilterInterceptor with no-op stubs so HTTP routing can be tested in isolation.
import { overrideAuthGuards } from '../common/testing';
const module = await overrideAuthGuards(
Test.createTestingModule({ controllers: [StudentsController], providers: [...] })
).compile();
For setup wizard controller specs, also assert the @HttpCode(200) decorator via reflection:
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
const httpCode = Reflect.getMetadata(
HTTP_CODE_METADATA,
SetupController.prototype.submitGroupStep,
);
expect(httpCode).toBe(HttpStatus.OK);
Canonical example: src/setup/setup.controller.spec.ts
4. Test Helpers¶
Unit-test helpers live in src/common/testing/.
| Helper | Use |
|---|---|
createMockCustomFieldsService() |
Mock CustomFieldsService for domain service tests |
createDeferredPostCommit() |
Buffer external effects until explicit simulated commit; discard on rollback |
overrideAuthGuards(builder) |
Override the helper's listed scope/action/auth guards and field filter; override RolesGuard separately when the tested controller requires it |
createMockAuthRequest(overrides?) |
Mock AuthenticatedRequest for controller tests |
createMockContext(user?, body?, permissions?) |
Mock ExecutionContext for guard unit tests |
E2E helpers live in test/helpers/.
| Helper | Use |
|---|---|
createTestApp(customise?) |
Build a full INestApplication with the same global pipes/filters/interceptors as main.ts, swap in MemoryMailer, and disable the throttler. |
loginAsAdmin / loginAsTeacher / loginAsReferent / loginAsHr / loginAsPrincipal / loginAsDeptHead / loginAsPlatformAdmin |
Authenticate as a seeded test user and return a Supertest agent with the access-token cookie set. Credentials come from prisma/seed/users.ts → E2E_CREDENTIALS. |
loginAs(app, email, password) / loginWithTenantSelection(...) |
Lower-level helpers for ad-hoc credentials or explicit tenant picks. |
loginUserData(prisma, input, profileType) |
Create/reuse a shared credential and supply the explicit school-local User.profileType before creating a User fixture. |
signResendWebhook(payload, secret) |
Generate a valid Svix signature for testing the Resend webhook endpoint. |
Person-binding fixtures must declare the matching RecipientType; do not infer it from a role ID or use null as a permissive placeholder. An established Staff membership cannot also bind a Teacher. To test Teacher + SEN, assign both roles to a dedicated Teacher membership.
An explicit employee-view login pick requires the corresponding role grant. Custom-role-only fixtures select their assigned role key; their Teacher/Staff person record supplies the profile anchor. For refresh tests expecting activeRole: null without a switch, use a single-view fixture that logs in directly: selecting teacher from a chooser sets activeRole: 'teacher'.
Management-reach fixtures that select the teacher or staff view must create
the matching base UserRole alongside the management assignment.
loginUserData(..., 'STAFF') and a linked Staff row do not create that grant.
5. Test Case Categories¶
Standard service spec categories for domain CRUD modules (see chapter 05 for the underlying service patterns):
- create — happy path + validation (grade lookup, duplicate detection, auto-generated codes)
- findAll — paginated response, tenant isolation
- findOne — found + not found
- update — partial update, not found
- remove — success + not found
- toScopedResponse — scope grouping, field mapping
- Entity-specific — import pipeline, lifecycle hooks
What to test per layer:
| Layer | What to test | What to mock |
|---|---|---|
| Service | Business logic, error cases, data transformations | PrismaService, other services |
| Guard | Allow/deny decisions | Reflector, PermissionsService |
| Interceptor | Response transformation | PermissionsService, Reflector |
| Controller | Delegation + decorator metadata | Service, guards (via overrideAuthGuards) |
6. Setup Wizard Testing¶
See chapter 08 for the wizard architecture. The test strategy has four layers:
DTO specs¶
Use plainToInstance() + validate() from class-validator directly — no NestJS DI needed. Define a toDto() helper that merges overrides onto a validData constant:
function toDto(overrides: Record<string, unknown> = {}) {
return plainToInstance(SchoolStepDataDto, { ...validData, ...overrides });
}
it('should fail country with invalid codes', async () => {
for (const code of ['XX', 'usa', '']) {
const errors = await validate(toDto({ country: code }));
expect(errors.some((e) => e.property === 'country')).toBe(true);
}
});
Canonical: src/setup/dto/steps/school-step.dto.spec.ts
Handler specs¶
Handlers are classes that delegate to domain services. Instantiate them directly with mocked domain services; use transaction mocks in the service tests that actually own database work:
Test all three methods (load, save, isComplete) plus error paths (missing academic year, duplicate names, ordinal gaps).
Handlers are organized into subdirectories: step-handlers/school-identity/ (school, year, grades) and step-handlers/people/ (staff, teachers, students, rollover). Form-driven steps (e.g. DEPARTMENTS) run through the generic src/setup/step-handlers/form-step.handler.ts rather than a dedicated handler.
Canonical: src/setup/step-handlers/school-identity/grades.handler.spec.ts
Service spec¶
State-machine coverage. Uses Test.createTestingModule with a mocked PrismaService. Covers: state transitions, mismatch errors, data loading per step, forward/backward/same-step navigation, completion gating.
Canonical: src/setup/setup.service.spec.ts
Controller spec¶
Thin delegation tests verifying the controller passes tenantId to the service. Also asserts @HttpCode(200) metadata on the POST endpoint via Reflect.getMetadata().
Canonical: src/setup/setup.controller.spec.ts
7. E2E Tests¶
E2E tests live in the test/ folder and spin up the full application against a real database via the shared createTestApp harness in test/helpers/app.helper.ts:
// test/teachers.e2e-spec.ts
import { createTestApp } from './helpers/app.helper';
import { loginAsTeacher } from './helpers/auth.helper';
describe('Teachers (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
app = await createTestApp();
});
afterAll(async () => { await app.close(); });
it('/teachers (GET) should require authentication', () => {
return request(app.getHttpServer()).get('/api/v1/teachers').expect(401);
});
});
createTestApp wires MAIL_TRANSPORT=memory, overrides the throttler with a no-op guard, and applies the same global pipes/filters/interceptors as main.ts. Pass a customise callback to override providers per-spec. Run with npm run test:e2e; E2E tests require a live database — start the Docker stack first (npm run docker:up).
Database isolation — how pollution is prevented¶
Give expensive setup and cleanup hooks explicit budgets: the policy-resolution suite allows 120 seconds for app startup plus owned-tenant preset cloning, and 60 seconds for teardown. Keep those limits on the hooks so individual policy assertions retain their normal timeout. Profiling app hooks allow 60 seconds; fixture creation remains outside measured request traffic.
The suite runs in N parallel workers, each against its own database clone, provisioned automatically per run. Within a worker execution is serial, and no database is shared across workers — so no database ever sees two concurrent specs. You never seed or reset anything by hand.
- Per-worker, disposable DBs.
test/global-setup-e2e.cjs(JestglobalSetup, once per run) migrates + seeds a template database (sis_e2e_template; on CIsis_ci_template), provisions the RLS-enforcingapp_usergrants on it, then drops and re-clones one database per worker (sis_e2e_w1..wN) viaCREATE DATABASE … TEMPLATE, a database-template copy.DROP … WITH (FORCE)clears connections leaked by a crashed prior run. By default every worker DB starts every run as an exact copy of the canonical seed: cross-run accumulation is impossible, and a new migration needs zero manual e2e-DB steps.E2E_DB_SUFFIXnames a separate family cloned from the same template (<base><suffix>_w<i>); only the scale runner sets it (§8). For a suffixed family only, the opt-inE2E_REUSE_WORKER_DB=1keeps a worker DB that already exists (no drop, no re-clone, no migration of it), so a baseline and a candidate measure the same kept fixture (REPORT_SCALE_KEEP=<path>writes the fixture handle to a local file,REPORT_SCALE_FIXTURE=<path>reuses it, intest/report-v2.e2e-scale.tsandtest/attendance-reader-golden.e2e-scale.ts). - Worker binding.
test/setup-e2e-env.tsrewrites the devDATABASE_URL(sis_dev→sis_e2e) and appends${E2E_DB_SUFFIX}_w${JEST_WORKER_ID}(the suffix is empty unless set) before the app boots, so e2e never touches your dev data and each worker lands on its own clone. A non-dev URL (CI's DB, an explicit override) keeps its base name and still gets the worker suffix.@nestjs/configwon't override aprocess.envvalue already set, so this wins over.env. - Worker count. Resolved once in
test/e2e-workers.cjs, shared by the jest config'smaxWorkers(test/jest-e2e.config.cjs) and the DB provisioning — that sharing is what guarantees a database exists for every worker.E2E_WORKERSenv var overrides; defaultmin(4, cores/2). CI pinsE2E_WORKERS: 2. - Same E2E provisioning in CI.
globalSetupowns the test clones and their seed. CI also runs migration deployment and schema-drift checks against its base database before E2E (see.github/workflows/ci.yml).
Why worker processes (and why not
--runInBand). EachcreateTestAppbuilds a fresh DI graph + two Prisma clients (PrismaServiceand the RLS-bypassingAdminPrismaService); Repeated boot/teardown cycles can exhaust a single long-lived process. Jest recycles a worker viaworkerIdleMemoryLimit: '512MB'whenever its post-file memory check crosses the threshold, keeping the working set bounded — and a recycled worker keeps itsJEST_WORKER_ID, so the worker↔DB binding is stable for the whole run. Do not revert to--runInBand, and do not run e2e through a barenpx jestwithout this config — the per-worker DB naming and provisioning come from the config pair above.
The reset boundary is per run, per worker — spec files scheduled onto the same worker still share that worker's DB within the run, so a spec must not depend on another spec's state (worker-to-file assignment is scheduler-determined and can change between runs). The assertion-hygiene rules in §9 make that sharing harmless.
8. Running Tests¶
npm test # All unit tests
npm run test:watch # Re-run on file changes
npm test -- --testPathPatterns=students # Single module
npm run test:cov # Coverage report
npm run test:e2e # E2E (requires DB)
npm run test:e2e:scale # Opt-in scaling canary (requires DB; NOT in the default gate)
Worker count. test:e2e parallelizes across workers (§7); override the count with E2E_WORKERS (e.g. E2E_WORKERS=1 npm run test:e2e to force serial when debugging, or a higher number on a big machine).
Opt-in scaling canary. test/*.e2e-scale.ts files run only under npm run test:e2e:scale (config test/jest-e2e-scale.json, testRegex: .e2e-scale.ts$) — the default test:e2e (.e2e-spec.ts$) skips them. They seed thousands of rows to surface scaling cliffs as a trend signal and are excluded from the default suite. The scale config pins maxWorkers: 1 (a duration trend needs an uncontended machine), and the test:e2e:scale script sets E2E_DB_SUFFIX=_scale, so the canary runs in its own clone (sis_e2e_scale_w1) and a default test:e2e run never drops a fixture it keeps. A direct jest --config ./test/jest-e2e-scale.json run sets E2E_DB_SUFFIX=_scale itself. test/curriculum-edit-scaling.e2e-scale.ts exercises curriculum-edit work as selection volume grows — tune volume with SCALE_STUDENTS (default 2500). The assertion is loose (completes under a generous bound); the logged [scale-canary] duration is the number to watch.
9. Rules¶
- Use the test-specific rules in
eslint.config.mjs; avoid blanket lint suppression. - Never hit a real database in unit tests — mock
PrismaServicewithuseValue. - Mock
$transactionasjest.fn((fn) => fn(prisma))for callback transactions. For services registering external effects, injectcreateDeferredPostCommit().coordinator; assert no effect before commit and none after rollback. An eagerregister(job) => job()stub hides timing bugs. - Use
AppException(not NestJS built-ins) in production code — test that the correctErrorCodeis thrown, not the HTTP exception class. - Canonical unit test example:
src/students/students.service.spec.ts - Canonical setup test examples:
src/setup/setup.service.spec.ts,src/setup/step-handlers/school-identity/grades.handler.spec.ts
Mocks for dated course and assistance consumers¶
- Mock only the clock query when the subject needs the real
withSchoolTodayhelper. Auto-mocking the entire temporal query module also makes that helper return undefined; a partial factory preservingjest.requireActualkeeps context hydration intact. - Authenticated guard fixtures must include session
rolesand the complete access-context slice (parameters,teacherDepartmentIds,parameterDimensions). An omitted role list is not a valid authenticated request. - Shared Prisma mocks must respect the requested projection.
course-roster.mock.tssynthesizes{id}rows only forselect: {id: true}; merge-target reads retain fields such as_count.assignments. Attendance visibility mocks distinguish dated metadata, authorized row reads and the final id-only existence probe. - Assistance-aware consumers need their child delegates even when the fixture has no shifts. Teacher setup removal also takes reference locks through
$queryRaw. Assert the shared AY-day lock before the course-day content lock, and preserve both ordinary and assistance branches in placement-plan filter expectations. - Enrollment/schedule writers mock the graph lock at
common/temporal/enrolment-schedule-lock. Assert the graph lock ahead of other locks and all writes. Student placement CAS capture must precede the graph lock, even inside the ambient HTTP transaction, so a racing command retains its original fingerprint. Timetable specs still mock their dated diagnostic/coverage inputs. - Review-header interceptor tests must model
TransactionHost's inherited CLS scopes. Use a realClsServicewithAsyncLocalStorage, thencls.run({ ifNested: 'inherit' }, callback)for transaction callbacks. Assert marks survive nested scopes, lookup follows commit, rollback discards the notice and lookup failure preserves success.
E2E assertion hygiene (keeps a shared DB pollution-proof)¶
The per-run reset (§7) kills cross-run accumulation; these rules make within-run sharing irrelevant, so a spec only ever sees its own data:
- Look records up by a unique marker, never by scanning an unfiltered page. Filter by email, a
Date.now()-stamped name, the tenant, or an id the spec just created — e.g.GET /referents?email=parent.doe@example.com, not.find()overGET /referentspage 1. A sibling spec's rows must be invisible to your query. - Never assert on the
totalordata.lengthof an unfiltered list, or on "X is on page 1." Those break the moment any other row exists. Assert on a filtered subset instead. - Use UUID markers for uniqueness-bound fixture fields, including emails, codes and names. Person duplicate checks also compare first name + last name + birth date: changing only the email does not make a new person. A timestamp alone can collide under a frozen clock or same-millisecond calls.
- Clean up everything you create in
afterAll— including rows behindonDelete: SetNullrelations (deleting theUserleaves aTeacher/Staffrow dangling; delete it explicitly). - Own the complete cohort behind exact attendance counts. Filtering a seeded grade is insufficient when another spec can add classless students to that grade. Create a dedicated grade, curriculum/subject, students, effective placements and memberships; keep the expected counts exact.
- Build coherent academic graphs for permission tests. Arbitrary seeded departments need not contain courses. A gradebook read also requires a taught subject/grade cell and an effective evaluation scale, even with an empty roster. Use an owned foreign-tenant course for isolation assertions and provide required Teacher fields such as
taxCodeand a uniqueinstitutionalEmail, including for disposable teachers without login accounts. - Staff every publishable timetable slot. A teacher inherited through one combined member covers the shared meeting; another member's solo slots still require their own ordinary teaching assignment. Pin fixture assignments to the captured school date and place lessons within teacher availability. Fake-solver FEASIBLE fixtures also need sufficient template capacity: generation runs the real staffing diagnostics after ingestion and rolls back with a 500 on any ERROR. See
test/timetables-combined.e2e-spec.tsfor missing-staff diagnostics followed by assignment and publication, andtest/timetables-generation.e2e-spec.tsfor staffed combined fixtures. - Normalize directly inserted login emails to lowercase.
LoginDtolowercases login input; a mixed-case email inserted directly through Prisma will not match the credential query. - Teacher attendance takes require school-today. Capture the intended school day, freeze
Datewith an explicitjestimport from@jest/globals, leave I/O timers real, and authenticate after freezing. Inattendance-combined.e2e-spec.ts,beforeEachresets the clock to the captured day andafterEachrestores real timers; sessions are first issued under that simulated date. Moving the submitted date into the future triggersATTENDANCE_FUTURE_DATE_LOCKED. - Use the actual tenant helper for RLS assertions. Call
withTenantGuc(app.get(PrismaService), tenantId, callback)fromsrc/common/utils/tenant-guc; it is a function, not aPrismaServicemethod. Direct fixture creation still usesgetAdminPrisma(). - Canonical examples of the lookup-by-marker pattern:
test/referents.e2e-spec.ts(filter by email),test/parametric-roles.e2e-spec.ts(own-created ids + explicit anchor cleanup). - Own the whole tenant for cross-module acceptance.
test/combined-enrolment.e2e-spec.ts(liketimetables-assistancebefore it) creates its own tenant (shared-${marker}), clones the preset roles withcloneRolesIntoTenant, builds year, department calendar, grades, curriculum cells, subjects, rooms, people, homeroom and courses in onebeforeAll, and tears everything down withresetTenantSetupDataplus the tenant delete. One ordereddescribecarries state; attendance-recording cases go last, because a cell recorded today makes later same-day roster changes refuse or slide to the next school day. - Override ports instead of stubbing HTTP.
createTestApp((b) => b.overrideProvider(SOLVER_PORT).useValue(gated))with aSolverPortwhosegenerateawaits a gate reproduces the stale-ingestion race deterministically: arm, start the generation, change a roster while the solver "runs", release, assertTIMETABLE_GENERATION_STALEand no timetable row. - Probe advisory locks through
pg_locks.locktype = 'advisory' AND objsubid = 1 AND classid::bigint = ((hashtextextended(key, 0) >> 32) & 4294967295) AND objid::bigint = (hashtextextended(key, 0) & 4294967295)withenrolmentScheduleGraphKey(tenantId, yearId); a barrier transaction holding the key queues competing requests (an import first, a roster write second) and the spec asserts who waits on whom, then releases and asserts both outcomes. - Races assert the current contract and committed state, never a preferred winner. Dispatch lazy Supertest requests with
Promise.resolve(req). Competing placement commands with the same captured state yield one 200 and one409 TEMPORAL_INTERVAL_OVERLAP. Roster saves can both succeed; a concurrent publication can succeed before the conflicting save or return422 TIMETABLE_PUBLISH_BLOCKEDafter it. In both cases assert the saved graph and its diagnostics. - Clean temporary conflicts in
finallyinside ordered acceptance suites. An early header or card assertion must not leave a roster overlap that makes a later warning-only publication fail for a different reason. Diagnostic dates are formatted message text; use the shared interpolation formatter for date-content assertions rather than assuming an ISO substring.
test/filters.e2e-spec.ts owns its curriculum grid, students and effective memberships. It reuses seeded login identities and structural anchors, but never adds selections to the shared Alice/Bob records: overlapping historical selections violate the temporal exclusion constraint. Its student visibility assertions search by the suite marker and compare exact IDs and totals; temporal structural expectations use effective placement rather than forward-head cohort fields.
Gradebook fixtures need an effective evaluation scale even when their roster is empty: GET /grades/subject-group/:id resolves live authoring scales and returns GRADE_SUBJECT_NOT_GRADABLE when none exists. test/sen-manager.e2e-spec.ts creates each complete department/grade/curriculum/scale/homeroom/course fixture in one transaction and records cleanup IDs only after it commits. It asserts both returned scale IDs and the empty student roster, then deletes the scale after removing its curriculum. This keeps the authorization assertions independent of seeded grading configuration and rolls back an incomplete fixture on setup failure.