Интеграционное тестирование с реальной базой
Моки проверяют твои допущения; настоящая база проверяет реальность. Интеграционные тесты гоняют код против одноразового Postgres (Testcontainers), ловя ограничения, каскады и дрифт миграций, которые мок структурно не видит, — ценой пер-тестового rollback и секунд времени.
Юнит-сьют был сплошной стеной зелёного. Каждый 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. Это самый быстрый вариант — но когда эта стратегия молча ломается?
- 01Какой класс багов интеграционный тест против реальной базы ловит, а замоканный юнит-тест структурно не может, и почему?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.