NestJS тестируется хорошо по той же причине, по которой хорошо структурируется, — из-за DI: любой провайдер подменяется на тестовый, не трогая код, который его использует. Это позволяет проверять и отдельный Handler с моками, и весь путь от HTTP до базы. Тестовый раннер — Jest.
Тестовый модуль
В тесте собирают усечённый модуль через Test.createTestingModule — с теми провайдерами, что нужны, и моками для остального.
import { Test } from '@nestjs/testing';
describe('CreateProductHandler', () => {
let handler: CreateProductHandler;
const repo = { save: jest.fn() };
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
CreateProductHandler,
{ provide: ProductRepository, useValue: repo },
],
}).compile();
handler = moduleRef.get(CreateProductHandler);
});
it('saves a product', async () => {
repo.save.mockResolvedValue({ id: 1 });
const result = await handler.handle({ name: 'Кофемолка', price: 4990 });
expect(result.id).toBe(1);
});
});
Здесь Handler тестируется в изоляции: репозиторий — мок, базы нет, тест быстрый. Это нижний, самый широкий уровень пирамиды.
Переопределение провайдеров
Когда поднимают реальный модуль, но хотят подменить часть, используют overrideProvider.
const moduleRef = await Test.createTestingModule({
imports: [ProductModule],
})
.overrideProvider(ProductRepository)
.useValue(fakeRepository)
.compile();
Так же подменяют guards (overrideGuard) — чтобы тестировать контроллер, не возясь с настоящим токеном. Подменяемость встроена, потому что всё идёт через DI.
E2E через supertest
Сквозной тест поднимает приложение и шлёт ему реальные HTTP-запросы через supertest.
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
describe('Products (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
await app.init();
});
it('POST /products', () => {
return request(app.getHttpServer())
.post('/products')
.send({ name: 'Кофемолка', price: 4990 })
.expect(201);
});
afterAll(async () => {
await app.close();
});
});
E2E проходит весь конвейер — pipes, guards, контроллер, Handler — и потому самый ценный для проверки контракта, но и самый медленный; их держат немного.
Тестовая база
E2E с данными бьют в реальную базу того же типа (PostgreSQL — в PostgreSQL, не в SQLite), но изолированно: отдельная тестовая база, которую миграции поднимают перед прогоном, или обёртка каждого теста в транзакцию с откатом. Поднимать настоящую базу в контейнере на время тестов — обычная практика; так проверяется и схема, и запросы.
Где это в UCP
Подменяемость через DI задаёт пирамиду: быстрые тесты Handler-ов и домена с моками портов — снизу, тесты контроллеров с подменой репозитория — посередине, немного e2e с тестовой базой — сверху. Логику не гоняют через HTTP без нужды, HTTP-слой не дублируют в каждом тесте. Это та же стратегия, что в Spring-биндинге со слайс-тестами и TestContainers, только Jest и supertest. Сервис, который так тестируется, продукт-инженер меняет без страха — обратная связь приходит за секунды.