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

Интеграционное тестирование с реальной базой

Моки проверяют твои допущения; настоящая база проверяет реальность. Интеграционные тесты гоняют код против одноразового Postgres (Testcontainers), ловя ограничения, каскады и дрифт миграций, которые мок структурно не видит, — ценой пер-тестового rollback и секунд времени.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Юнит-сьют был сплошной стеной зелёного. Каждый repository замокан, каждый тест сервиса бежал за единицы миллисекунд, покрытие — 94%. А потом продовый INSERT рванул на ограничении UNIQUE, которое мок никогда не проверял: мок радостно возвращал «сохранённого» пользователя для уже существующего email, потому что мок возвращает ровно то, что ты ему велел. Хуже: ON DELETE CASCADE на внешнем ключе молча снёс дерево дочерних строк, когда админ удалил одного родителя, — ни один юнит-тест этого не проверял, ведь у мока нет понятия внешних ключей. Оба бага жили в зазоре между тем, что код предполагает о базе, и тем, что база делает на самом деле, — а этот зазор моку невидим по построению. Потом команда перегнула в другую сторону: интеграционный сьют без изоляции, где все тесты делят одну изменяемую базу, стал зависеть от порядка и расфлакался так, что блокировал CI два дня. Этот урок — про средний путь: тестировать против настоящего Postgres, изолировать каждый тест и держать всё достаточно быстрым, чтобы жить в CI.

Где гнётся пирамида: что ловит только настоящая база

Спроси себя: что на самом деле тестирует твой сьют с 94% покрытием на моках? Он тестирует, что твой код согласован с твоими допущениями о базе — не то, что база ведёт себя именно так. Вот конкретный класс багов, который живёт в этом зазоре.

Юнит-тесты (замоканные зависимости, ~1мс каждый, их много) проверяют твою логику в изоляции. Интеграционные проверяют, что твой код плюс настоящий коллаборатор — база — действительно работают вместе. В этом различии вся суть: мок возвращает то, что ты в него запрограммировал, поэтому он способен подтвердить лишь те допущения, которые ты уже держишь в голове. А баги, доходящие до прода из слоя данных, — ровно те, что твои допущения упустили:

  • Нарушения ограниченийUNIQUE, NOT NULL, CHECK, внешние ключи. Замоканный repo будет «сохранять» дубликат email сколько угодно; Postgres поднимет 23505.
  • Правила каскадовON DELETE CASCADE / SET NULL переформовывают строки, которых ты явно не касался. Ни один мок не моделирует ссылочное действие.
  • Дрифт миграций — миграция, которая не прогналась, колонка, всё ещё varchar(50), отсутствующий индекс. У мока нет схемы, с которой можно дрифтовать.
  • Реальный маппинг ORM — колонки JSONB, enum-типы, точность numeric, последовательности, snake_case ↔ camelCase. Настоящая генерация SQL у ORM, а не твоя мысленная модель её.
  • Поведение транзакций и изоляции — что rollback действительно откатывает, что блокировка действительно блокирует.

Цена реальна: интеграционным тестам нужен настоящий Postgres, и они бегут за ~10–100мс+ (реальный IO) вместо ~1мс, а старт контейнера стоит ~1–3с один раз на сьют. Поэтому их пишут сильно меньше, чем юнитов, — пирамида остаётся пирамидой. Ты не заменяешь юнит-тесты; ты добавляешь тонкий высокоценный слой, утверждающий то, чего моки не могут.

Почему не замоканный repo и почему не SQLite

Два соблазнительных срезанных угла проваливаются одинаково — дают зелёные тесты, которые врут:

// Мок утверждает ТВОЁ ДОПУЩЕНИЕ, а не реальность.
const repo = { save: jest.fn().mockResolvedValue({ id: 1, email: 'a@b.com' }) };
// Это "проходит", даже если email a@b.com уже есть в проде и дал бы 23505.
// Мок не может обеспечить UNIQUE, не может каскадить, не может прогнать миграцию.

