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.

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:

  • $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 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):

  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 plain functions — test them without the NestJS DI container. Mock PrismaService as a plain object. Pass the same mock object as the transaction client:

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, 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 (each createTestApp builds a fresh DI graph + two Prisma clients — PrismaService and the RLS-bypassing AdminPrismaService) and the run OOMs past Node's ~4 GB default ceiling. --maxWorkers=1 keeps execution serial (same isolation guarantee — no concurrent DB access) but runs in a child worker that Jest recycles via --workerIdleMemoryLimit=512MB whenever 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.ts rewrites the dev DATABASE_URL (sis_devsis_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/config won't override a process.env value already set, so this wins over .env.
  • Reset once per run. test/global-setup-e2e.cjs (Jest globalSetup) runs once before the whole suite: create-if-missing → prisma migrate deployprisma 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; globalSetup is 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 trigger no-unsafe-* rules.
  • Never hit a real database in unit tests — mock PrismaService with useValue.
  • Mock $transaction as jest.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 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

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.
  • Stamp uniqueness-bound fields (emails, codes, names hitting a @@unique) with Date.now() so re-runs and parallel data never collide.
  • 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).
  • 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).