Core Patterns
NestJS testing relies on Test.createTestingModule() to build a lightweight DI container for the units under test. Unit tests replace real providers with mocks; E2E tests spin up the full application. Guards and interceptors are tested in isolation without the full request cycle.
Unit Testing with DI — Test.createTestingModule() and Mock Providers
Build a minimal testing module that includes only the class under test and mocked versions of its dependencies. Never import the full feature module in unit tests.
// src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
// Mock implementation typed to the interface
const mockUsersRepository = {
findById: jest.fn(),
findAll: jest.fn(),
findByEmail: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
describe('UsersService', () => {
let service: UsersService;
let repository: typeof mockUsersRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
// Replace the real repository with the mock
{
provide: UsersRepository,
useValue: mockUsersRepository,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repository = module.get(UsersRepository);
});
afterEach(() => {
// Reset all mocks between tests to prevent state leakage
jest.clearAllMocks();
});
describe('findById', () => {
it('returns the user when found', async () => {
const user = { id: '1', name: 'Alice', email: 'alice@example.com' };
repository.findById.mockResolvedValue(user);
const result = await service.findById('1');
expect(repository.findById).toHaveBeenCalledWith('1');
expect(result).toEqual(user);
});
it('throws NotFoundException when user does not exist', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('non-existent'))
.rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('throws ConflictException when email is already taken', async () => {
repository.findByEmail.mockResolvedValue({ id: '2', email: 'bob@example.com' });
await expect(service.create({ name: 'Bob', email: 'bob@example.com' }))
.rejects.toThrow('Email already registered');
});
it('saves and returns the new user', async () => {
repository.findByEmail.mockResolvedValue(null);
const saved = { id: '3', name: 'Charlie', email: 'charlie@example.com' };
repository.save.mockResolvedValue(saved);
const result = await service.create({ name: 'Charlie', email: 'charlie@example.com' });
expect(repository.save).toHaveBeenCalledWith({ name: 'Charlie', email: 'charlie@example.com' });
expect(result).toEqual(saved);
});
});
});
Mocking Services with jest.fn() and useValue
When testing a controller, mock the service. When testing a service, mock the repository. Provide mocks as plain objects using useValue — no need for jest.mock() module-level mocking.
// src/users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
// Typed partial mock — only define methods the controller calls
const mockUsersService: Partial<UsersService> = {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
};
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('delegates to UsersService.findAll()', async () => {
const users = [{ id: '1', name: 'Alice' }];
(service.findAll as jest.Mock).mockResolvedValue(users);
const result = await controller.findAll();
expect(service.findAll).toHaveBeenCalledTimes(1);
expect(result).toEqual(users);
});
});
describe('create', () => {
it('passes the DTO to UsersService.create()', async () => {
const dto = { name: 'Bob', email: 'bob@example.com' };
const created = { id: '2', ...dto };
(service.create as jest.Mock).mockResolvedValue(created);
const result = await controller.create(dto as any);
expect(service.create).toHaveBeenCalledWith(dto);
expect(result).toEqual(created);
});
});
});
For injection tokens (not class references), use the token string or symbol as the provide key:
// Mocking a provider registered with a string token
{
provide: 'MAILER_OPTIONS',
useValue: { host: 'localhost', port: 1025 },
}
// Mocking a provider registered with a symbol token
const CACHE_CLIENT = Symbol('CACHE_CLIENT');
{
provide: CACHE_CLIENT,
useValue: { get: jest.fn(), set: jest.fn() },
}
E2E Testing with Supertest and @nestjs/testing
E2E tests start the full NestJS application (minus external services) and test HTTP behavior end-to-end through real middleware, pipes, and guards.
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as supertest from 'supertest';
import { AppModule } from '../src/app.module';
import { DatabaseService } from '../src/shared/database/database.service';
describe('UsersController (e2e)', () => {
let app: INestApplication;
let db: DatabaseService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
// Override the real DB with a test DB or in-memory stub
.overrideProvider(DatabaseService)
.useValue({
query: jest.fn(),
disconnect: jest.fn(),
})
.compile();
app = moduleFixture.createNestApplication();
// Apply the same global pipes and filters as production
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
db = moduleFixture.get<DatabaseService>(DatabaseService);
});
afterAll(async () => {
await app.close();
});
describe('POST /users', () => {
it('creates a user and returns 201', async () => {
const created = { id: '1', name: 'Alice', email: 'alice@example.com' };
(db.query as jest.Mock).mockResolvedValueOnce(created);
const response = await supertest(app.getHttpServer())
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201);
expect(response.body).toMatchObject({ id: '1', name: 'Alice' });
});
it('returns 400 for invalid email', async () => {
const response = await supertest(app.getHttpServer())
.post('/users')
.send({ name: 'Bob', email: 'not-an-email' })
.expect(400);
expect(response.body.message).toBeDefined();
});
});
describe('GET /users/:id', () => {
it('returns 404 when user does not exist', async () => {
(db.query as jest.Mock).mockResolvedValueOnce(null);
await supertest(app.getHttpServer())
.get('/users/non-existent-id')
.expect(404);
});
});
});
E2E test configuration in jest-e2e.json:
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" }
}
Run E2E tests separately from unit tests:
{
"scripts": {
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json"
}
}
Testing Guards and Interceptors in Isolation
Guards and interceptors receive an ExecutionContext. Test them without spinning up the full application by constructing a mock context.
// src/auth/guards/auth.guard.spec.ts
import { AuthGuard } from './auth.guard';
import { JwtService } from '@nestjs/jwt';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { createMock } from '@golevelup/ts-jest'; // optional helper
const mockJwtService = {
verifyAsync: jest.fn(),
};
describe('AuthGuard', () => {
let guard: AuthGuard;
beforeEach(() => {
guard = new AuthGuard(mockJwtService as any);
jest.clearAllMocks();
});
function buildContext(authHeader?: string): ExecutionContext {
// Construct a minimal ExecutionContext mock
const mockRequest = { headers: { authorization: authHeader } };
const mockResponse = {};
const mockNext = {};
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
getResponse: () => mockResponse,
getNext: () => mockNext,
}),
getClass: () => Object,
getHandler: () => () => {},
} as unknown as ExecutionContext;
}
it('throws UnauthorizedException when no Authorization header', async () => {
const context = buildContext(undefined);
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('throws UnauthorizedException when token is invalid', async () => {
mockJwtService.verifyAsync.mockRejectedValue(new Error('invalid signature'));
const context = buildContext('Bearer bad-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('attaches user to request and returns true for valid token', async () => {
const payload = { sub: 'user-1', email: 'alice@example.com' };
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = buildContext('Bearer valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
// Verify user was attached to request
const request = context.switchToHttp().getRequest() as any;
expect(request.user).toEqual(payload);
});
});
Testing an interceptor that transforms the response:
// src/common/interceptors/transform.interceptor.spec.ts
import { TransformInterceptor } from './transform.interceptor';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
describe('TransformInterceptor', () => {
let interceptor: TransformInterceptor;
beforeEach(() => {
interceptor = new TransformInterceptor();
});
it('wraps the response in a data envelope', (done) => {
const context = {} as ExecutionContext;
const callHandler: CallHandler = {
handle: () => of({ id: 1, name: 'Alice' }),
};
interceptor.intercept(context, callHandler).subscribe((result) => {
expect(result).toEqual({ data: { id: 1, name: 'Alice' } });
done();
});
});
});