Замоканный repository не поймает нарушение уникальности, не запустит FK-каскад, не вскроет N+1 и не заметит миграцию, которая не прогналась, — потому что он и есть твоё допущение, закодированное. SQLite-как-фейк — ловушка тоньше: это настоящая база, поэтому она кажется честной, но это другой движок, не тот, что в проде. Его аффинность типов, обработка JSONB, семантика последовательностей и поведение изоляции — всё расходится с Postgres. Ты получаешь тесты, зелёные на SQLite и красные в проде, — худший возможный исход, ведь ты им доверял. Сеньорская позиция однозначна: интеграционные тесты бегут на том же движке и мажорной версии, что и прод. Если прод — Postgres 16, контейнер теста — Postgres 16.

Testcontainers: одноразовый Postgres на прогон тестов

@testcontainers/postgresql (библиотека, которая поднимает Docker-контейнеры из кода теста и сносит их после) поднимает настоящий одноразовый Postgres в Docker на время прогона и отдаёт тебе динамический URL подключения. Ты скармливаешь этот URL в TypeOrmModule.forRoot в тестовом модуле, прогоняешь против него настоящие миграции (никогда synchronize: true — это пропускает те самые миграции, которые ты должен протестировать), выполняешь сьют и сносишь контейнер. Один beforeAll собирает всё:

import { Test } from '@nestjs/testing';
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { UsersModule } from '../src/users/users.module';

let container: StartedPostgreSqlContainer;
let dataSource: DataSource;

beforeAll(async () => {
  // ~1–3с один раз: настоящий Postgres ТОЙ ЖЕ мажорной версии, что и прод
  container = await new PostgreSqlContainer('postgres:16').start();

  const moduleRef = await Test.createTestingModule({
    imports: [
      TypeOrmModule.forRoot({
        type: 'postgres',
        url: container.getConnectionUri(), // динамические host/port — никогда не хардкодь 5432
        entities: [/* … */],
        synchronize: false,                // тестируем НАСТОЯЩИЕ миграции, не авто-синк
        migrationsRun: false,              // мы прогоняем их явно ниже
      }),
      UsersModule,
    ],
  }).compile();

  dataSource = moduleRef.get<DataSource>(getDataSourceToken());
  await dataSource.runMigrations();        // прогоняем тот же SQL, что прогонит прод
}, 60_000);                                // щедрый таймаут: тянуть образ медленно на холодном кэше

afterAll(async () => {
  await dataSource?.destroy();
  await container?.stop();                  // утилизируем одноразовую БД
});

Вызов runMigrations() вместо synchronize: true — несущая деталь: значит, миграция, которая дропает колонку, добавляет ограничение или бэкфилит данные, реально выполняется в тесте, поэтому дрифт миграций всплывает здесь, а не в проде.

Пер-тестовая изоляция: техника, решающая, можно ли доверять сьюту

Тесты не должны видеть данные друг друга. Флакость из Hook пришла из общего изменяемого состояния — один тест вставил строку, другой посчитал строки и получил число, зависящее от порядка тестов. Три стратегии в порядке предпочтения:

// (a) ПРЕДПОЧТИТЕЛЬНО — оборачиваем каждый тест в транзакцию и ROLLBACK после.
//     Быстрее всего: ничего не коммитится, значит и чистить нечего.
let qr;
beforeEach(async () => {
  qr = dataSource.createQueryRunner();
  await qr.connect();
  await qr.startTransaction();             // BEGIN
  // отдаём qr.manager тестируемому коду, чтобы его записи жили в ЭТОЙ tx
});
afterEach(async () => {
  await qr.rollbackTransaction();          // ROLLBACK — каждая запись исчезает
  await qr.release();
});
// (b) НАДЁЖНО — TRUNCATE всех таблиц между тестами. Переживает код, который
//     коммитит свои транзакции, но медленнее (реальные DELETE + перетряска индексов).
afterEach(async () => {
  const tables = dataSource.entityMetadatas.map((m) => `"${m.tableName}"`).join(', ');
  await dataSource.query(`TRUNCATE ${tables} RESTART IDENTITY CASCADE;`);
});

// (c) ПАРАЛЛЕЛЬНО — свежая схема (или база) на каждый ФАЙЛ тестов, чтобы воркеры
//     не делили состояние. Максимум изоляции, максимум настройки; для параллелизма.

