Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як протестувати додаток NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Тестування NestJS** використовує `@nestjs/testing` для ізольованих DI-контейнерів у unit-тестах і повного запуску додатку для e2e через Supertest. ```typescript const module = await Test.createTestingModule({ providers: [ UsersService, { provide: getRepositoryToken(User), useValue: mockRepo } ], }).compile(); const service = module.get(UsersService); ``` **Головне:** підміняй тільки те, від чого залежить тестований провайдер; завжди `await app.init()` перед першим e2e-запитом.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Тестування 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](/questions/nestjs-decorators), резолвить токени і зв'язує все разом. Головна відмінність від `NestFactory.create()` - ніякого HTTP-слухача. Коли ти пишеш `{ provide: getRepositoryToken(User), useValue: mockRepo }`, Nest замінює кожну точку ін'єкції для цього токена твоїм моком. Це працює тому, що `getRepositoryToken()` повертає передбачуваний рядок-токен. Механізм той самий, що і в стандартному [NestJS dependency injection](/questions/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 }` обходить авторизацію, і ти тестуєш тільки логіку контролера.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.