Як протестувати додаток NestJS?
Тестування NestJS спирається на @nestjs/testing для побудови ізольованих DI-контейнерів у unit-тестах і повного запуску додатку для e2e через Supertest.
Теорія
TL;DR
- Unit-тести створюють легкий модуль через
Test.createTestingModule(), підміняють реальні залежностіjest.fn()моками і виконуються за мілісекунди - E2e-тести піднімають повноцінний Express/Fastify-сервер, надсилають реальні HTTP-запити через Supertest і перевіряють наскрізний флоу
- Guards, pipes та interceptors перевизначаються через
.overrideGuard(),.overridePipe(),.overrideInterceptor() - Unit-тест для чистої бізнес-логіки; e2e для auth-флоу, валідації та багатокрокових сценаріїв
- Завжди
await app.init()перед першим e2e-запитом, інакше ECONNREFUSED
Швидкий приклад
// Unit-тест: підміняємо TypeORM-репозиторій, тестуємо сервіс окремо
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
describe('UsersService', () => {
let service: UsersService;
const mockRepo = {
find: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
findOneBy: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepo },
],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('повертає список користувачів', async () => {
const result = await service.findAll();
expect(result).toEqual([{ id: 1, name: 'Alice' }]);
});
});
// ✓ повертає список користувачів (4ms)Test.createTestingModule() будує DI-контейнер, який повторює структуру реального модуля, але не запускає HTTP-сервер. Мок-репозиторій займає місце TypeORM, підключення до бази не потрібне.
Unit-тести проти e2e
Unit-тест ізолює один провайдер. Ти підміняєш тільки те, від чого цей провайдер залежить. DI-контейнер резолвить токени через reflect-metadata, як у продакшені, але без жодного I/O. Добре написаний unit-тест виконується менш ніж за 50ms.
E2e-тести викликають module.createNestApplication(), що піднімає справжній Express або Fastify сервер. Supertest прив'язується до нього і надсилає реальні HTTP-запити. Відпрацьовують guards, pipes, interceptors і middleware. Це єдиний спосіб перевірити помилки валідації, auth-флоу або все, що відбувається на рівні HTTP.
Коли що використовувати
- Сервіс із запитами до БД: unit-тест із мок-репозиторієм
- Контролер із ValidationPipe: unit-тест з мок-сервісом і реальним
ValidationPipe - Флоу входу (POST /auth → JWT → GET /profile): e2e-тест
- Логіка guard в ізоляції: unit-тест із мок
ExecutionContext - Повний користувацький сценарій із реальними записами в БД: e2e з тестовою базою
Якщо тесту потрібне мережеве з'єднання або реальна база, роби e2e. Інакше - unit.
Як працює модульна система зсередини
Test.createTestingModule() створює легкий DI-контейнер. Він сканує провайдери через декоратори reflect-metadata, резолвить токени і зв'язує все разом. Головна відмінність від NestFactory.create() - ніякого HTTP-слухача.
Коли ти пишеш { provide: getRepositoryToken(User), useValue: mockRepo }, Nest замінює кожну точку ін'єкції для цього токена твоїм моком. Це працює тому, що getRepositoryToken() повертає передбачуваний рядок-токен. Механізм той самий, що і в стандартному NestJS dependency injection, де токени прив'язуються до провайдерів під час компіляції.
Для e2e-тестів createNestApplication() запускає ту ж логіку ініціалізації, що і твій main.ts. Якщо у продакшені є app.useGlobalPipes(new ValidationPipe()), додай це і в тест. Без цього перевірки валідації будуть проходити там, де мали б падати.
Перевизначення guards, pipes та interceptors
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [UsersService],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overridePipe(ParseIntPipe)
.useValue({ transform: (val) => parseInt(val) })
.compile();.overrideGuard() замінює guards після резолвінгу DI. Це відрізняється від overrideProvider(), який підміняє будь-який інжектабл за токеном. Правило: для guards - overrideGuard, для interceptors - overrideInterceptor, для сервісів і репозиторіїв - overrideProvider.
Типові помилки
Забутий await app.init()
// Неправильно - сервер ще не слухає
app = moduleFixture.createNestApplication();
request(app.getHttpServer()).get('/users'); // ECONNREFUSED
// Правильно
await app.init();
request(app.getHttpServer()).get('/users'); // 200Відсутній ValidationPipe в e2e-налаштуванні. Якщо твій додаток використовує ValidationPipe глобально, додай його в beforeAll:
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();Без цього POST-запити з невалідним тілом повертатимуть 201 замість 400. Тести проходять, але продакшен відхиляє той самий запит.
Спільна тестова база без очищення. Тести, що пишуть у спільну базу, залишають дані. Наступний тест знаходить зайві рядки і падає. Рішення: обрізати таблиці в beforeEach або використовувати окрему схему для кожного запуску. Це найпоширеніша причина нестабільних тестів у CI для NestJS-проектів - і зазвичай виявляється прямо перед релізом.
Мок усього модуля замість конкретних провайдерів:
// Неправильно - втрачається DI-контекст, module.get() не працює
jest.mock('./users.service');
// Правильно - мокуємо тільки потрібне
{ provide: UsersService, useValue: { findAll: jest.fn() } }Тестування REQUEST-scoped провайдерів у unit-тестах. REQUEST-scoped провайдери відтворюються на кожен HTTP-запит. У unit-тесті запиту немає, тому module.get() для такого провайдера не спрацює. Тестуй їх в e2e або підміняй через overrideProvider на DEFAULT-scoped мок. У NestJS v10 з'явився overrideScope для точнішого контролю.
Де застосовується в реальних проектах
- NestJS CLI генерує
*.spec.tsфайли з налаштуванням@nestjs/testingодразу післяnest new project - Prisma-проекти мокають
PrismaServiceу unit-тестах, для тестів із записом використовують spy на$transaction - BullMQ:
overrideProvider(Queue).useValue(mockQueue)для тестування процесорів без Redis - GraphQL e2e: імпортуй
GqlModule.forRootі надсилай сирі query-рядки через Supertest - Stripe webhooks: мок
stripe.webhookConstructEvent(), щоб уникнути реальних платежів у CI
Follow-up питання
Q: Як замокати динамічний провайдер на кшталт ConfigService у NestJS 10+?
A: Через useFactory з ін'єкцією: { provide: 'API_KEY', useFactory: (config: ConfigService) => config.get('KEY'), inject: [ConfigService] }. Або просто перевизнач сам ConfigService через useValue: { get: jest.fn().mockReturnValue('test-key') }.
Q: Яка різниця між overrideProvider і overrideGuard?
A: overrideProvider підміняє будь-який інжектабл за токеном ін'єкції, включно з сервісами та репозиторіями. overrideGuard замінює guards після резолвінгу DI і може перевизначити guards, прив'язані до конкретного роуту, які не мають очевидного токена.
Q: Як тестувати мікросервіс із Redis-транспортом без реального Redis?
A: Повний мок клієнта: overrideProvider(getClientToken()).useValue({ send: jest.fn() }). Головне - жодного реального брокера у unit і CI тестах.
Q: Чому e2e-тест проходить локально, але падає в CI?
A: Найчастіше: відсутній globalSetup для синхронізації схеми БД, інша змінна DATABASE_URL або race condition у beforeAll. Додай явну міграцію схеми в globalSetup і перевір змінні оточення в конфігурації CI.
Q: Як REQUEST-scope впливає на мокування в unit-тестах і як з цим працювати?
A: REQUEST-scoped провайдери відтворюються на кожен HTTP-запит. У unit-тесті запиту немає, тому Nest не може їх нормально резолвити. Варіанти: тестувати в e2e де є справжній HTTP-контекст, або замінити на DEFAULT-scoped мок через overrideProvider. У NestJS v10 з'явився overrideScope для точнішого контролю - це деталь, яку варто згадати на senior-рівні.
Приклади
Базовий unit-тест сервісу з NotFoundException
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { NotFoundException } from '@nestjs/common';
describe('UsersService', () => {
let service: UsersService;
let repo: jest.Mocked<Repository<User>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repo = module.get(getRepositoryToken(User));
});
it('повертає користувача, коли знайдено', async () => {
const user = { id: 1, name: 'Alice', email: 'alice@test.com' } as User;
repo.findOneBy.mockResolvedValue(user);
const result = await service.findOne(1);
expect(result).toEqual(user);
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 1 });
});
it('кидає NotFoundException, коли користувача не існує', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(service.findOne(99)).rejects.toThrow(NotFoundException);
});
});repo.findOneBy.mockResolvedValue(null) імітує відсутній запис. Тест перевіряє, що сервіс кидає NotFoundException, а не просто повертає null. Саме цей контракт очікує решта кодової бази.
E2e-тест із auth-флоу
// test/auth.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Auth (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
// Точно дублюємо налаштування з main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
});
afterAll(async () => await app.close());
it('POST /auth/register повертає 201 з access_token', async () => {
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'test@test.com', password: 'password123' });
expect(res.status).toBe(201);
expect(res.body.access_token).toBeDefined();
});
it('POST /auth/register повертає 400 з невалідним email', async () => {
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'not-an-email', password: 'password123' });
expect(res.status).toBe(400);
});
});
// ✓ POST /auth/register повертає 201 з access_token (45ms)afterAll(async () => await app.close()) не є опціональним. Без нього Jest зависає після завершення тестів, бо HTTP-сервер продовжує слухати. Кожен e2e-файл потребує цього очищення.
Unit-тест контролера з мок-guard
// users/users.controller.spec.ts
import { Test } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
describe('UsersController', () => {
let controller: UsersController;
let service: jest.Mocked<UsersService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
findAll: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
findOne: jest.fn(),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<UsersController>(UsersController);
service = module.get(UsersService);
});
it('викликає service.findAll і повертає результат', async () => {
const result = await controller.findAll();
expect(result).toEqual([{ id: 1, name: 'Alice' }]);
expect(service.findAll).toHaveBeenCalled();
});
});Без .overrideGuard(JwtAuthGuard) guard відпрацьовує і кидає помилку, бо JWT-токена в unit-тесті немає. Мок { canActivate: () => true } обходить авторизацію, і ти тестуєш тільки логіку контролера.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.