Naalya Handbook

Testing

How the API tests itself — Vitest, co-located specs, the Nest testing-module mocking style, unit vs e2e, and the commands.

Tests on this backend exist to answer one question quickly: did I break anything? The project leans on Vitest — a fast test runner — and a single, repeatable recipe for testing NestJS code. Once you've read one spec, you've basically read them all, which is exactly the point. You should be able to add a test without inventing a setup.

Three ideas carry the whole chapter, so hold them in your head as you read:

  • Globals are on. describe, it, expect, and vi are available in every spec without importing them. If you see them used out of nowhere, that's why.
  • Specs live next to the code. A service and its test sit in the same folder, so the test is never more than one directory away from what it checks.
  • You test a class in isolation. You spin up a tiny Nest testing module containing the real class under test and fake versions of everything it depends on.

You don't import the test functions

Both Vitest configs set globals: true. That's the deal that lets a spec call describe(...) or vi.fn() with no import line at the top. Don't add import { describe } from 'vitest' — it's unnecessary noise and the rest of the codebase doesn't do it.


Unit vs e2e

There are two flavours of test here, and each has its own config file at the repo root. The split matters because they run very differently.

Unit tests check one class with all its collaborators mocked — no database, no Redis, no network. They're the fast majority, named *.spec.ts, and driven by vitest.config.ts.

End-to-end (e2e) tests boot more of the app and exercise it for real, so they're slower and given a generous timeout. They're named *.e2e-spec.ts and driven by vitest.config.e2e.ts.

UnitE2E
File suffix*.spec.ts*.e2e-spec.ts
Configvitest.config.tsvitest.config.e2e.ts
Speedfast (mocked deps)slower (real wiring)
Timeoutdefault30000 ms

Both configs share the important bits: they run in the node environment, enable globals, and re-declare the same path aliases as tsconfig.json. That last point is what lets a test import from @app/shared, @server, or @worker exactly the way app code does — your imports don't change just because you're in a spec.

vitest.config.e2e.ts
export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['**/*.e2e-spec.ts'],
    testTimeout: 30000,
    alias: {
      '@app/shared': path.resolve(__dirname, 'libs/shared/src'),
      '@server': path.resolve(__dirname, 'apps/server/src'),
      '@worker': path.resolve(__dirname, 'apps/worker/src'),
    },
  },
  // ... swc plugin so decorators compile ...
});

Unit config is organised by module

vitest.config.ts doesn't just glob *.spec.ts — it defines a Vitest project per feature module (Auth, Users, Campus, Roles, Enrollment, Email, Worker, Shared, and so on). You rarely touch this, but it's why suites stay tidy and why you can run just one module's tests when you need to.


Where specs live

Specs are co-located with the code they cover, tucked into a tests/ subfolder right beside the source file. You never go hunting in a far-off __tests__ directory — the test for email.service.ts is one folder away from it.

apps/server/src/app/email/
email.service.ts
tests/
  email.service.spec.ts

The naming rule is mechanical: <thing>.spec.ts for a unit test, <thing>.e2e-spec.ts for an e2e test. Match the file you're testing, drop it in tests/, and the config picks it up automatically.


The mocking style

Here's the recipe that repeats across the whole codebase. To test a service, you build a Nest testing module — a stripped-down dependency-injection container that holds the real class plus stand-ins for its collaborators. NestJS resolves dependencies by token: a class is its own token, and framework things (like a queue) have a helper that produces theirs. You register a fake against that token with { provide: Token, useValue: mock }, and Nest hands your service the fake instead of the real thing.

A mock is just a recorded stunt double — vi.fn() creates a function that does nothing but remember how it was called, so you can assert against it afterward.

The clearest real example is the email service. It does one job — push a job onto a BullMQ queue — so its test provides a fake queue and checks that add was called with the right arguments. This is trimmed from the real spec:

apps/server/src/app/email/tests/email.service.spec.ts
describe('EmailService', () => {
  let service: EmailService;
  let mockQueue: { add: ReturnType<typeof vi.fn> };

  beforeEach(async () => {
    // a fake queue whose `add` just records calls
    mockQueue = { add: vi.fn().mockResolvedValue({ id: 'job-1' }) };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        EmailService, // the real class under test
        { provide: getQueueToken(EMAIL_QUEUE), useValue: mockQueue },
      ],
    }).compile();

    service = module.get<EmailService>(EmailService);
  });

  it('enqueues a welcome email with the correct type and payload', () => {
    const payload: EmailJobPayload = {
      type: EmailType.WELCOME, to: 'test@example.com',
      subject: 'Welcome!', data: { /* ... */ },
    };
    service.send(payload);
    expect(mockQueue.add).toHaveBeenCalledWith(EmailType.WELCOME, payload);
  });
});

Notice the shape: beforeEach rebuilds a fresh module so every test starts clean, the real EmailService is listed as a provider, and getQueueToken(EMAIL_QUEUE) is the token helper that lets Nest match the fake to the queue the service injects.

A few patterns worth copying when you write your own:

  • The class under test is real; its collaborators are fake. List the real service in providers, and mock each thing it depends on with { provide: Token, useValue: mock }.
  • Use the Nest token helpers for framework-injected things — getQueueToken(...) for BullMQ queues, for instance. A plain service class is simply its own token, so you can list it directly.
  • Mock whole external libraries with vi.mock(...). The auth service does this to stub out bcryptjsvi.mock('bcryptjs', () => ({ /* ... */ })) — so password hashing is predictable and instant in tests.
  • Test the unhappy path too. The email spec's last case proves send does not throw even when the queue rejects — a failure that matters as much as the happy case.

Tokens, not types, are how Nest finds a provider

You can't mock an injected queue by providing "a queue" — Nest looks it up by the exact token the service asked for. Use getQueueToken(EMAIL_QUEUE), not a guessed string or the class. Get the token wrong and .compile() fails with an "unresolved dependency" error rather than a clean test failure.


Running the tests

The scripts live in package.json, all wrapping the vitest binary. The ones you'll reach for most:

CommandWhat it does
pnpm testRun every unit suite once (CI mode).
pnpm test:watchRe-run on save while you work.
pnpm test:covRun once and report coverage.
pnpm test:e2eRun the e2e suite with its own config.

To run a single file or folder, pass a path fragment as a plain argument — Vitest treats it as a filter and only runs matching specs:

run one slice
# run just the email service spec
pnpm test email.service

# run everything under the auth module
pnpm test auth

Filter by path, the Vitest way

Vitest filters tests by a positional path argument, not by a --testPathPattern flag (that's a Jest-ism). pnpm test email.service runs the matching file; there's no special flag to remember.


Push gate

This isn't optional hygiene — it's enforced. A Husky pre-push hook runs pnpm test every time you git push, so the entire unit suite must be green before your code can leave your machine. A separate pre-commit hook runs lint-staged (Prettier + ESLint) on what you've staged.

A red suite blocks your push

If pnpm test fails, the push is rejected — there's no quietly shipping a broken build. Run the suite locally before you push so a failure is something you fix at your desk, not a surprise at the terminal. The same lint and test conventions are collected in Conventions.


Where to go next

On this page