← назад к разделу

Мок репозитория не выполняет SQL, SQLite не воспроизводит типы и блокировки PostgreSQL, а synchronize создает другую схему, нежели production-миграции. Интеграционный тест нужен именно там, где цена ошибки живет между компонентами: QueryBuilder и база, HTTP и транзакция, миграция и сущность.

Что проверять на настоящей базе

  • SQL и параметры QueryBuilder;
  • маппинг numeric, bigint, jsonb и timestamptz;
  • UNIQUE, foreign keys и поведение при COMMIT;
  • optimistic и pessimistic locking;
  • транзакционную запись заказа и outbox;
  • миграции на чистой и непустой базе;
  • полный HTTP-путь через pipes, guards и serialization.

Чистую доменную логику тащить в такой тест не нужно. Она проверяется без Nest и Docker и остается быстрой.

PostgreSQL один раз на набор

import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { DataSource } from 'typeorm';

let postgres: StartedPostgreSqlContainer;
let dataSource: DataSource;

beforeAll(async () => {
  postgres = await new PostgreSqlContainer('postgres:16-alpine')
    .withDatabase('shop')
    .withUsername('shop')
    .withPassword('shop')
    .start();

  dataSource = new DataSource({
    type: 'postgres',
    url: postgres.getConnectionUri(),
    entities: [OrderEntity, OutboxEventEntity],
    migrations: [CreateOrders1710000000000],
    migrationsRun: true,
    synchronize: false,
  });
  await dataSource.initialize();
}, 60_000);

afterAll(async () => {
  await dataSource.destroy();
  await postgres.stop();
});

beforeAll убирает повторный старт контейнера перед каждым тестом. getConnectionUri передает фактический адрес и случайный порт в TypeORM. Приложение не должно продолжать читать production DATABASE_URL: конфигурация тестового модуля формируется из этого URI.

В большом проекте общий setup выносится в один базовый модуль, а не копируется в каждый spec. Если Jest workers выполняются параллельно, каждый получает отдельную базу или схему. Общий контейнер с общими данными и независимыми workers не сочетается без дополнительной изоляции.

Миграции вместо synchronize

Пустой контейнер не содержит ваших таблиц. Их создают те же миграции, что поедут в production. synchronize: true делает схему по текущим entities и позволяет тесту пройти даже со сломанной или пропущенной миграцией.

Не создавайте второй тестовый DDL-файл для скорости. Через несколько изменений он начнет описывать другую базу. Миграции запускаются один раз после старта PostgreSQL, а справочные данные из них сохраняются при очистке.

Отдельный сценарий миграций проверяет не только пустую базу:

  1. восстановить предыдущую схему и реалистичные обезличенные данные;
  2. применить новые миграции с ограничением времени и блокировок;
  3. проверить старый код на новой схеме, потому что rolling update и откат создают именно такую комбинацию;
  4. проверить повторный запуск и поведение при неуспехе.

Изоляция данных

Уникальные ключи по умолчанию

it('возвращает только заказы своего покупателя', async () => {
  const customerId = `customer-${randomUUID()}`;
  await orders.create({ customerId, lines: [line()] });
  await orders.create({ customerId: `customer-${randomUUID()}`, lines: [line()] });

  const result = await orders.ofCustomer(customerId);

  expect(result).toHaveLength(1);
  expect(result[0].customerId).toBe(customerId);
});

Тест не предполагает пустую таблицу и не мешает соседям. Любой count() ограничен собственным ключом. Этот способ лучше всего переносит параллельный прогон.

Откат транзакции для репозитория

TypeORM QueryRunner можно передать адаптеру и откатить транзакцию после теста. Это работает, только если весь проверяемый код использует тот же manager. Сценарий, который внутри открывает собственную транзакцию через другой DataSource, выйдет за границу тестовой.

Откат не проверяет поведение при фиксации и не подходит для HTTP-запроса, обработчик которого берет соединения из общего пула.

Очистка после commit

Для сквозных сценариев данные фиксируются по-настоящему. После теста таблицы очищают одним TRUNCATE ... RESTART IDENTITY CASCADE, исключая историю миграций и справочники. Один запрос на все таблицы устраняет проблему порядка foreign keys.

Глобальная очистка небезопасна при параллельных тестах на одной базе: один сценарий удалит данные другого. Там используют уникальные ключи или отдельные схемы.

HTTP до базы и обратно

beforeAll(async () => {
  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(DATA_SOURCE)
    .useValue(dataSource)
    .compile();

  app = moduleRef.createNestApplication();
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.init();
});

afterAll(async () => {
  await app.close();
});

it('создает заказ и фиксирует его в PostgreSQL', async () => {
  const customerId = `customer-${randomUUID()}`;

  await request(app.getHttpServer())
    .post('/orders')
    .send({ customerId, lines: [{ sku: 'A1', quantity: 2 }] })
    .expect(201);

  const stored = await dataSource.getRepository(OrderEntity).findOneBy({ customerId });
  expect(stored).toMatchObject({ customerId, status: 'new' });
});

Проверка одного 201 недостаточна: сценарий мог не зафиксировать транзакцию. Проверка базы без HTTP не видит pipes и guards. Сквозной тест соединяет оба утверждения, а app.close() не оставляет Jest открытые handles.

Асинхронные эффекты без сна

Фиксированный setTimeout платит время на каждом прогоне и все равно мигает под нагрузкой. Ждут условие с потолком:

async function eventually(check: () => Promise<void>, timeoutMs = 5000): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (true) {
    try {
      await check();
      return;
    } catch (error) {
      if (Date.now() >= deadline) throw error;
      await new Promise(resolve => setTimeout(resolve, 50));
    }
  }
}

await eventually(async () => {
  expect(await published.countByOrder(orderId)).toBe(1);
});

Если асинхронность не является предметом теста, лучше вызвать relay.publishPending() напрямую или подставить планировщик, который выполняет задачу сразу. Проверка становится детерминированной без polling.

Мигающие тесты

Основные источники: общее состояние, реальный Date.now(), случайные идентификаторы без печати seed, свободные порты, выбранные вручную, незакрытые серверы и ожидание времени вместо условия. Случайный порядок тестов помогает находить скрытые зависимости.

Не закрывайте проблему retry в Jest. Мигающий тест получает владельца и срок исправления. Повторный запуск полезен для диагностики частоты, но не делает систему надежнее.

Коротко

  • PostgreSQL и миграции поднимаются один раз на набор, данные изолируются отдельно.
  • synchronize не заменяет production-миграции.
  • Уникальные ключи лучше общего состояния и параллельного TRUNCATE.
  • HTTP-тест проверяет и ответ, и зафиксированный результат.
  • Асинхронный эффект ждут по условию или вызывают напрямую, а не спят.
  • Все DataSource, приложение, серверы и контейнеры закрываются в afterAll.

Что почитать дальше