Testing Best Practices
A test suite is only valuable if it is fast, deterministic, and trusted enough that a failing run actually blocks a merge. NestJS gives you the building blocks — Test.createTestingModule, dependency injection, and provider overrides — but the discipline around them determines whether your suite catches regressions or rots into a wall of skipped, flaky specs. This page distills the conventions that keep a Nest test suite healthy as it grows from ten files to a thousand.
Organize tests around behavior, not files
Co-locate unit tests with the code they cover (user.service.spec.ts next to user.service.ts) and keep end-to-end tests in a separate test/ directory with their own Jest config. This split matters because unit and e2e tests have different lifecycles, timeouts, and database needs, and you almost always want to run them in separate CI jobs.
Group assertions by the behavior under test rather than by method name. A describe block per scenario reads like a specification and makes failures self-documenting.
import { Test } from '@nestjs/testing';
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
describe('UserService', () => {
let service: UserService;
let repo: jest.Mocked<UserRepository>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserService,
{ provide: UserRepository, useValue: { findByEmail: jest.fn(), save: jest.fn() } },
],
}).compile();
service = module.get(UserService);
repo = module.get(UserRepository);
});
describe('when registering a new email', () => {
it('persists the user and returns it', async () => {
repo.findByEmail.mockResolvedValue(null);
repo.save.mockImplementation(async (u) => ({ id: 1, ...u }));
const user = await service.register('[email protected]', 'pw');
expect(user.id).toBe(1);
expect(repo.save).toHaveBeenCalledTimes(1);
});
it('rejects a duplicate email', async () => {
repo.findByEmail.mockResolvedValue({ id: 7, email: '[email protected]' });
await expect(service.register('[email protected]', 'pw')).rejects.toThrow('Email already in use');
});
});
});
Build fixtures with factories, not literals
Inline object literals scattered across specs are a maintenance trap: add a required field to an entity and every test breaks. A factory centralizes the “valid by default” shape and lets each test override only the field it cares about. This keeps tests focused on the one variable they exercise.
// test/factories/user.factory.ts
import { randomUUID } from 'node:crypto';
export interface User {
id: string;
email: string;
role: 'admin' | 'member';
active: boolean;
}
export function makeUser(overrides: Partial<User> = {}): User {
return {
id: randomUUID(),
email: `user-${randomUUID()}@dev.io`,
role: 'member',
active: true,
...overrides,
};
}
it('blocks inactive admins', () => {
const admin = makeUser({ role: 'admin', active: false });
expect(canAccessDashboard(admin)).toBe(false);
});
Randomizing identifiers (rather than hardcoding id: 1) prevents accidental cross-test coupling through shared databases and surfaces order-dependent bugs early.
Tip: Keep factories pure and synchronous. If a fixture needs to hit the database, wrap the factory in a
persistUser(repo, makeUser())helper so the in-memory shape stays reusable in unit tests.
Set meaningful coverage targets
Coverage is a guardrail, not a goal. Chasing 100% rewards trivial getter tests while saying nothing about whether your error paths work. Set per-metric thresholds in Jest so a drop fails CI, and prioritize branch coverage — it measures whether your if/catch/guard logic is actually exercised.
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: { '^.+\\.ts$': 'ts-jest' },
collectCoverageFrom: ['**/*.(t|j)s', '!**/*.module.ts', '!**/main.ts'],
coverageDirectory: '../coverage',
coverageThreshold: {
global: { branches: 80, functions: 85, lines: 85, statements: 85 },
},
};
export default config;
| Metric | Good target | What it really tells you |
|---|---|---|
| Statements | 85% | How much code ran at all |
| Branches | 80% | Whether decision paths were tested |
| Functions | 85% | Whether each unit was invoked |
| Lines | 85% | Rough proxy for statements |
Eliminate flaky tests at the source
Flaky tests destroy trust faster than missing tests, because a suite that “fails sometimes” trains the team to ignore red. The common causes are shared mutable state, real timers, and unawaited promises.
- Reset mocks between tests with
clearMocks: true(orjest.restoreAllMocks()inafterEach). - Use fake timers for anything time-dependent instead of real
setTimeout. - Isolate database state per test with transactions or truncation, never relying on leftover rows.
import { UserService } from './user.service';
describe('session expiry', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('expires a session after 30 minutes', () => {
const service = new UserService(/* deps */ {} as any);
const session = service.createSession('u1');
jest.advanceTimersByTime(30 * 60 * 1000);
expect(service.isExpired(session)).toBe(true);
});
});
Run tests in CI
CI should fail loudly and fast. Split unit and e2e into separate jobs, run them with --ci --runInBand for e2e (predictable ordering against a real database) and parallel for units, and publish coverage as a build artifact.
# package.json scripts
npm run test -- --ci --coverage
npm run test:e2e -- --ci --runInBand
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run test -- --ci --coverage --maxWorkers=2
Output:
PASS src/user/user.service.spec.ts
PASS src/auth/auth.service.spec.ts
-------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-------------------|---------|----------|---------|---------|
All files | 91.4 | 84.2 | 88.0 | 91.1 |
-------------------|---------|----------|---------|---------|
Test Suites: 24 passed, 24 total
Tests: 138 passed, 138 total
Time: 6.812 s
Warning: Never let CI cache a stale
node_moduleswithout locking topackage-lock.json. Usenpm ci, notnpm install, so a corrupt lockfile fails the build instead of silently resolving different versions than developers ran locally.
Best Practices
- Co-locate unit specs with source; keep e2e tests and their Jest config in a separate
test/directory. - Drive every fixture through a factory with sensible defaults and per-test overrides — never duplicate entity literals.
- Set per-metric
coverageThresholdvalues and watch branch coverage rather than chasing a 100% line number. - Enable
clearMocks, use fake timers, and isolate database state per test to keep the suite deterministic. - Run unit and e2e as separate CI jobs; use
npm ciand--ciflags so failures are reproducible. - Treat any flaky test as a P1 bug: quarantine it, fix the root cause, and re-enable it — do not retry-loop around it.