E2E-тестирование через supertest
E2E-тесты поднимают всё приложение (createTestingModule(AppModule), createNestApplication, app.init) и гоняют его через supertest по реальному pipeline, реальной тестовой БД и реальным JWT либо переопределённым guard. Всегда app.close().
Твой unit-тест на UsersService.create зелёный. Он мокает repository, проверяет, что DTO мапится чисто, и выполняется за две миллисекунды. А потом прод возвращает 500 на самый первый POST /users: глобальный ValidationPipe отклоняет поле, которого замоканный DTO в глаза не видел, JwtAuthGuard, который ты забыл занести в whitelist, блокирует маршрут, а уникальное ограничение на email — невидимое замоканному repo — выбрасывает сырую ошибку Postgres, которую твой filter не ловит. Ни один из этих багов не живёт в UsersService. Они живут в обвязке: pipe, guard, filter, реальная схема. Unit-тест замокал всё это, поэтому увидеть их он не способен в принципе. Лечение — тест, который поднимает настоящее приложение и общается с ним по HTTP, ровно как это сделал бы клиент.
Подними всё приложение, потом говори с ним по HTTP
За десять минут у тебя будет рабочая e2e-настройка, которая ловит баги обвязки, невидимые unit-тесту: pipe, отклоняющий поле, guard, молча блокирующий маршрут.
Unit-тест инстанцирует один класс с фейками вокруг. E2E-тест делает наоборот: он компилирует весь граф модулей из AppModule, строит настоящее приложение Nest и гоняет его через HTTP-листенер. Два метода, которые ты пропускаешь в unit-тестах, здесь становятся центральными. compile() строит только граф провайдеров — у HttpAdapterHost ещё нет сервера. createNestApplication() — это то, что инстанцирует полный рантайм с HTTP-адаптером; app.init() затем выполняет все lifecycle-хуки (onModuleInit, onApplicationBootstrap) и регистрирует твои глобальные pipes, guards, interceptors и filters.
import * as request from 'supertest';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { AppModule } from '../src/app.module';
describe('Users (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule], // ВСЁ приложение, не один провайдер
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); // зеркалим main.ts
await app.init(); // запускает lifecycle-хуки + обвязывает глобалы
});
it('POST /users then GET /users/:id', async () => {
const created = await request(app.getHttpServer())
.post('/users')
.send({ email: 'ada@example.com', name: 'Ada' })
.expect(201);
await request(app.getHttpServer())
.get(`/users/${created.body.id}`)
.expect(200)
.expect((res) => {
if (res.body.email !== 'ada@example.com') throw new Error('email mismatch');
});
});
afterAll(async () => {
await app.close(); // освобождает хендлы БД + открытые сокеты
});
});request(app.getHttpServer()) передаёт supertest (HTTP-клиент для Node.js, умеющий биндить приложение без реального порта) нижележащий Node HTTP-листенер. Supertest биндит его на эфемерный порт, шлёт настоящий запрос, и ответ проходит по настоящему pipeline. Этот единственный .post('/users') прогоняет твой глобальный ValidationPipe, каждый guard на маршруте, контроллер, сервис, repository против реальной БД и твой exception filter на выходе — именно поэтому такой тест ловит баги обвязки, которые unit-тест замокал.
▸Почему это работает
Почему e2e ловит то, что unit-тесты не могут? Unit-тест на UsersService заменяет repository, pipe и guard фейками — так что по построению он тестирует сервис в изоляции от ровно тех вещей, что сломались в проде. Те 500 из хука были ValidationPipe, отклонившим поле, JwtAuthGuard, заблокировавшим маршрут, и нарушением уникального ограничения. Все три — это обвязка, а не логика сервиса. Supertest шлёт запрос через собранное приложение, так что pipe реально валидирует, guard реально выполняется, а реальная схема реально обеспечивает свои ограничения. Ты меняешь скорость (миллисекунды становятся сотнями мс) на покрытие тех интеграционных швов, где и живёт большинство прод-инцидентов.
Направь приложение на реальную одноразовую тестовую БД
БД — это то, что делает e2e честным. Замокай repository — и ты соврал про тот самый слой, который ломается чаще всего: ограничения, транзакции, реальный SQL. Поэтому честный e2e-набор гоняется против реальной, но эфемерной БД: dockerized Postgres (часто через Testcontainers, который поднимает одноразовый контейнер на прогон) или лёгкого движка вроде sqlite. Ты прогоняешь миграции, чтобы построить схему, засеваешь фикстуры и — критически — сбрасываешь состояние между тестами, чтобы они оставались независимыми.
Ты переопределяешь продовый data source, чтобы приложение говорило с тестовой БД. С TypeORM подмени провайдер DataSource по его токену:
import { getDataSourceToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
const testDataSource = new DataSource({
type: 'postgres',
url: process.env.TEST_DATABASE_URL, // одноразовый контейнер, не прод
entities: [User],
synchronize: false, // вместо этого гоняем реальные миграции
});
beforeAll(async () => {
await testDataSource.initialize();
await testDataSource.runMigrations();
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(getDataSourceToken()) // заменяем продовый DataSource...
.useValue(testDataSource) // ...на тестовый
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
beforeEach(async () => {
// изоляция: вытираем строки между тестами, чтобы порядок никогда не имел значения
await testDataSource.query('TRUNCATE TABLE "user" RESTART IDENTITY CASCADE');
});TRUNCATE между тестами — самый дешёвый путь к изоляции: каждый it стартует с заведомо пустой таблицы, так что тест, создающий пользователя, не может отравить следующий. Альтернатива — оборачивать каждый тест в транзакцию и откатывать — быстрее, но ломается, когда твой код открывает собственные транзакции. Конфиг через переменные окружения (.env.test, направляющий DATABASE_URL на контейнер) — более простая альтернатива overrideProvider, когда всё приложение читает одну строку подключения.
Auth в тестах: реальный токен или обход guard
Если твои маршруты сидят за JwtAuthGuard, каждый e2e-запрос должен его удовлетворить — и у тебя два честных варианта. Вариант один: получи реальный токен. Сходи на /auth/login (или подпиши токен тестовым секретом) и приложи его, чтобы guard выполнился по-настоящему:
let token: string;
beforeAll(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'ada@example.com', password: 'pw' });
token = res.body.accessToken;
});
it('GET /me with a real JWT', () =>
request(app.getHttpServer())
.get('/me')
.set('Authorization', `Bearer ${token}`) // guard выполняется, валидирует токен
.expect(200));Вариант два: переопредели guard, когда auth — не то, что ты тестируешь, чтобы не платить за танец с логином в каждой спеке:
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true }) // каждый запрос «аутентифицирован»
.compile();Компромисс — реалистичность против скорости. Реальный токен прогоняет весь путь auth — подпись токена, guard, strategy, заполнение req.user — так что ловит баги обвязки auth; он медленнее и связывает твой тест с flow логина. Переопределение guard быстрое и сфокусированное, но это значит, что данный набор не доказывает ничего про аутентификацию, так что тебе всё равно нужна хотя бы одна спека, использующая реальный токен от начала до конца.
Гигиена lifecycle: закрой приложение, изолируй состояние
Самый частый провал e2e — не ассерт, а Jest, зависший в конце с «a worker process has failed to exit gracefully… open handles». Это приложение, которое ты поднял, но не закрыл: пул БД и HTTP-сокет всё ещё открыты. afterAll(() => app.close()) выполняет shutdown-хуки и освобождает их. Сочетай это со сбросом состояния БД на каждый тест и никогда не давай одному тесту зависеть от объедков другого.
| Подвох | Симптом | Фикс |
|---|---|---|
| Приложение не закрыто | Jest зависает; «open handles» / «failed to exit gracefully» | await app.close() в afterAll |
| Утёкшие соединения | «too many clients»; исчерпание пула между наборами | Закрой и DataSource; одно приложение на набор, не на тест |
| Общее изменяемое состояние | Тесты проходят по отдельности, падают вместе | TRUNCATE / откат в beforeEach; никаких межтестовых фикстур |
| Тесты, зависящие от порядка | Зелёные в порядке файла, красные при рандомизации/шардинге | Каждый тест засевает свои данные; не утверждай ничего про прежние строки |
У твоего API есть уникальное ограничение на email и денежный перевод, обёрнутый в транзакцию. Ты хочешь, чтобы e2e-набор ловил реальные баги SQL и ограничений, а не просто подтверждал счастливый путь сервиса. Что стоит за e2e-базой?
Почему request(app.getHttpServer()).post('/users') ловит баг глобального ValidationPipe, который unit-тест на UsersService.create поймать не может?
Твой e2e-набор проходит, когда каждый файл гоняется отдельно, но падает, когда Jest гоняет их вместе, и CI иногда предупреждает про open handles. Каковы две коренные причины?
- 01Пройди по e2e-настройке от начала до конца: какие методы поднимают приложение, что гоняет supertest и почему это ловит баги, которые unit-тест не может?
- 02Как дать e2e-набору реальную тестовую БД и разрулить auth, и какая гигиена lifecycle не даёт ему течь или флакать?
E2E-тест — это инверсия unit-теста: вместо одного класса, обёрнутого в фейки, он поднимает всё приложение и гоняет его по HTTP. Ты компилируешь весь граф через Test.createTestingModule({ imports: [AppModule] }).compile(), затем вызываешь createNestApplication(), чтобы построить полный рантайм с HTTP-адаптером — один compile() оставляет без сервера — и await app.init(), чтобы выполнить lifecycle-хуки и зарегистрировать твои глобальные pipes, guards, interceptors и filters. request(app.getHttpServer()) передаёт supertest реальный HTTP-листенер, так что .post(‘/users’).send(dto).expect(201) проходит по подлинному pipeline: ValidationPipe валидирует, каждый guard выполняется, хендлер идёт в реальный repository, а exception filter формирует любую ошибку. Поэтому e2e и ловит баги обвязки — отклонённое поле, блокирующий guard, нарушение уникального ограничения, — которые замоканный unit-тест увидеть не может. Подкрепи это реальной, но одноразовой БД: dockerized Postgres (Testcontainers) или sqlite, с реальными миграциями, засеянными фикстурами и TRUNCATE между тестами ради изоляции, переопределив продовый DataSource через .overrideProvider(getDataSourceToken()).useValue(testDataSource) или строку подключения через переменные окружения. Для auth либо достань реальный JWT и поставь заголовок Authorization (реалистично, прогоняет весь путь auth), либо .overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true }), чтобы обойти его (быстро, но ничего не доказывает про аутентификацию). Наконец, держи набор чистым: afterAll(() => app.close()) освобождает пул БД и сокет, чтобы Jest не зависал на open handles, сбрасывай состояние БД на каждый тест и никогда не давай одному тесту зависеть от другого. Теперь, когда увидишь «open handles» в CI или сьют, проходящий отдельно, но падающий вместе, ты знаешь, где искать: незакрытое приложение, общая строка в БД или пропущенный TRUNCATE в beforeEach.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.