open atlas
↑ К треку
Node.js с нуля до senior NODE · 06 · 02

Интеграционное тестирование HTTP API

Интеграционный тест прогоняет роутинг, middleware, хендлер и реальную БД через HTTP-поверхность. supertest поднимает приложение на эфемерном порту; изоляция и реальный движок решают, ловит ли набор баги или плодит флейки.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

У команды было 400 зелёных юнит-тестов, и они выкатили роут POST /orders, который отдавал 500 на первом же реальном запросе. Хендлер был безупречен в изоляции; баг был в том, что auth-middleware шёл после парсера тела, поэтому req.user был undefined к моменту, когда его читала валидация, — ошибка проводки, которую ни один юнит-тест не видел, потому что каждый юнит-тест звал хендлер напрямую с поддельным req. Хуже того, их единственный интеграционный тест проходил локально и случайно падал в CI: он бежал по той же общей dev-Postgres, что и у всех, так что seed-строка коллеги заставляла проверку «ожидаю 1 результат» прыгать между 1 и 2. У них не было нехватки тестов. У них были тесты, которые никогда не прогоняли тот шов, где реально жил баг.

Что интеграционный тест покрывает, а юнит — нет

Спроси себя: когда последний раз баг пробился в прод, несмотря на зелёные юнит-тесты? Скорее всего, он жил на шве — порядок middleware, неверный статус-код, auth-гард на не том пути. Это именно тот зазор, который закрывают интеграционные тесты.

Юнит-тест фиксирует одну функцию с заглушенными коллабораторами: быстрый, точечный и слепой к проводке. Интеграционный тест прогоняет весь путь запроса — роутер → цепочка middleware → хендлер → (обычно) реальная база — через настоящую HTTP-поверхность. Именно там живёт класс багов, до которого юнит-тесты структурно не дотягиваются: порядок middleware, забытый await в роуте, сериализатор, теряющий поле, статус-код 200 там, где должен быть 201, рассинхрон content negotiation, auth-гард, повешенный на не тот путь. Хендлер может быть верен сам по себе, а сборка всё равно сломана.

В пирамиде тестов интеграционные стоят выше юнитов и ниже end-to-end: их больше, чем E2E (которые гоняют реальный браузер/сеть и потому медленны и флейки), и меньше, чем юнитов. Отдача на тест высока — один интеграционный тест покрывает швы на много юнитов, — но каждый дороже в прогоне, так что тысячами их не пишут. Классический провал — «рожок мороженого»: почти все тесты в медленном конце, полая середина, и контрактные баги утекают в прод.

supertest: настоящие HTTP-вызовы без управления портом

supertest оборачивает любой Node-обработчик httpapp Express или Fastify, или голый (req, res) => … — и на каждый вызов поднимает его на эфемерном порту (или диспетчеризует in-process), шлёт настоящий HTTP-запрос и сносит слушатель. Ты никогда не выбираешь порт, не рискуешь EADDRINUSE от захардкоженного 3000 и проверяешь реальную строку статуса, заголовки и распарсенное тело.

import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { app } from "../src/app.js"; // НЕ вызывай здесь app.listen()

test("POST /users создаёт пользователя", async () => {
  const res = await request(app)
    .post("/users")
    .send({ email: "a@b.com" })   // ставит JSON-тело + content-type
    .expect("Content-Type", /json/)
    .expect(201);

  assert.equal(res.body.email, "a@b.com");
  assert.match(res.headers.location, /^\/users\/\d+$/);
});

Передавай сам app, а не запущенный сервер — supertest управляет слушателем, чтобы жизненным циклом владел тест. Поскольку вызов идёт по настоящему HTTP, он ловит баг проводки из хука: порядок middleware, парсинг тела и статус-код все в игре, а не заглушены.

Реальная база: четыре стратегии, четыре компромисса

База — это то, ради чего интеграционные тесты и нужны, и то, на чём они ломаются. Четыре частых подхода:

СтратегияСкоростьДостоверностьИзоляция
Транзакция на тест + rollbackСамая быстрая (нет I/O на сброс)Реальный движокСильная, автоматом
Truncate / сброс таблицМедленнее (запись на тест)Реальный движокХорошая, если чистишь всё
Testcontainers (Docker Postgres/Redis)Медленный старт (~сек/набор)Наивысшая — как в продеКонтейнер на набор
In-memory SQLiteОчень быстраяНе тот движок — ложная уверенностьНа соединение

Rollback оборачивает каждый тест в транзакцию и обрывает её в конце — ничего не коммитится, так что следующий тест видит чистую базу при нулевой цене сброса. Это самый быстрый вариант с сильной изоляцией, но он не может тестировать код, который сам коммитит или полагается на видимость транзакции (нельзя откатить то, что твой код уже закоммитил, а трюки с вложенными транзакциями меняют семантику). Truncate/сброс проще и позволяет тестировать логику коммита ценой явной записи-очистки между тестами. Testcontainers поднимает реальный Postgres или Redis в одноразовом Docker-контейнере на набор: наивысшая достоверность, потому что это тот же движок, версия и SQL-диалект, что в проде, — ценой секунд старта контейнера. In-memory SQLite быстра и заманчива, но это другая база: она не проверит твои типы Postgres, в ней нет JSONB, RETURNING, частичных индексов и многих ограничений, так что зелёный набор на SQLite может прятать SQL, который взрывается на реальном Postgres в проде. Предпочитай реальный движок (Testcontainers) ради достоверности; берись за rollback, когда правит скорость набора.

