open atlas
↑ К треку
NestJS с нуля до senior NEST · 06 · 02

E2E-тестирование через supertest

E2E-тесты поднимают всё приложение (createTestingModule(AppModule), createNestApplication, app.init) и гоняют его через supertest по реальному pipeline, реальной тестовой БД и реальным JWT либо переопределённым guard. Всегда app.close().

NEST Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Твой 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. Каковы две коренные причины?

Вспомните перед уходом
  1. 01
    Пройди по e2e-настройке от начала до конца: какие методы поднимают приложение, что гоняет supertest и почему это ловит баги, которые unit-тест не может?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.