Skip to main content

Як протестувати додаток 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

Швидкий приклад

typescript
// 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

typescript
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()

typescript
// Неправильно - сервер ще не слухає app = moduleFixture.createNestApplication(); request(app.getHttpServer()).get('/users'); // ECONNREFUSED // Правильно await app.init(); request(app.getHttpServer()).get('/users'); // 200

Відсутній ValidationPipe в e2e-налаштуванні. Якщо твій додаток використовує ValidationPipe глобально, додай його в beforeAll:

typescript
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init();

Без цього POST-запити з невалідним тілом повертатимуть 201 замість 400. Тести проходять, але продакшен відхиляє той самий запит.

Спільна тестова база без очищення. Тести, що пишуть у спільну базу, залишають дані. Наступний тест знаходить зайві рядки і падає. Рішення: обрізати таблиці в beforeEach або використовувати окрему схему для кожного запуску. Це найпоширеніша причина нестабільних тестів у CI для NestJS-проектів - і зазвичай виявляється прямо перед релізом.

Мок усього модуля замість конкретних провайдерів:

typescript
// Неправильно - втрачається 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

typescript
// 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-флоу

typescript
// 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

typescript
// 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 } обходить авторизацію, і ти тестуєш тільки логіку контролера.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?