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, andviare 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.
| Unit | E2E | |
|---|---|---|
| File suffix | *.spec.ts | *.e2e-spec.ts |
| Config | vitest.config.ts | vitest.config.e2e.ts |
| Speed | fast (mocked deps) | slower (real wiring) |
| Timeout | default | 30000 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.
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.
email.service.ts
tests/
email.service.spec.tsThe 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:
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 outbcryptjs—vi.mock('bcryptjs', () => ({ /* ... */ }))— so password hashing is predictable and instant in tests. - Test the unhappy path too. The email spec's last case proves
senddoes 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:
| Command | What it does |
|---|---|
pnpm test | Run every unit suite once (CI mode). |
pnpm test:watch | Re-run on save while you work. |
pnpm test:cov | Run once and report coverage. |
pnpm test:e2e | Run 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 just the email service spec
pnpm test email.service
# run everything under the auth module
pnpm test authFilter 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
Conventions
The lint, format, and commit rules the hooks enforce alongside tests.
Module Anatomy
What a feature module is made of — the services you'll be writing specs for.
Queues & Messaging
The BullMQ queues mocked in the email spec above, explained in full.
Add an Endpoint
Build a feature end to end, then cover it with a spec.