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.
Every test file must have /* eslint-disable */ at the very top — jest mocks are loosely typed and trigger @typescript-eslint/no-unsafe-* rules.
2. Service Specs¶
Service tests create a NestJS testing module with a manually crafted PrismaService mock. Only stub the Prisma delegates and methods your service actually calls.
/* eslint-disable */
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 bypass the full guard stack 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.
/* eslint-disable */
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 |
overrideAuthGuards(builder) |
Override all auth guards + FieldFilterInterceptor for controller tests |
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. |
signResendWebhook(payload, secret) |
Generate a valid Svix signature for testing the Resend webhook endpoint. |
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 plain functions — test them without the NestJS DI container. Mock PrismaService as a plain object. Pass the same mock object as the transaction client:
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, invitations). 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¶
Full state machine coverage (~850 lines). 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¶
The suite runs serially in a single worker (--maxWorkers=1) against one shared, dedicated database, provisioned automatically. You never seed or reset it by hand.
Why not
--runInBand. In-band execution keeps all ~60+ full-app boot/teardown cycles in one long-lived process; the heap grows monotonically (eachcreateTestAppbuilds a fresh DI graph + two Prisma clients —PrismaServiceand the RLS-bypassingAdminPrismaService) and the run OOMs past Node's ~4 GB default ceiling.--maxWorkers=1keeps execution serial (same isolation guarantee — no concurrent DB access) but runs in a child worker that Jest recycles via--workerIdleMemoryLimit=512MBwhenever its post-file memory check crosses the threshold, so the working set stays bounded regardless of how many suites exist. Do not revert to--runInBand.
- Dedicated, disposable DB.
test/setup-e2e-env.tsrewrites the devDATABASE_URL(sis_dev→sis_e2e) before the app boots, so e2e never touches your dev data. Any non-dev URL (CI's ephemeral DB, an explicit override) passes through unchanged.@nestjs/configwon't override aprocess.envvalue already set, so this wins over.env. - Reset once per run.
test/global-setup-e2e.cjs(JestglobalSetup) runs once before the whole suite: create-if-missing →prisma migrate deploy→prisma db seed(TRUNCATE-CASCADE reset + reseed). Every run starts from exactly the canonical seed — cross-run accumulation is impossible. A new migration needs zero manual e2e-DB steps. - Same path in CI. CI has no separate migrate/seed steps;
globalSetupis the single source of truth (see.github/workflows/ci.yml).
The reset boundary is per run, not per spec — specs in the same run still share a DB, so a spec must not depend on another spec's state. 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)
9. Rules¶
/* eslint-disable */at the top of every test file — mocks triggerno-unsafe-*rules.- Never hit a real database in unit tests — mock
PrismaServicewithuseValue. - Mock
$transactionasjest.fn((fn) => fn(prisma))so transactional code resolves correctly with the same mock client. - 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
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. - Stamp uniqueness-bound fields (emails, codes, names hitting a
@@unique) withDate.now()so re-runs and parallel data never collide. - Clean up everything you create in
afterAll— including rows behindonDelete: SetNullrelations (deleting theUserleaves aTeacher/Staffrow dangling; delete it explicitly). - 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).