Set up NestJS testing modules, override providers with mocks, test controllers and services wired together, and write end-to-end NestJS tests using the full application context.
NestJS's dependency injection system makes module-level testing both powerful and predictable. Test.createTestingModule() mirrors the real module bootstrap process but gives you precise control over which providers are real and which are mocked. You can test a single controller with its real service and a mocked database repository, or spin up the entire application context for end-to-end tests.
This lesson covers the two primary NestJS integration testing patterns: isolated module tests for fast unit-style integration, and full app tests with supertest for HTTP-level verification. Both patterns are essential for a well-tested NestJS application.
createTestingModule builds a lightweight NestJS module with only the providers you declare. Override real providers to control dependencies.
1// src/users/users.module.ts
2@Module({
3 providers: [UsersService, UsersRepository],
4 controllers: [UsersController],
5})
6export class UsersModule {}
7
8// src/users/users.service.ts
9@Injectable()
10export class UsersService {
11 constructor(private readonly repo: UsersRepository) {}
12
13 async findById(id: string): Promise<User | null> {
14 return this.repo.findById(id);
15 }
16
17 async create(dto: CreateUserDto): Promise<User> {
18 const existing = await this.repo.findByEmail(dto.email);
19 if (existing) throw new ConflictException('Email already registered');
20 return this.repo.create(dto);
21 }
22}
23
24// src/users/users.service.spec.ts
25import { Test, TestingModule } from '@nestjs/testing';
26import { ConflictException } from '@nestjs/common';
27import { UsersService } from './users.service';
28import { UsersRepository } from './users.repository';
29
30describe('UsersService', () => {
31 let service: UsersService;
32 let mockRepo: jest.Mocked<UsersRepository>;
33
34 beforeEach(async () => {
35 const module: TestingModule = await Test.createTestingModule({
36 providers: [
37 UsersService,
38 {
39 provide: UsersRepository,
40 useValue: {
41 findById: jest.fn(),
42 findByEmail: jest.fn(),
43 create: jest.fn(),
44 },
45 },
46 ],
47 }).compile();
48
49 service = module.get<UsersService>(UsersService);
50 mockRepo = module.get(UsersRepository);
51 });
52
53 it('returns null when user does not exist', async () => {
54 mockRepo.findById.mockResolvedValue(null);
55 expect(await service.findById('nonexistent')).toBeNull();
56 });
57
58 it('returns the user when found', async () => {
59 const user = { id: '1', email: '[email protected]', name: 'Alice' } as User;
60 mockRepo.findById.mockResolvedValue(user);
61 expect(await service.findById('1')).toEqual(user);
62 });
63
64 it('throws ConflictException when email is already registered', async () => {
65 mockRepo.findByEmail.mockResolvedValue({ id: '1' } as User);
66 await expect(
67 service.create({ email: '[email protected]', password: 'pass', name: 'Alice' })
68 ).rejects.toThrow(ConflictException);
69 });
70
71 it('creates and returns the new user when email is unique', async () => {
72 mockRepo.findByEmail.mockResolvedValue(null);
73 mockRepo.create.mockResolvedValue({ id: '2', email: '[email protected]', name: 'Bob' } as User);
74 const result = await service.create({ email: '[email protected]', password: 'pass', name: 'Bob' });
75 expect(result.id).toBe('2');
76 });
77});Compile the testing module's HTTP server and pass it to supertest to test the full HTTP pipeline including guards and interceptors.
1import { Test, TestingModule } from '@nestjs/testing';
2import { INestApplication, ValidationPipe } from '@nestjs/common';
3import * as request from 'supertest';
4import { UsersModule } from './users.module';
5import { UsersRepository } from './users.repository';
6
7describe('UsersController (integration)', () => {
8 let app: INestApplication;
9 let mockRepo: jest.Mocked<Partial<UsersRepository>>;
10
11 beforeEach(async () => {
12 mockRepo = {
13 findById: jest.fn(),
14 findByEmail: jest.fn(),
15 create: jest.fn(),
16 };
17
18 const moduleFixture = await Test.createTestingModule({
19 imports: [UsersModule],
20 })
21 .overrideProvider(UsersRepository)
22 .useValue(mockRepo)
23 .compile();
24
25 app = moduleFixture.createNestApplication();
26 app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
27 await app.init();
28 });
29
30 afterEach(async () => {
31 await app.close();
32 });
33
34 describe('GET /users/:id', () => {
35 it('returns 200 with the user when found', async () => {
36 mockRepo.findById!.mockResolvedValue({
37 id: '1', email: '[email protected]', name: 'Alice',
38 } as any);
39
40 const res = await request(app.getHttpServer()).get('/users/1');
41
42 expect(res.status).toBe(200);
43 expect(res.body.email).toBe('[email protected]');
44 });
45
46 it('returns 404 when user is not found', async () => {
47 mockRepo.findById!.mockResolvedValue(null);
48 const res = await request(app.getHttpServer()).get('/users/nonexistent');
49 expect(res.status).toBe(404);
50 });
51 });
52
53 describe('POST /users', () => {
54 it('returns 400 for invalid DTO (ValidationPipe active)', async () => {
55 const res = await request(app.getHttpServer())
56 .post('/users')
57 .send({ name: 'Alice' }); // missing email and password
58
59 expect(res.status).toBe(400);
60 expect(res.body.message).toBeInstanceOf(Array);
61 });
62
63 it('returns 201 with created user for valid DTO', async () => {
64 mockRepo.findByEmail!.mockResolvedValue(null);
65 mockRepo.create!.mockResolvedValue({ id: '2', email: '[email protected]', name: 'Bob' } as any);
66
67 const res = await request(app.getHttpServer())
68 .post('/users')
69 .send({ email: '[email protected]', password: 'SecurePass1!', name: 'Bob' });
70
71 expect(res.status).toBe(201);
72 expect(res.body.id).toBe('2');
73 });
74 });
75});E2E tests spin up the full NestJS application with real providers and a test database for maximum confidence.
1// test/app.e2e-spec.ts
2import { Test } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { AppModule } from '../src/app.module';
6
7describe('AppModule E2E', () => {
8 let app: INestApplication;
9
10 beforeAll(async () => {
11 const moduleFixture = await Test.createTestingModule({
12 imports: [AppModule], // full real module
13 }).compile();
14
15 app = moduleFixture.createNestApplication();
16 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
17 app.setGlobalPrefix('api');
18 await app.init();
19 });
20
21 afterAll(async () => {
22 await app.close();
23 });
24
25 it('GET /api/health returns 200', async () => {
26 const res = await request(app.getHttpServer()).get('/api/health');
27 expect(res.status).toBe(200);
28 expect(res.body.status).toBe('ok');
29 });
30
31 it('full user registration and profile retrieval flow', async () => {
32 // Register
33 const regRes = await request(app.getHttpServer())
34 .post('/api/auth/register')
35 .send({ name: 'E2E User', email: '[email protected]', password: 'SecurePass1!' });
36 expect(regRes.status).toBe(201);
37
38 const { accessToken } = regRes.body;
39
40 // Access profile
41 const profileRes = await request(app.getHttpServer())
42 .get('/api/auth/profile')
43 .set('Authorization', `Bearer ${accessToken}`);
44 expect(profileRes.status).toBe(200);
45 expect(profileRes.body.email).toBe('[email protected]');
46 });
47});Guards and interceptors are middleware — test them both in isolation and wired into the full module.
1// src/auth/jwt-auth.guard.spec.ts
2import { Test } from '@nestjs/testing';
3import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
4import { JwtAuthGuard } from './jwt-auth.guard';
5import { JwtService } from '@nestjs/jwt';
6
7describe('JwtAuthGuard', () => {
8 let guard: JwtAuthGuard;
9 let jwtService: jest.Mocked<JwtService>;
10
11 beforeEach(async () => {
12 const module = await Test.createTestingModule({
13 providers: [
14 JwtAuthGuard,
15 {
16 provide: JwtService,
17 useValue: { verify: jest.fn() },
18 },
19 ],
20 }).compile();
21
22 guard = module.get(JwtAuthGuard);
23 jwtService = module.get(JwtService);
24 });
25
26 function buildContext(authHeader?: string): ExecutionContext {
27 return {
28 switchToHttp: () => ({
29 getRequest: () => ({
30 headers: authHeader ? { authorization: authHeader } : {},
31 user: undefined,
32 }),
33 }),
34 } as unknown as ExecutionContext;
35 }
36
37 it('throws UnauthorizedException when Authorization header is missing', () => {
38 expect(() => guard.canActivate(buildContext())).toThrow(UnauthorizedException);
39 });
40
41 it('throws UnauthorizedException when token is invalid', () => {
42 jwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
43 expect(() => guard.canActivate(buildContext('Bearer bad.token'))).toThrow(UnauthorizedException);
44 });
45
46 it('returns true and sets req.user for valid token', () => {
47 const payload = { userId: 'u1', role: 'student' };
48 jwtService.verify.mockReturnValue(payload);
49 const ctx = buildContext('Bearer valid.token');
50 const result = guard.canActivate(ctx);
51 expect(result).toBe(true);
52 expect(ctx.switchToHttp().getRequest().user).toEqual(payload);
53 });
54});ValidationPipe with class-validator decorators rejects invalid input before it reaches controllers. Integration tests verify the exact validation errors returned.
1// src/courses/dto/create-course.dto.ts
2import { IsString, IsNumber, Min, Max, IsEnum, MinLength } from 'class-validator';
3
4export enum CourseLevel { BEGINNER = 'BEGINNER', INTERMEDIATE = 'INTERMEDIATE', ADVANCED = 'ADVANCED' }
5
6export class CreateCourseDto {
7 @IsString() @MinLength(5)
8 title: string;
9
10 @IsString() @MinLength(20)
11 description: string;
12
13 @IsNumber() @Min(0) @Max(999)
14 price: number;
15
16 @IsEnum(CourseLevel)
17 level: CourseLevel;
18}
19
20// Integration test with full ValidationPipe:
21describe('POST /courses — DTO validation', () => {
22 it('returns 400 with field errors for empty body', async () => {
23 const res = await request(app.getHttpServer())
24 .post('/courses')
25 .set('Authorization', `Bearer ${instructorToken}`)
26 .send({});
27
28 expect(res.status).toBe(400);
29 expect(res.body.message).toEqual(
30 expect.arrayContaining([
31 expect.stringContaining('title'),
32 expect.stringContaining('description'),
33 expect.stringContaining('price'),
34 expect.stringContaining('level'),
35 ])
36 );
37 });
38
39 it('returns 400 when price exceeds maximum', async () => {
40 const res = await request(app.getHttpServer())
41 .post('/courses')
42 .set('Authorization', `Bearer ${instructorToken}`)
43 .send({ title: 'Valid Title Here', description: 'A long enough description for the course', price: 10000, level: 'BEGINNER' });
44
45 expect(res.status).toBe(400);
46 const messages = res.body.message as string[];
47 expect(messages.some((m: string) => m.includes('price'))).toBe(true);
48 });
49
50 it('returns 400 for invalid level enum value', async () => {
51 const res = await request(app.getHttpServer())
52 .post('/courses')
53 .set('Authorization', `Bearer ${instructorToken}`)
54 .send({ title: 'Valid Title Here', description: 'A long enough description text', price: 49, level: 'EXPERT' });
55
56 expect(res.status).toBe(400);
57 });
58
59 it('returns 201 for fully valid DTO', async () => {
60 const res = await request(app.getHttpServer())
61 .post('/courses')
62 .set('Authorization', `Bearer ${instructorToken}`)
63 .send({
64 title: 'TypeScript Deep Dive',
65 description: 'A comprehensive course on TypeScript for intermediate developers',
66 price: 49.99,
67 level: 'INTERMEDIATE',
68 });
69
70 expect(res.status).toBe(201);
71 expect(res.body.id).toBeDefined();
72 });
73});A NestJS courses module tested at three levels: service with mocked repository, controller via Supertest with mocked service, and full E2E with real database.
1// src/courses/courses.service.ts
2@Injectable()
3export class CoursesService {
4 constructor(private readonly repo: CoursesRepository) {}
5
6 async findAll(filters: { level?: string; published?: boolean }): Promise<Course[]> {
7 return this.repo.findAll(filters);
8 }
9
10 async findById(id: string): Promise<Course> {
11 const course = await this.repo.findById(id);
12 if (!course) throw new NotFoundException(`Course ${id} not found`);
13 return course;
14 }
15
16 async create(dto: CreateCourseDto, instructorId: string): Promise<Course> {
17 return this.repo.create({ ...dto, instructorId });
18 }
19}
20
21// Level 1: Service unit test with mocked repo
22describe('CoursesService', () => {
23 let service: CoursesService;
24 let mockRepo: jest.Mocked<CoursesRepository>;
25
26 beforeEach(async () => {
27 const module = await Test.createTestingModule({
28 providers: [
29 CoursesService,
30 { provide: CoursesRepository, useValue: { findAll: jest.fn(), findById: jest.fn(), create: jest.fn() } },
31 ],
32 }).compile();
33 service = module.get(CoursesService);
34 mockRepo = module.get(CoursesRepository);
35 });
36
37 it('throws NotFoundException for unknown course id', async () => {
38 mockRepo.findById.mockResolvedValue(null);
39 await expect(service.findById('bad-id')).rejects.toThrow(NotFoundException);
40 });
41
42 it('passes filters through to repository', async () => {
43 mockRepo.findAll.mockResolvedValue([]);
44 await service.findAll({ level: 'BEGINNER', published: true });
45 expect(mockRepo.findAll).toHaveBeenCalledWith({ level: 'BEGINNER', published: true });
46 });
47});
48
49// Level 2: Controller integration test via Supertest
50describe('CoursesController (HTTP)', () => {
51 let app: INestApplication;
52 let mockService: jest.Mocked<Partial<CoursesService>>;
53
54 beforeEach(async () => {
55 mockService = { findAll: jest.fn(), findById: jest.fn(), create: jest.fn() };
56
57 const moduleFixture = await Test.createTestingModule({
58 imports: [CoursesModule],
59 }).overrideProvider(CoursesService).useValue(mockService).compile();
60
61 app = moduleFixture.createNestApplication();
62 app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
63 await app.init();
64 });
65
66 afterEach(() => app.close());
67
68 it('GET /courses returns 200 with list', async () => {
69 mockService.findAll!.mockResolvedValue([{ id: '1', title: 'TypeScript' }] as any);
70 const res = await request(app.getHttpServer()).get('/courses');
71 expect(res.status).toBe(200);
72 expect(res.body).toHaveLength(1);
73 });
74
75 it('GET /courses/:id returns 404 for unknown id', async () => {
76 mockService.findById!.mockRejectedValue(new NotFoundException('Course bad-id not found'));
77 const res = await request(app.getHttpServer()).get('/courses/bad-id');
78 expect(res.status).toBe(404);
79 });
80
81 it('POST /courses returns 400 for invalid DTO', async () => {
82 const res = await request(app.getHttpServer())
83 .post('/courses')
84 .send({ title: 'X' }); // too short, missing required fields
85 expect(res.status).toBe(400);
86 });
87});The three-level approach is deliberate: service tests run in < 5ms each (mocked repo), controller tests add HTTP parsing and validation (mocked service), and E2E tests verify the full wiring including real database. Each level catches a different class of bug. The overrideProvider API is the key NestJS testing pattern — it replaces a real provider by its injection token without modifying the module definition.
The next lesson covers testing data flows: verifying that data transforms correctly as it passes through multiple layers, and testing the serialisation and deserialisation of request/response payloads.
Your progress is saved in this browser, on this device only.