Почему это работает

Почему SQLite-вместо-Postgres именно опасна, а не просто «менее точна»? Потому что отказ тихий и инвертированный: тест зелёный, а ломается prod-запрос. Миграция с ALTER TABLE ... ADD COLUMN ... GENERATED, запрос с ON CONFLICT ... DO UPDATE, колонка citext или check-ограничение — всё это может распарситься и пройти на SQLite (которая их игнорирует или подделывает) и при этом упасть или вести себя иначе на Postgres. Ты выкатываешь с зелёной сборкой и узнаёшь о расхождении от клиента. Тестировать против движка, который деплоишь, — единственный способ сделать вердикт теста доверенным.

Изоляция и зависание на открытых хендлах

Интеграционному набору можно доверять, только если каждый тест независим: ни оставшихся строк из прошлого теста, ни зависимости от порядка, ни общего изменяемого состояния. Сей детерминированные фикстуры внутри каждого теста (или свежую транзакцию на тест), направляй набор на выделенную тестовую базу или схему — никогда на общую dev-БД — и давай параллельным воркерам отдельные схемы или базы, чтобы два воркера не дрались за одни строки. Флейк из хука — каноническое нарушение: тесты против общей БД становятся зависимыми от порядка, взаимно разрушительными и проходят-или-падают по везению.

Второй классический отказ — процесс, который не выходит. Если ты открыл слушатель сервера или пул соединений к БД и не закрыл их, тест-раннер Node заканчивает проверки, но в event loop ещё есть живые хендлы, так что процесс зависает (или ругается на открытые хендлы / утёкшие таймеры). Всегда закрывай всё в teardown:

import { test, after } from "node:test";
import { pool } from "../src/db.js";

// node:test запускает хуки `after`, когда тесты файла завершились
after(async () => {
  await pool.end();   // закрыть пул соединений к БД
  // если поднимал слушатель — server.close() тоже
});

С supertest ты обычно вообще не поднимаешь слушатель (он управляет эфемерными), так что забытый хендл — это пул. Зависший CI-джоб, отваливающийся по таймауту через десять минут, почти всегда — незакрытый пул или сокет, а не медленный тест.

Выбери лучший вариант

Твой API использует фичи Postgres (колонки JSONB, upsert через ON CONFLICT, частичные индексы). Нужен интеграционный набор, ловящий SQL-баги до прода, и ты можешь позволить пару секунд старта на набор. Какая стратегия тестовой БД?

Викторина

Что request(app) делает с сетевым портом, когда ты вызываешь supertest?

Викторина

Твой интеграционный набор зелёный на in-memory SQLite, но API отдаёт 500 в проде на запросе с ON CONFLICT и колонкой JSONB. Почему тесты это упустили?

Расставь шаги по порядку

Расставь жизненный цикл одного изолированного интеграционного теста — от подготовки до чистого выхода:

  1. 1 Поднять изолированную БД (контейнер на набор или транзакцию на тест), направленную на выделенную тестовую схему
  2. 2 Засеять детерминированные фикстуры, нужные этому тесту, — и ничего больше
  3. 3 Отправить запрос через request(app).post('/...').send(...) по эфемерному порту
  4. 4 Проверить статус, заголовки и распарсенное тело
  5. 5 Прибраться: откатить транзакцию (или truncate), затем закрыть пул/сервер в teardown
Вспомните перед уходом
  1. 01
    Почему интеграционный тест ловит баги, до которых юнит структурно не дотягивается, и где он стоит в пирамиде тестов?
  2. 02
    Сравни четыре стратегии тестовой БД и скажи, когда какая верна.
Итог

Интеграционный тест прогоняет роутинг, цепочку middleware, хендлер и обычно реальную базу вместе через HTTP-поверхность, ловя баги проводки и контракта — порядок middleware, забытый await, неверный статус-код, потерянное поле, — которые юнит-тесты структурно не видят, потому что зовут хендлер в изоляции. Он стоит выше юнитов и ниже E2E в пирамиде: их меньше, чем юнитов, и больше, чем у медленного, флейкового end-to-end слоя. request(app) из supertest поднимает твоё приложение Express/Fastify/http на эфемерном, выбранном ОС порту (или in-process), делает настоящий HTTP-вызов и сносит — так что ты никогда не управляешь портом и проверяешь истинный статус, заголовки и тело. Выбор базы решает, говорит ли набор правду: rollback на транзакцию-на-тест самый быстрый с сильной изоляцией, но не тестирует логику коммита; truncate/сброс проще и медленнее; Testcontainers даёт наивысшую достоверность, гоняя тот же Postgres/Redis, что в проде, за пару секунд старта; а in-memory SQLite быстра, но это другой движок, чья зелёная сборка может прятать специфичный для Postgres SQL, ломающийся в проде. Держи каждый тест независимым — детерминированные фикстуры, выделенная тестовая БД/схема, отдельные схемы на параллельного воркера — и всегда закрывай сервер и пул соединений в teardown, иначе живые хендлы повесят раннер. Два отказа из хука — баг проводки, до которого не дотягивались юниты, и флейк общей БД — это ровно та пара, которую предотвращает эта дисциплина. Теперь, когда увидишь интеграционный тест, проходящий локально, но падающий в CI, первый вопрос — изоляция: тест бьёт в общую БД, не закрыт хендл, или пропущен teardown?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.