Skip to content

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:

  • $transaction mock passes the same prisma object as the transaction client, so transactional calls resolve correctly.
  • Reset mocks in beforeEach with jest.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):

  1. create — happy path + validation (grade lookup, duplicate detection, auto-generated codes)
  2. findAll — paginated response, tenant isolation
  3. findOne — found + not found
  4. update — partial update, not found
  5. remove — success + not found
  6. toScopedResponse — scope grouping, field mapping
  7. 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:

const prisma = {
  department: { findMany: jest.fn() },
  $transaction: jest.fn((fn) => fn(prisma)),
};

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 (Jest globalSetup, once per run) migrates + seeds a template database (sis_e2e_template; on CI sis_ci_template), provisions the RLS-enforcing app_user grants on it, then drops and re-clones one database per worker (sis_e2e_w1..wN) via CREATE 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_SUFFIX names 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-in E2E_REUSE_WORKER_DB=1 keeps 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, in test/report-v2.e2e-scale.ts and test/attendance-reader-golden.e2e-scale.ts).
  • Worker binding. test/setup-e2e-env.ts rewrites the dev DATABASE_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/config won't override a process.env value already set, so this wins over .env.
  • Worker count. Resolved once in test/e2e-workers.cjs, shared by the jest config's maxWorkers (test/jest-e2e.config.cjs) and the DB provisioning — that sharing is what guarantees a database exists for every worker. E2E_WORKERS env var overrides; default min(4, cores/2). CI pins E2E_WORKERS: 2.
  • Same E2E provisioning in CI. globalSetup owns 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). Each createTestApp builds a fresh DI graph + two Prisma clients (PrismaService and the RLS-bypassing AdminPrismaService); Repeated boot/teardown cycles can exhaust a single long-lived process. Jest recycles a worker via workerIdleMemoryLimit: '512MB' whenever its post-file memory check crosses the threshold, keeping the working set bounded — and a recycled worker keeps its JEST_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 bare npx jest without 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 PrismaService with useValue.
  • Mock $transaction as jest.fn((fn) => fn(prisma)) for callback transactions. For services registering external effects, inject createDeferredPostCommit().coordinator; assert no effect before commit and none after rollback. An eager register(job) => job() stub hides timing bugs.
  • Use AppException (not NestJS built-ins) in production code — test that the correct ErrorCode is 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 withSchoolToday helper. Auto-mocking the entire temporal query module also makes that helper return undefined; a partial factory preserving jest.requireActual keeps context hydration intact.
  • Authenticated guard fixtures must include session roles and 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.ts synthesizes {id} rows only for select: {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 real ClsService with AsyncLocalStorage, then cls.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() over GET /referents page 1. A sibling spec's rows must be invisible to your query.
  • Never assert on the total or data.length of 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 behind onDelete: SetNull relations (deleting the User leaves a Teacher/Staff row 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 taxCode and a unique institutionalEmail, 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.ts for missing-staff diagnostics followed by assignment and publication, and test/timetables-generation.e2e-spec.ts for staffed combined fixtures.
  • Normalize directly inserted login emails to lowercase. LoginDto lowercases 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 Date with an explicit jest import from @jest/globals, leave I/O timers real, and authenticate after freezing. In attendance-combined.e2e-spec.ts, beforeEach resets the clock to the captured day and afterEach restores real timers; sessions are first issued under that simulated date. Moving the submitted date into the future triggers ATTENDANCE_FUTURE_DATE_LOCKED.
  • Use the actual tenant helper for RLS assertions. Call withTenantGuc(app.get(PrismaService), tenantId, callback) from src/common/utils/tenant-guc; it is a function, not a PrismaService method. Direct fixture creation still uses getAdminPrisma().
  • 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 (like timetables-assistance before it) creates its own tenant (shared-${marker}), clones the preset roles with cloneRolesIntoTenant, builds year, department calendar, grades, curriculum cells, subjects, rooms, people, homeroom and courses in one beforeAll, and tears everything down with resetTenantSetupData plus the tenant delete. One ordered describe carries 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 a SolverPort whose generate awaits a gate reproduces the stale-ingestion race deterministically: arm, start the generation, change a roster while the solver "runs", release, assert TIMETABLE_GENERATION_STALE and 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) with enrolmentScheduleGraphKey(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 one 409 TEMPORAL_INTERVAL_OVERLAP. Roster saves can both succeed; a concurrent publication can succeed before the conflicting save or return 422 TIMETABLE_PUBLISH_BLOCKED after it. In both cases assert the saved graph and its diagnostics.
  • Clean temporary conflicts in finally inside 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.