Стратегия (a) быстрее всего, потому что откатанная транзакция ничего не коммитит — чистить нечего. Но у неё острый край: она ломается в момент, когда тестируемый код управляет своей транзакцией и коммитит. Если твой сервис открывает свой dataSource.transaction(...) и коммитит внутри, этот коммит сбегает из твоей внешней тестовой транзакции (или вложенный savepoint освобождается), и твой ROLLBACK его больше не отменяет. Для такого кода откатывайся к (b) TRUNCATE. Ловушка, которую надо усвоить: общее изменяемое состояние БД + параллельный раннер = зависящая от порядка флакость. Изолируй на каждый тест или на каждого воркера — никогда не давай двум тестам трогать одни закоммиченные строки.

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

Почему интеграционный тест с реальной БД стоит своей медлительности по сравнению с замоканным repo? Потому что баги, доходящие до прода из слоя данных, — нарушения ограничений, каскадные удаления, дрифт миграций, ошибки ORM-маппинга, сюрпризы изоляции и блокировок — это ровно тот класс, который мок не может смоделировать. Мок возвращает то, что ты ему велел, поэтому он утверждает твоё допущение; настоящая база утверждает реальность, включая те части реальности, где ты ошибся. Нарушение уникальности, каскад, снёсший дочерние строки, миграция, которая не прогналась, — ни одно из них невыразимо против jest.fn(). Ты платишь секундами времени и Docker-контейнером, чтобы купить покрытие единственного класса багов, к которому моки структурно слепы. Это не терпимость к медленным тестам; это единственное место, где такие дефекты можно поймать до прода.

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

Тебе нужно тестировать repository и query-код с настоящей уверенностью — ловя нарушения ограничений, каскады и дрифт миграций до прода. Как настроить тестовую базу?

Викторина

Твой сервис полностью покрыт юнит-тестами с замоканным repository и покрытием 94%, и всё же продовый INSERT падает на ограничении UNIQUE, а админское удаление молча каскадит дочерние строки. Какой класс багов замоканные юнит-тесты структурно не поймали?

Викторина

Для пер-тестовой изоляции ты оборачиваешь каждый тест в транзакцию и делаешь ROLLBACK в afterEach. Это самый быстрый вариант — но когда эта стратегия молча ломается?

Вспомните перед уходом
  1. 01
    Какой класс багов интеграционный тест против реальной базы ловит, а замоканный юнит-тест структурно не может, и почему?
  2. 02
    Пройди настройку интеграции на Testcontainers с пер-тестовой изоляцией: контейнер, миграции, тестовый модуль и стратегию изоляции — включая, когда быстрая ломается.
Итог

Интеграционные тесты занимают изгиб пирамиды тестов: юнит-тесты моками утверждают твою логику в изоляции (~1мс, много), а интеграционные гоняют код против настоящего коллаборатора — базы — чтобы утверждать реальность (~10–100мс, меньше). Мок возвращает то, что ты в него запрограммировал, поэтому подтверждает твои допущения и структурно слеп к классу багов слоя данных: нарушениям ограничений UNIQUE/FK/CHECK, правилам ON DELETE CASCADE, дрифту миграций, реальному ORM/SQL-маппингу (JSONB, enum, последовательности) и поведению транзакций/изоляции. Эта слепота — то, как сьют с 94% покрытия на моках пропустил в прод нарушение UNIQUE и молчаливое каскадное удаление. Не чини это SQLite — другой движок даёт зелёные тесты, которые врут; гоняй на том же движке и мажорной версии, что и прод. Testcontainers (@testcontainers/postgresql) стартует одноразовый Postgres в Docker один раз на сьют (~1–3с), отдаёт тебе динамический URL подключения, на который ты нацеливаешь TypeOrmModule.forRoot, и ты прогоняешь настоящие миграции (runMigrations, никогда synchronize:true), чтобы дрифт падал в тесте. Изолируй каждый тест, иначе сьют станет зависимым от порядка и флакающим: предпочитай оборачивать каждый тест в транзакцию и откатывать в afterEach (быстрее всего — ничего не коммитится, чистить нечего), откатывайся к TRUNCATE между тестами, когда тестируемый код коммитит свою транзакцию (которая сбегает из внешнего rollback), и используй свежую схему на файл при параллелизме. Дисциплина: настоящий движок, настоящие миграции, пер-тестовая изоляция — оплачено секундами, возвращено как тот класс багов, который моки никогда не увидят. Теперь, когда в проде выскочит нарушение UNIQUE на маршруте со «100% покрытием», ты знаешь, куда добавить интеграционный тест — и какую стратегию изоляции выбрать.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.