Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core and @nestjs/testing 12.1.2), TypeScript 6 (6.0.x), Node.js 24 LTS (Node.js 20.19+ or 22.12+ run Nest 12), Express 5 and Fastify 5, Vitest 4.1 (on Vite 8), Jest 30.5, @swc/jest 0.2.39, unplugin-swc 2, @swc/core 1.16, supertest 7.3, TypeORM 1.1 and Mongoose 9, and was checked on 2026-10-02. The examples target two Nest 12 project layouts: an ESM project with Vitest, and a CommonJS project with Jest, in which the same test files pass both with ts-jest and with @swc/jest. Tests that need a Docker daemon are covered in the Testcontainers section.
A test suite is the part of an application that answers the question "can I change this safely?". Nest does not ship a test runner. What it ships is @nestjs/testing, a small package whose job is to build the same dependency injection graph your application builds in main.ts, but under your control: you can leave providers out, replace any provider, guard, pipe, interceptor, filter or module with a test double, and then either call the resulting objects directly (a unit test) or wrap them in a real HTTP server and send requests to it (an end-to-end test). This page follows that progression for the Bookshelf application that grows across this section. It first fixes the shape of a healthy suite (the test pyramid), then sets up the two test runners Nest scaffolds, then walks through the testing API from the smallest test to the whole application, then shows how to test code that talks to PostgreSQL, SQLite or MongoDB, and ends with coverage. Testing GraphQL resolvers, WebSocket gateways and microservices uses the same tools with a few additions and has its own page: Testing GraphQL, WebSockets & Microservices.
The test pyramid
The usual advice is to write many small, fast tests, fewer tests that join several parts, and very few tests that drive the whole system, because each step up is slower, more fragile and harder to diagnose. NestJS maps onto the three layers like this.
Few tests: critical journeys with the real guards, pipes, filters and database"] INT["Integration: a feature module with a real database
(in-memory SQLite, mongodb-memory-server, Testcontainers).
Some tests: queries, transactions, migrations, module wiring"] UNIT["Unit: one class, collaborators replaced by mocks, no HTTP, no database.
Many tests: business rules, error branches, mapping"] E2E --- INT --- UNIT
Unit tests are cheap enough to run on every save, so most of the logic (the rules in a service, a guard’s decision, a pipe’s transformation) should be reachable from them. A unit test of a controller or service proves that it calls its collaborators correctly. It does not prove that the query it sends is valid or that the guard is attached to the route, so a smaller number of integration tests exercise a module with a real database, and a few end-to-end tests send real HTTP requests through the whole pipeline of guards, pipes, interceptors and filters (see Request Lifecycle for the order). A useful rule of thumb is to put each assertion at the lowest layer that can make it: an input-validation rule belongs in a pipe’s unit test, and the fact that the pipe is applied to the route belongs in one e2e test. The book this section replaces (see the section bibliography) reaches the same split in its chapter on testing and debugging REST APIs; the rest of this page is about getting each layer with current Nest APIs.
Source: NestJS: Testing
Choosing and configuring a test runner
nest new asks whether the project uses ESM or CommonJS, and the answer selects the test setup. In a non-interactive environment, such as CI, it defaults to ESM.
| ESM project (default) | CommonJS project | |
|---|---|---|
Test runner |
Vitest (the scaffold pins |
Jest 30 with |
Unit test glob |
|
|
e2e test glob |
|
|
Scripts |
|
|
Globals ( |
|
provided by Jest, |
Mock functions |
|
|
Relative imports |
with the extension, |
without it, |
The table is read from the two scaffolded projects. Nest itself is agnostic: nothing in @nestjs/testing knows which runner calls it, so every pattern below works in either runner, and also with Node’s built-in node:test. @nestjs/testing is a development dependency (npm i --save-dev @nestjs/testing) and the scaffold already installs it.
npm run test:e2e uses in an ESM project)import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
},
});
Keep unit tests next to the class they test with a .spec.ts suffix, and e2e tests in a test/ directory with an .e2e-spec.ts suffix. The two globs keep them apart, so the fast suite stays fast. Jest itself (matchers, mocks, snapshots, configuration) is covered on Unit Testing with Jest; this page only covers what is specific to Nest.
Source: NestJS: Testing and NestJS CLI: nest new
Why SWC: decorator metadata in tests
Nest resolves constructor dependencies by type. For constructor(private readonly notifier: NotifierService), the TypeScript compiler emits the parameter types as metadata (design:paramtypes) when emitDecoratorMetadata is on, and Nest reads that metadata at runtime. A test runner whose transformer does not emit it does not fail loudly: the dependency is simply undefined, and the first call on it throws a confusing TypeError. Vitest long used esbuild, which does not emit decorator metadata, so Nest’s documentation tells you to build the tests with SWC, which does. The failure was reproduced on Vitest 3.2 (Vite 7) with a service that injects a class:
❯ dep.spec.ts (1 test | 1 failed)
× injects by type
→ Cannot read properties of undefined (reading 'hi')
Adding unplugin-swc to the same project makes the same test pass. On the Vitest 4.1 and Vite 8 combination that nest new pins in 2026, the same injection tests also pass with no SWC plugin at all. Vite 8 transforms TypeScript with Oxc instead of esbuild, and in that setup it emits the metadata from the tsconfig.json settings (the scaffolded vitest.config.ts has only vite-tsconfig-paths). So whether Vitest needs SWC depends on the Vite version underneath it. Keeping the plugin is still the safer choice, and it is what the Nest documentation shows: the behaviour then does not change when a dependency upgrade swaps the transformer, and tests are compiled the same way as production if you build with SWC (nest start -b swc, see Getting Started & CLI).
npm i --save-dev vitest unplugin-swc @swc/core @vitest/coverage-v8
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.spec.ts', '**/*.e2e-spec.ts'],
},
plugins: [
// Compiles the test files, and the sources they import, with SWC.
swc.vite({
// Set the module type explicitly so it is not inherited from a .swcrc file.
module: { type: 'es6' },
}),
],
});
Source: NestJS: SWC, Vitest
In a CommonJS project the scaffold transforms TypeScript with ts-jest, which emits decorator metadata (the tests of this page pass unchanged with it). @swc/jest compiles without type-checking and is usually much faster. Two options are required, legacyDecorator (Nest uses the TypeScript decorator semantics, not the TC39 ones) and decoratorMetadata. They can sit in a .swcrc file, as the Nest documentation does, or inline in the Jest configuration, which is what this page uses:
npm i --save-dev jest @swc/core @swc/jest
import type { Config } from 'jest';
const config: Config = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': [
'@swc/jest',
{
jsc: {
target: 'es2023',
parser: { syntax: 'typescript', decorators: true },
transform: { legacyDecorator: true, decoratorMetadata: true },
keepClassNames: true,
},
module: { type: 'commonjs' },
},
],
},
collectCoverageFrom: ['src/**/*.(t|j)s'],
coverageDirectory: './coverage',
testEnvironment: 'node',
};
export default config;
The .swcrc variant from the documentation (the same jsc.transform keys, "module": { "type": "commonjs" }, and transform: { '^.+\\.(t|j)s$': ['@swc/jest'] } in the Jest configuration) works the same way. Because SWC strips types without checking them, run tsc --noEmit or nest build as its own CI step: a green SWC test run says nothing about type errors. SWC also does not run Nest CLI plugins (for example the Swagger plugin) while compiling tests; the SWC recipe describes the plugin metadata generator for that case. Under Jest 30, loading ESM-only Nest 12 packages from CommonJS tests needs Node.js 24.9 or later (the scaffolded --experimental-vm-modules flag is part of that setup), for example Node.js 24.16.
Source: NestJS: SWC, Jest + SWC
The application under test
The examples use one small slice of Bookshelf: a BooksModule whose service uses a TypeORM Repository<Book> and a NotifierService, a controller with a pipe, an interceptor, an exception filter and a route guard, a request-scoped provider that reads a header, and a global guard registered with APP_GUARD. It is deliberately small, because every part of it will be replaced, one at a time, by a test double.
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Book {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column({ type: 'integer', default: 0 })
stock: number;
}
// --- notifier.service.ts ---
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotifierService {
async bookCreated(title: string): Promise<void> {
// would publish to a queue or send an e-mail in the real application
console.log(`notify: ${title}`);
}
}
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Book } from './book.entity.js';
import { NotifierService } from './notifier.service.js';
@Injectable()
export class BooksService {
constructor(
@InjectRepository(Book) private readonly books: Repository<Book>,
private readonly notifier: NotifierService,
) {}
findAll(): Promise<Book[]> {
return this.books.find({ order: { id: 'ASC' } });
}
async findOne(id: number): Promise<Book> {
const book = await this.books.findOneBy({ id });
if (!book) throw new NotFoundException(`Book ${id} not found`);
return book;
}
async create(title: string): Promise<Book> {
const book = await this.books.save(this.books.create({ title }));
await this.notifier.bookCreated(title);
return book;
}
async remove(id: number): Promise<void> {
const result = await this.books.delete(id);
if (!result.affected) throw new NotFoundException(`Book ${id} not found`);
}
}
// --- jwt-auth.guard.ts ---
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (!req.headers.authorization?.startsWith('Bearer ')) {
throw new UnauthorizedException();
}
return true; // the real guard would verify the token with JwtService
}
}
// --- admin.guard.ts ---
import { ForbiddenException } from '@nestjs/common';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const role = context.switchToHttp().getRequest().headers['x-role'];
if (role !== 'admin') throw new ForbiddenException();
return true;
}
}
// --- title.pipe.ts ---
import { BadRequestException, PipeTransform } from '@nestjs/common';
@Injectable()
export class TitlePipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException('title must be a non-empty string');
}
return value.trim();
}
}
// --- timing.interceptor.ts ---
import { CallHandler, NestInterceptor } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
@Injectable()
export class TimingInterceptor implements NestInterceptor {
constructor(private readonly adapterHost: HttpAdapterHost) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const res = context.switchToHttp().getResponse();
const started = Date.now();
// httpAdapter.setHeader() works on Express and on Fastify; res.setHeader() only on Express
return next
.handle()
.pipe(tap(() => this.adapterHost.httpAdapter.setHeader(res, 'X-Elapsed-Ms', String(Date.now() - started))));
}
}
// --- domain-error.filter.ts ---
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import type { Response } from 'express';
export class OutOfStockError extends Error {}
@Catch(OutOfStockError)
export class OutOfStockFilter implements ExceptionFilter {
catch(error: OutOfStockError, host: ArgumentsHost) {
host.switchToHttp().getResponse<Response>().status(409).json({ code: 'OUT_OF_STOCK', message: error.message });
}
}
// --- request-context.service.ts ---
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
constructor(@Inject(REQUEST) private readonly request: Request) {}
get requestId(): string {
return String(this.request.headers['x-request-id'] ?? 'none');
}
}
// --- books.controller.ts ---
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
@Controller('books')
@UseInterceptors(TimingInterceptor)
@UseFilters(OutOfStockFilter)
export class BooksController {
constructor(
private readonly books: BooksService,
private readonly context: RequestContextService,
) {}
@Get()
async findAll() {
return { requestId: this.context.requestId, data: await this.books.findAll() };
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.books.findOne(id);
}
@Post()
create(@Body('title', TitlePipe) title: string) {
return this.books.create(title);
}
@Post(':id/borrow')
async borrow(@Param('id', ParseIntPipe) id: number) {
const book = await this.books.findOne(id);
if (book.stock === 0) throw new OutOfStockError(`"${book.title}" is out of stock`);
return book;
}
@Delete(':id')
@HttpCode(204)
@UseGuards(AdminGuard)
async remove(@Param('id', ParseIntPipe) id: number) {
await this.books.remove(id);
}
}
// --- books.module.ts ---
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Book])],
controllers: [BooksController],
providers: [
BooksService,
NotifierService,
RequestContextService,
// useExisting (not useClass) makes the guard a normal provider that tests can override
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
JwtAuthGuard,
],
exports: [BooksService],
})
export class BooksModule {}
// --- app.module.ts ---
@Module({
imports: [
TypeOrmModule.forRoot({ type: 'better-sqlite3', database: ':memory:', entities: [Book], synchronize: true }),
BooksModule,
],
})
export class TestingAppModule {}
Source: NestJS: Testing.
The in-memory SQLite database with synchronize: true exists for the tests only; production configuration, migrations and PostgreSQL are on Data Overview & TypeORM. The whole slice compiled with tsc --noEmit and every spec below ran against it.
Unit tests
Isolated tests without Nest
The simplest test builds the class by hand and passes it a double. It needs no Nest at all, and it is the fastest and most precise way to test a pure class. The Nest documentation calls this isolated testing, and the example below uses it for a class that takes a plain function as a dependency (a clock), which is also how you make time-dependent code deterministic.
export class Greeter {
constructor(private readonly now: () => Date) {}
greet(): string {
return this.now().getUTCHours() < 12 ? 'Good morning' : 'Good afternoon';
}
}
// in the spec
it('Greeter with a fixed clock', () => {
expect(new Greeter(() => new Date('2026-10-02T08:00:00Z')).greet()).toBe('Good morning');
});
Source: NestJS: Testing, Unit testing
Isolated tests cannot tell you whether Nest can build the class: they never use the decorators, the constructor parameter types or the module that provides it. For that, build the class through a testing module.
Test.createTestingModule and compile()
Test.createTestingModule() takes the same metadata object as @Module() (imports, controllers, providers, exports) and returns a TestingModuleBuilder. Calling compile() on it, which is asynchronous, instantiates the graph the way NestFactory.create() does, without an HTTP server, and returns a TestingModule. A TestingModule extends the application context, so get(), resolve(), select() and close() work like on ModuleRef.
import { vi } from 'vitest';
import { NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Book } from '../src/testing/book.entity.js';
import { BooksService } from '../src/testing/books.service.js';
import { NotifierService } from '../src/testing/notifier.service.js';
describe('BooksService (unit)', () => {
let service: BooksService;
const repo = {
find: vi.fn(),
findOneBy: vi.fn(),
create: vi.fn((x: Partial<Book>) => x),
save: vi.fn(async (x: Partial<Book>) => ({ id: 1, stock: 0, ...x })),
};
const notifier = { bookCreated: vi.fn() };
beforeEach(async () => {
vi.clearAllMocks();
const moduleRef = await Test.createTestingModule({
providers: [
BooksService,
{ provide: getRepositoryToken(Book), useValue: repo },
{ provide: NotifierService, useValue: notifier },
],
}).compile();
service = moduleRef.get(BooksService);
});
it('creates and notifies', async () => {
const book = await service.create('Dune');
expect(book).toMatchObject({ id: 1, title: 'Dune' });
expect(notifier.bookCreated).toHaveBeenCalledWith('Dune');
});
it('throws NotFoundException', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(service.findOne(9)).rejects.toBeInstanceOf(NotFoundException);
});
});
Three details of that test matter. First, the doubles are ordinary custom providers: { provide: token, useValue: double } registers the object under the token the class asks for. getRepositoryToken(Book) returns the token that @InjectRepository(Book) uses, so the service receives the mock repository (the string BookRepository for the default data source). Second, build the module in beforeEach so that every test starts from fresh instances, and clear the mocks, or Vitest keeps call counts from the previous test. Third, if a dependency of the class under test cannot be resolved, compile() rejects with the same "Nest can’t resolve dependencies" error the application would give at startup. That is a feature: the error names the class and marks the position of the missing provider, and the official common-errors page lists the usual causes. In this run, importing BooksModule alone, without the TypeOrmModule.forRoot() that provides the DataSource, failed in exactly this way.
Source: NestJS: Common errors
The compiled module exposes these methods:
| Method | Purpose |
|---|---|
|
Returns a static (singleton) instance of any provider, controller, guard, filter, pipe or interceptor in the graph. With |
|
Returns a scoped (request or transient) instance. Each call without a context id creates a new DI sub-tree, so two calls return different instances. |
|
Navigates to a module of the graph, to be combined with |
|
Creates an |
|
Creates an |
|
Closes the module and runs its destroy and shutdown lifecycle hooks, which releases pools and timers. |
select() with strict is the way to ask for the instance that belongs to one module when several modules provide the same class:
const moduleRef = await Test.createTestingModule({ imports: [TestingAppModule] })
.overrideProvider(NotifierService).useValue({ bookCreated: vi.fn() })
.compile();
// Walk the module graph, then ask only that module (strict: true)
const service = moduleRef.select(BooksModule).get(BooksService, { strict: true });
expect(service).toBeInstanceOf(BooksService);
expect(() => moduleRef.get(BooksService, { strict: true })).toThrow(); // BooksService is not in the root module
Two further builder options are worth knowing. setLogger(loggerService) decides how Nest logs while the module compiles; by default only errors reach the console, and passing an object with log, error and warn lets you capture the lines or keep CI output quiet (see Logging). The second argument of Test.createTestingModule(metadata, options) accepts moduleIdGeneratorAlgorithm, as the 12.1.2 typings show; the official page does not document it, and the examples here do not need it.
const lines: string[] = [];
const moduleRef = await Test.createTestingModule({ providers: [NotifierService] })
.setLogger({ log: (m: string) => lines.push(m), error: (m: string) => lines.push(m), warn: () => {} })
.compile();
One pitfall: after compile(), HttpAdapterHost#httpAdapter is still undefined, because no HTTP adapter exists until you call createNestApplication(). A provider that reads it in a constructor or in onModuleInit() fails in a unit test that only compiles the module. Create the application in that test, or read the adapter lazily (the TimingInterceptor above reads it only while handling a request).
Test doubles: useValue, useClass and useFactory
The same three forms that define custom providers (see Providers & Dependency Injection) build doubles. Use useValue for a plain object with a few methods, useClass for a hand-written fake class that you reuse across tests, and useFactory when the double depends on other providers. A vi.fn() or jest.fn() object gives you call assertions; a hand-written in-memory fake gives you behaviour; prefer the fake when a test would otherwise need many mockResolvedValueOnce() calls in a row.
Source: NestJS: Custom providers
Auto-mocking with useMocker
When a class has many dependencies and a test only cares about one or two, providing every double is noise. useMocker() registers a factory that Nest calls for every dependency that is still missing after the providers you listed are registered; the factory receives the token and returns a double, or nothing, in which case the dependency stays unresolved and Nest reports it as usual.
it('auto-mocks every missing dependency', async () => {
const moduleRef = await Test.createTestingModule({ controllers: [BooksController] })
.useMocker((token) => {
if (token === BooksService) {
return { findAll: vi.fn().mockResolvedValue([{ id: 1, title: 'Dune', stock: 1 }]) };
}
if (token === RequestContextService) return { requestId: 'r-1' };
})
.compile();
// static here: the request-scoped service was replaced by a plain value
const controller = moduleRef.get(BooksController);
expect(await controller.findAll()).toEqual({ requestId: 'r-1', data: [{ id: 1, title: 'Dune', stock: 1 }] });
});
Source: NestJS: Testing, Auto mocking
The factory is called for the two dependencies of BooksController and may be called once more with the token Object; returning nothing for tokens you do not handle is harmless. Mocks created this way are retrievable with moduleRef.get(BooksService), like any provider. Two limits apply. useMocker() fills only dependencies that are missing, so it does not replace a provider that is already registered (use an override for that). And REQUEST and INQUIRER cannot be auto-mocked, because Nest predefines them in the context: replace them with a custom provider or with overrideProvider(). A double built from {} hides a typo in a method name until a runtime TypeError, so keep the objects your factory returns as small as the test needs and fail loudly for the rest.
Overriding providers, enhancers and modules
An override replaces something that is already part of a module graph. Unlike adding a provider to the test module, it works however deeply the original is nested: the override reaches into imported modules, so you can import the real AppModule and swap out only the pieces that touch the outside world. Each method returns a builder; overrideModule() is the one exception.
| Method | Replaces | Finish with |
|---|---|---|
|
any provider, including a guard, pipe, interceptor or filter that is registered as a provider |
|
|
a guard attached with |
the same three |
|
a pipe attached with |
the same three |
|
an interceptor attached with |
the same three |
|
an exception filter attached with |
the same three |
|
a whole module |
|
Every method takes a class or an injection token. All of them return the builder, so they chain in the fluent style, and compile() goes last.
import { vi } from 'vitest';
import { INestApplication, CanActivate, ExecutionContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import request from 'supertest';
import type { Repository } from 'typeorm';
class AllowAllGuard implements CanActivate {
canActivate(_context: ExecutionContext) {
return true;
}
}
describe('e2e with overrides', () => {
let app: INestApplication;
const notifier = { bookCreated: vi.fn() };
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [TestingAppModule] })
.overrideProvider(JwtAuthGuard) // the APP_GUARD uses useExisting, so this works
.useClass(AllowAllGuard)
.overrideProvider(NotifierService)
.useValue(notifier)
.overridePipe(TitlePipe)
.useValue({ transform: (v: string) => v.toUpperCase() })
.compile();
app = moduleRef.createNestApplication();
await app.init();
const repo = app.get<Repository<Book>>(getRepositoryToken(Book));
await repo.save([{ title: 'Dune', stock: 2 }, { title: 'Emma', stock: 0 }]);
});
afterAll(() => app.close());
it('GET /books with the guard replaced, the request-scoped provider and the header', async () => {
const res = await request(app.getHttpServer()).get('/books').set('x-request-id', 'r-42').expect(200);
expect(res.body.requestId).toBe('r-42');
expect(res.body.data.map((b: Book) => b.title)).toEqual(['Dune', 'Emma']);
expect(res.headers['x-elapsed-ms']).toBeDefined();
});
it('POST /books uses the overridden pipe and the notifier mock', async () => {
const res = await request(app.getHttpServer()).post('/books').send({ title: 'ulysses' }).expect(201);
expect(res.body.title).toBe('ULYSSES');
expect(notifier.bookCreated).toHaveBeenCalledWith('ULYSSES');
});
it('exception filter and parameter pipes', async () => {
await request(app.getHttpServer()).post('/books/2/borrow').expect(409).expect({
code: 'OUT_OF_STOCK',
message: '"Emma" is out of stock',
});
await request(app.getHttpServer()).get('/books/99').expect(404);
await request(app.getHttpServer()).get('/books/abc').expect(400);
});
});
The second test shows overridePipe() applied to a pipe that the controller names in a parameter decorator. The third test runs the real exception filter, the real ParseIntPipe and a real in-memory database, so a 409 with the filter’s JSON, a 404 from the service and a 400 from ParseIntPipe all came from the production code.
The other override methods are used the same way. overrideGuard() replaces a guard that a route attaches with @UseGuards(), so the route can be tested without presenting a role header:
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
let app: NestFastifyApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [TestingAppModule] })
.overrideProvider(JwtAuthGuard).useClass(AllowAllGuard)
.overrideProvider(NotifierService).useValue({ bookCreated: vi.fn() })
.overrideGuard(AdminGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(() => app.close());
it('creates, then deletes without the x-role header', async () => {
const created = await app.inject({ method: 'POST', url: '/books', payload: { title: 'Dune' } });
expect(created.statusCode).toBe(201);
expect(created.headers['x-elapsed-ms']).toBeDefined();
const { id } = created.json();
expect((await app.inject({ method: 'DELETE', url: `/books/${id}` })).statusCode).toBe(204);
expect((await app.inject({ method: 'DELETE', url: `/books/${id}` })).statusCode).toBe(404);
});
Fastify has its own testing facility, inject(), which sends a request through Fastify’s router without opening a socket, and requires await app.getHttpAdapter().getInstance().ready() after init() so that all routes are registered. The same run also exposed a real Express and Fastify difference. The first version of the TimingInterceptor called res.setHeader(), which is an Express (Node http) method; on Fastify the reply object has header() instead, and the request failed with res.setHeader is not a function and a 500. The version above goes through HttpAdapterHost#httpAdapter.setHeader(response, name, value), which works on both. If your code is meant to be platform-agnostic (see Platform Agnosticism & Standalone Apps and Fastify & HTTP Platforms), run at least the e2e tests of cross-cutting code on both adapters.
const moduleRef = await Test.createTestingModule({
providers: [
{ provide: ConfigService, useValue: { get: () => 'x' } },
{ provide: NotifierService, useClass: NotifierService },
],
})
.overrideProvider(NotifierService)
.useFactory({
factory: (config: ConfigService) => ({ bookCreated: async () => config.get('x') }),
inject: [ConfigService],
})
.compile();
useFactory takes an object, { factory, inject }, in the testing API, not the two separate properties of a custom provider definition. Finally, overrideInterceptor() and overrideFilter() replace the TimingInterceptor and OutOfStockFilter in the same way, and overrideModule() replaces a whole module, for instance one that connects to an external service:
@Module({
providers: [{ provide: BooksService, useValue: { findAll: async () => [] } }],
exports: [BooksService],
})
class FakeBooksModule {}
// replace a whole module
const moduleRef = await Test.createTestingModule({ imports: [BooksModule] })
.overrideModule(BooksModule)
.useModule(FakeBooksModule)
.compile();
expect(await moduleRef.get(BooksService).findAll()).toEqual([]);
// replace an interceptor and a filter
const app = (
await Test.createTestingModule({ imports: [TestingAppModule] })
.overrideProvider(JwtAuthGuard).useClass(AllowAllGuard)
.overrideInterceptor(TimingInterceptor).useValue({ intercept: (_: unknown, next: { handle(): unknown }) => next.handle() })
.overrideFilter(OutOfStockFilter).useValue({ catch: (_e: unknown, host: ExecutionContext) => host.switchToHttp().getResponse().status(418).end() })
.compile()
).createNestApplication();
All of these specs, 21 tests in three files, pass under Vitest 4.1 and under Jest 30 (with jest.fn() in place of vi.fn() and imports without the .js extension).
Overriding globally registered enhancers
A guard, pipe, interceptor or filter registered through APP_GUARD, APP_PIPE, APP_INTERCEPTOR or APP_FILTER is, by default, created behind the token with useClass. Nest cannot tell that the class is also a provider, so overrideProvider(JwtAuthGuard) finds nothing to replace and the real guard keeps running. The remedy is to register the class as a normal provider and point the token at it with useExisting:
providers: [
{
provide: APP_GUARD,
useExisting: JwtAuthGuard,
// ^^^^^^^^ useExisting instead of useClass
},
JwtAuthGuard,
],
JwtAuthGuard is now a regular provider that tests can override, as the e2e test above does with .overrideProvider(JwtAuthGuard).useClass(AllowAllGuard). The first test of the e2e file shows the other side: without any override, the same application answers 401 to a request without a bearer token and 200 to one with it, which proves that the global guard is applied to the route:
describe('without the override, the global guard blocks', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [TestingAppModule] })
.overrideProvider(NotifierService).useValue({ bookCreated: vi.fn() })
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('401 without a token', () => request(app.getHttpServer()).get('/books').expect(401));
it('200 with one', () =>
request(app.getHttpServer()).get('/books').set('Authorization', 'Bearer x').expect(200));
});
useExisting has a design benefit beyond testing: the guard becomes an ordinary provider that other classes can inject. Use it for every global enhancer that a test might need to replace. Enhancers that are registered with app.useGlobalGuards(new JwtAuthGuard()) in main.ts are a different case altogether: they do not exist in a testing module, because main.ts never runs.
Testing request-scoped providers
A request-scoped provider (see Injection Scopes) is created per request, and so is every provider that depends on it, including the controller that injects it. Two consequences follow for tests. moduleRef.get() cannot return such a provider and throws; you use resolve(), which returns an instance from a new DI sub-tree on each call:
it('resolve() gives a fresh instance per context id; get() throws', async () => {
const moduleRef = await Test.createTestingModule({
providers: [RequestContextService, { provide: 'REQUEST', useValue: {} }],
})
.overrideProvider(RequestContextService)
.useClass(class extends RequestContextService {
constructor() {
super({ headers: { 'x-request-id': 'abc' } } as never);
}
})
.compile();
const a = await moduleRef.resolve(RequestContextService);
const b = await moduleRef.resolve(RequestContextService);
expect(a).not.toBe(b);
expect(a.requestId).toBe('abc');
expect(() => moduleRef.get(RequestContextService)).toThrow();
});
The second consequence is that in an e2e test you cannot reach the instance that a request created, because it is discarded when the request ends. The strategy in the Nest documentation is to fix the context id: generate one beforehand and make ContextIdFactory.getByRequest() return it for every request, so all requests share one DI sub-tree that you can then resolve() with the same id.
import { ContextIdFactory } from '@nestjs/core';
it('retrieves the request-scoped instance of a tested request with a fixed context id', async () => {
const contextId = ContextIdFactory.create();
const spy = vi.spyOn(ContextIdFactory, 'getByRequest').mockImplementation(() => contextId);
await request(app.getHttpServer()).get('/books').set('x-request-id', 'r-7').expect(200);
const ctx = await moduleRef.resolve(RequestContextService, contextId);
expect(ctx.requestId).toBe('r-7');
spy.mockRestore(); // restore it: the spy is global to the process
});
Because the spy changes a static method for the whole process, always restore it, and prefer to keep this kind of test in its own file. Under Jest use jest.spyOn(). The e2e test that does this works on Express. If most of your providers are request-scoped by accident (one request-scoped dependency makes every consumer request-scoped), the better fix is usually in the design, as the performance notes in Injection Scopes explain, not in the test.
End-to-end tests with supertest
An e2e test builds the real module graph (with doubles only for the outside world), asks it for an HTTP application and sends requests. createNestApplication() turns a TestingModule into an INestApplication; app.getHttpServer() returns the underlying Node HTTP server, which supertest accepts and binds to an ephemeral port by itself, so tests never need a fixed port and cannot collide when run in parallel. Do not call app.listen() in an e2e test.
import request from 'supertest';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
describe('Books (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [TestingAppModule] }).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /books', () => request(app.getHttpServer()).get('/books').set('Authorization', 'Bearer x').expect(200));
});
Points that decide whether an e2e suite is pleasant or painful:
-
Use the default import of supertest. supertest 7 is a CommonJS module with
export =typings. WithesModuleInterop(the scaffolded setting) the form to use isimport request from 'supertest'. Theimport * as request from 'supertest'form that the book uses is a TypeScript error and, under Vitest’s ESM, a runtimeTypeError(… is not a function); both were reproduced. The scaffolded@types/supertestis installed for the typings. -
app.init()is notapp.listen().init()runs the lifecycle hooks (onModuleInit,onApplicationBootstrap) and registers routes, which is what supertest needs. Always callapp.close()inafterAll(); a forgotten close leaves database pools and timers open, and the runner then hangs or reports open handles. -
main.tsdoes not run. Everything you configure inbootstrap()(global prefix,app.useGlobalPipes(new ValidationPipe()), versioning, CORS,enableShutdownHooks(), Helmet) is absent from the testing application unless you repeat it. If your suite passes while the production application rejects the same body, this is why. The remedy is to move that configuration into one function that bothmain.tsand the tests call (configureApp(app)), or to register the pipe throughAPP_PIPEin a module. The next test shows the failure it prevents: with the pipe applied the waymain.tsapplies it,GET /books/abcis a400. -
Share a database or reset it. A
beforeAll()application shares its database across the tests of the file, so tests that write data depend on order. Either build the application inbeforeEach()(slower), truncate the tables inbeforeEach(), or run each test in a rolled-back transaction. -
Keep timing out of assertions. Fake timers and fixed clocks (like the
Greeterabove) beatsetTimeout-based waits.
it('global pipes must be repeated in the test', async () => {
const moduleRef = await Test.createTestingModule({ imports: [BooksOnlyModule] })
.overrideProvider(AdminGuard).useValue({ canActivate: () => true })
.compile();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); // what main.ts does
await app.init();
await request(app.getHttpServer()).get('/books/abc').expect(400);
await app.close();
});
On Fastify, createNestApplication<NestFastifyApplication>(new FastifyAdapter()) plus app.getHttpAdapter().getInstance().ready() is required, and app.inject() replaces supertest, as in the Fastify test above. inject() needs no socket, so there is no port to manage either.
Source: NestJS: Testing, End-to-end testing and supertest
Testing code that uses a database
Mocks verify how a service calls the persistence layer; only a real database verifies that the call means what you think. The usual arrangement is: unit-test business rules with the persistence layer mocked, and integration-test the repositories and the modules that wire them against the database engine you run in production. Data Overview & TypeORM and MongoDB with Mongoose explain the data layer; the testing-specific parts follow.
TypeORM
For a unit test, replace the repository with getRepositoryToken(Entity), as in the BooksService test above. For a named data source, pass its name as the second argument, and use getDataSourceToken() for the data source. Mocks of query builders and transactions quickly become more complicated than the code they test; when that happens, switch to a real database. The module above uses better-sqlite3 in memory, which starts in milliseconds and is enough for all the e2e tests of this page, with synchronize: true to create the schema. Its limits are the dialect: SQLite has no jsonb operators, no row locking, different type affinity and different error messages, so a query that works on it may fail on PostgreSQL and the reverse.
Source: NestJS: TypeORM, Testing
Mongoose
Models are providers with a deterministic token, getModelToken(Shelf.name), so unit tests replace them as above, and connections are replaced through getConnectionToken(). For integration tests, mongodb-memory-server downloads and starts a real mongod for the test run. A real service and schema were tested this way:
// --- book.schema.ts ---
import { Injectable } from '@nestjs/common';
import { InjectModel, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Model } from 'mongoose';
@Schema()
export class Shelf {
@Prop({ required: true, unique: true })
name: string;
@Prop({ type: [String], default: [] })
titles: string[];
}
export type ShelfDocument = HydratedDocument<Shelf>;
export const ShelfSchema = SchemaFactory.createForClass(Shelf);
@Injectable()
export class ShelvesService {
constructor(@InjectModel(Shelf.name) private readonly shelves: Model<Shelf>) {}
add(name: string, title: string) {
return this.shelves
.findOneAndUpdate({ name }, { $addToSet: { titles: title } }, { upsert: true, returnDocument: 'after' })
.lean()
.exec();
}
}
// --- the spec ---
import { MongoMemoryServer } from 'mongodb-memory-server';
import { MongooseModule } from '@nestjs/mongoose';
describe('Mongoose with a real mongod (mongodb-memory-server)', () => {
let mongod: MongoMemoryServer;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
});
afterAll(() => mongod.stop());
it('upserts', async () => {
const moduleRef = await Test.createTestingModule({
imports: [
MongooseModule.forRoot(mongod.getUri('bookshelf')),
MongooseModule.forFeature([{ name: Shelf.name, schema: ShelfSchema }]),
],
providers: [ShelvesService],
}).compile();
const svc = moduleRef.get(ShelvesService);
await svc.add('scifi', 'Dune');
const shelf = await svc.add('scifi', 'Dune');
const shelf2 = await svc.add('scifi', 'Emma');
expect(shelf?.titles).toEqual(['Dune']);
expect(shelf2?.titles).toEqual(['Dune', 'Emma']);
await moduleRef.close();
});
});
The test passed, and it shows something a mock cannot: $addToSet really keeps the array free of duplicates. MongoMemoryServer is a single node; transactions need a replica set, so use MongoMemoryReplSet for those (see MongoDB with Mongoose). Close the module (moduleRef.close()) before stopping the server, so that the Mongoose connection is released first.
Source: NestJS: MongoDB, Testing
Prisma
PrismaService is an ordinary class provider that extends the generated client, so a unit test uses the class itself as the token and the double never opens a connection. When a test imports PrismaModule, use overrideProvider(PrismaService) instead. Because the mock replaces PrismaService entirely, its onModuleInit() hook never runs.
it('can be unit tested with a mock PrismaService', async () => {
const prismaMock = { book: { findMany: vi.fn().mockResolvedValue([]) } };
const moduleRef = await Test.createTestingModule({
providers: [BooksService, { provide: PrismaService, useValue: prismaMock }],
}).compile();
expect(await moduleRef.get(BooksService).findAll()).toEqual([]);
expect(prismaMock.book.findMany).toHaveBeenCalledWith({ include: { author: true }, orderBy: { id: 'asc' } });
});
This snippet is the one shown on Prisma, Drizzle, MikroORM & Sequelize, which also runs the real client against a temporary SQLite file created with prisma migrate deploy; it needs a generated Prisma client. For Drizzle, MikroORM and Sequelize the same page shows each library’s testing pattern.
Source: NestJS: Prisma, Testing
Testcontainers
In-memory databases are a compromise. Testcontainers starts the real engine in a Docker container for the test run, so the tests exercise the same PostgreSQL or MongoDB version as production. The Node.js library is testcontainers with one package per technology (@testcontainers/postgresql, @testcontainers/mongodb, and so on); its concepts (container lifecycle, wait strategies, Ryuk, reuse, image pulls) are those described on Testcontainers Fundamentals for Java, and the Java-side Spring equivalent is on Spring Boot Integration Tests with Testcontainers.
npm i --save-dev testcontainers @testcontainers/postgresql @testcontainers/mongodb
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
describe('BooksService against PostgreSQL', () => {
let postgres: StartedPostgreSqlContainer;
let moduleRef: TestingModule;
let books: BooksService;
beforeAll(async () => {
postgres = await new PostgreSqlContainer('postgres:18-alpine').withDatabase('bookshelf').start();
moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
url: postgres.getConnectionUri(),
entities: [Book],
synchronize: true, // fine for a throwaway container; use migrations in production
}),
TypeOrmModule.forFeature([Book]),
],
providers: [BooksService, NotifierService],
}).compile();
books = moduleRef.get(BooksService);
}, 120_000);
afterAll(async () => {
await moduleRef?.close(); // close the connection pool first
await postgres?.stop();
});
it('persists a book', async () => {
const book = await books.create('Dune');
expect(await books.findOne(book.id)).toMatchObject({ title: 'Dune' });
});
});
import { MongoDBContainer, StartedMongoDBContainer } from '@testcontainers/mongodb';
let mongo: StartedMongoDBContainer;
beforeAll(async () => {
mongo = await new MongoDBContainer('mongo:8').start();
moduleRef = await Test.createTestingModule({
imports: [
// The container runs a single-node replica set; connect directly to it.
MongooseModule.forRoot(mongo.getConnectionString(), { dbName: 'bookshelf', directConnection: true }),
MongooseModule.forFeature([{ name: Shelf.name, schema: ShelfSchema }]),
],
providers: [ShelvesService],
}).compile();
}, 120_000);
Both snippets compile against testcontainers, @testcontainers/postgresql and @testcontainers/mongodb 12.2.0 and need a running Docker daemon. The container start, the postgres:18-alpine and mongo:8 image tags and the connection options follow the libraries' documentation. Note the long beforeAll timeout (the first run pulls the image), that the connection constructor takes the image as a mandatory argument, and that the container is stopped only after the Nest module has closed its pool. To keep a suite fast, start one container per test file or per run (a Vitest globalSetup), not per test, and give each test file its own database or schema. The PostgreSqlContainer also offers snapshot() and restoreSnapshot() to reset a migrated database between tests; they are in the 12.2.0 typings.
Coverage
Coverage measures which lines and branches the tests executed. It cannot tell you whether the assertions are good; it can show you the code that nothing exercises. With Vitest, install @vitest/coverage-v8 and configure the coverage block; with Jest, --coverage and collectCoverageFrom (see above) are built in.
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.spec.ts', '**/*.e2e-spec.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['**/*.module.ts', 'src/main.ts'],
reporter: ['text-summary', 'lcov'],
thresholds: { statements: 90, branches: 80 },
},
},
plugins: [swc.vite({ module: { type: 'es6' } })],
});
Running vitest run --coverage on the 21 tests of this page prints the following; with thresholds of 99 and 90 the same command reports Coverage for statements (93.87%) does not meet global threshold (99%) and exits with status 1, which is what makes a threshold usable as a CI gate, and with 90 and 80 it exits with 0.
Statements : 93.87% ( 46/49 )
Branches : 83.33% ( 15/18 )
Functions : 96% ( 24/25 )
Lines : 93.33% ( 42/45 )
Jest with --coverage and collectCoverageFrom: ['src/testing/*/.ts'] prints a per-file table, with 97.5% of statements and 83.33% of branches covered, and lists the lines that no test reached (for instance the body of NotifierService.bookCreated(), which every test replaced with a mock). Practical advice: exclude files that only wire things together (modules, main.ts, generated code) rather than testing them for the number; set thresholds per run, not as a goal; report on the combined unit and e2e runs, because e2e tests reach code that unit tests miss; and treat a low-coverage file as a question ("is this untested because it is trivial or because it is hard?").
Source: Vitest: Coverage and Jest: collectCoverageFrom
Where the book is outdated
The reference book (see the section bibliography) uses Jest and import * as request from 'supertest' in its testing chapter, and states that it was tested with NestJS 10. Current Nest differs in four ways. New ESM projects use Vitest by default, with Jest kept for CommonJS projects, and a project can use either. The import * as request form no longer works with supertest 7 and esModuleInterop: use the default import. SWC (unplugin-swc for Vitest, @swc/jest for Jest) is the documented way to speed up and align test compilation, and the Nest documentation has dedicated recipes for both. Finally, a guard registered globally with useClass under APP_GUARD cannot be replaced by an override; register it with useExisting as shown above. Wherever the book’s examples return HTTP 200 for a failure, a test should assert the status code the endpoint ought to return instead.
What changed recently
-
NestJS 12 scaffolds Vitest for ESM projects and Jest 30 for CommonJS projects, and the Nest documentation’s testing page now uses Vitest, with
vi.fn()andvi.spyOn(),.jsimport extensions and the defaultsupertestimport. -
The Vite 8 line (used by Vitest 4.1) transforms TypeScript with Oxc, which emits decorator metadata from the
tsconfig.jsonsettings without an SWC plugin; on Vitest 3 and Vite 7 the plugin is needed (shown above). -
The Jest scaffold uses
--experimental-vm-modules, and Jest can load the ESM-only Nest 12 packages from CommonJS tests only on Node.js 24.9 or later (migration guide). -
The 12.1.2 typings of
@nestjs/testingshowTest.createTestingModule(metadata, options?)with amoduleIdGeneratorAlgorithmoption, andcompile()acceptingsnapshotandpreview; none of these is on the testing documentation page. -
Testcontainers for Node.js reached 12.x (12.2.0 on 2026-10-02) with module constructors that take the image name as a required argument.