Опирается на правила: NODETEST-9, NODETEST-10, NODETEST-11, NODETEST-X3 из Node Test Strategy → раздел 3. DatabasePreparer — fluent setup БД.

Важно знать

  • На каждый Bounded Context — свой <Domain>DatabasePreparer, не общий на сервис.
  • Три группы методов: clear*() — удаление строк, create*(...) — вставка, prepare() — запуск очереди.
  • Не пересоздаём схему между тестами — только DELETE нужных таблиц; миграции применяются один раз при старте контейнера.
  • Порядок методов в цепочке = порядок исполнения — методы откладывают операции в очередь, prepare() выполняет.
  • FK-порядок при очистке: зависимые таблицы (order_items) первыми, родительские (orders, customers) последними.
  • FK-порядок при создании: сначала родительская запись (customer), затем дочерняя (order, order_item).
  • clearAll() в beforeEach — гарантирует чистое состояние перед каждым тестом.
  • synchronize: true запрещён — маскирует миграционные ошибки и медленно.

Setup БД в integration-тесте — обычно самая многословная часть: вокруг трёх строк бизнес-логики растут десятки INSERT-ов и DELETE-ов. DatabasePreparer прячет этот шум за читаемым fluent-интерфейсом и делает Arrange-фазу компактной. Один объект на Bounded Context, один paradigm на сервис — никаких прямых dataSource.query(...) в теле теста.

Структура OrderDatabasePreparer

NODETEST-9: один препарер на Bounded Context, содержит ссылку на DataSource.

export class OrderDatabasePreparer {
  private readonly queue: Array<() => Promise<void>> = [];

  constructor(private readonly dataSource: DataSource) {}

  clearOrderItems(): this {
    this.queue.push(() =>
      this.dataSource.query('DELETE FROM order_items'),
    );
    return this;
  }

  clearOrders(): this {
    this.queue.push(() =>
      this.dataSource.query('DELETE FROM orders'),
    );
    return this;
  }

  clearCustomers(): this {
    this.queue.push(() =>
      this.dataSource.query('DELETE FROM customers'),
    );
    return this;
  }

  createCustomer(params: { id: string; email: string }): this {
    this.queue.push(() =>
      this.dataSource.query(
        'INSERT INTO customers(id, email) VALUES($1, $2)',
        [params.id, params.email],
      ),
    );
    return this;
  }

  createOrder(params: {
    id: string;
    customerId: string;
    status: string;
    totalAmount?: number;
  }): this {
    this.queue.push(() =>
      this.dataSource.query(
        `INSERT INTO orders(id, customer_id, status, total_amount, created_at)
         VALUES($1, $2, $3, $4, $5)`,
        [
          params.id,
          params.customerId,
          params.status,
          params.totalAmount ?? 0,
          new Date('2026-05-26T10:00:00.000Z'),
        ],
      ),
    );
    return this;
  }

  createOrderItem(params: {
    id: string;
    orderId: string;
    productId: string;
    amount: number;
  }): this {
    this.queue.push(() =>
      this.dataSource.query(
        'INSERT INTO order_items(id, order_id, product_id, amount) VALUES($1, $2, $3, $4)',
        [params.id, params.orderId, params.productId, params.amount],
      ),
    );
    return this;
  }

  async clearAll(): Promise<void> {
    this.clearOrderItems().clearOrders().clearCustomers();
    await this.prepare();
  }

  async prepare(): Promise<void> {
    for (const op of this.queue) {
      await op();
    }
    this.queue.length = 0;
  }
}

Что важно:

  • Fluent — все методы мутации возвращают this; цепочка читается сверху вниз.
  • Lazy execution — методы добавляют операции в queue, не выполняют сразу; prepare() запускает всё.
  • Прямые SQL-запросы — через dataSource.query(); тест не зависит от entity-слоя и маппингов TypeORM.
  • clearAll() — convenience method; вызывает все clear*() в правильном FK-порядке, затем prepare().

Три группы методов

NODETEST-9: clear / create / prepare.

clear*() — очистка

clearOrderItems(): this {
  this.queue.push(() =>
    this.dataSource.query('DELETE FROM order_items'),
  );
  return this;
}

Чистит одну таблицу. Не TRUNCATEDELETE достаточно быстр для тестовых объёмов и не требует прав TRUNCATE. clearAll() собирает все clear*() в правильном порядке.

create*(...) — вставка

createOrder(params: { id: string; customerId: string; status: string }): this {
  this.queue.push(() =>
    this.dataSource.query(
      `INSERT INTO orders(id, customer_id, status, created_at)
       VALUES($1, $2, $3, $4)`,
      [params.id, params.customerId, params.status, new Date('2026-05-26T10:00:00.000Z')],
    ),
  );
  return this;
}

Принимает плоский объект параметров с разумными дефолтами для незначимых полей (время, суммы). В тесте перезаписываем только то, что важно для сценария — см. fluent builders.

prepare() — запуск

async prepare(): Promise<void> {
  for (const op of this.queue) {
    await op();
  }
  this.queue.length = 0;
}

Выполняет все операции последовательно, затем сбрасывает очередь. await this.queue.reduce(...) — альтернатива; важно сохранить последовательность, не Promise.all (из-за FK).

Не пересоздаём схему

NODETEST-10: только DELETE, не DROP TABLE или synchronize: true.

// ПЛОХО — медленно, маскирует миграционные ошибки
beforeEach(async () => {
  await dataSource.synchronize(true);
});

// ХОРОШО — миллисекунды
beforeEach(async () => {
  await preparer.clearAll();
});

Различия:

  • synchronize(true) — дропает и создаёт таблицы по entity-метаданным; 2–8 секунд на каждый тест; маскирует ошибки в миграциях (schema в тесте расходится с продакшеном).
  • DELETE — пустые таблицы за миллисекунды; миграции применяются один раз при старте PostgreSqlContainer в globalSetup.

NODETEST-X3: synchronize: true в конфиге DataSource для тестов запрещён — по той же причине.

FK-порядок

NODETEST-11: порядок методов в цепочке = порядок исполнения.

Схема: customersorders (FK customer_id) ← order_items (FK order_id).

При очистке — от листьев к корню:

// ПЛОХО — FK violation: orders ссылаются из order_items
await preparer
  .clearOrders()
  .clearOrderItems()
  .prepare();

// ХОРОШО — сначала зависимые
await preparer
  .clearOrderItems()
  .clearOrders()
  .clearCustomers()
  .prepare();

При создании — от корня к листьям:

// ПЛОХО — FK violation: order_id в order_items не существует
await preparer
  .createOrderItem({ id: 'oi-1', orderId: 'o-1', productId: 'p-sber', amount: 2 })
  .createOrder({ id: 'o-1', customerId: 'c-1', status: 'DRAFT' })
  .prepare();

// ХОРОШО — сначала родительская запись
await preparer
  .createCustomer({ id: 'c-1', email: 'customer@sber.ru' })
  .createOrder({ id: 'o-1', customerId: 'c-1', status: 'DRAFT' })
  .createOrderItem({ id: 'oi-1', orderId: 'o-1', productId: 'p-sber', amount: 2 })
  .prepare();

Правило: clear от листьев к корню, create от корня к листьям.

clearAll в beforeEach

Препарер создаётся один раз в beforeAll (или в фабрике setup-хелпера) и передаётся в тест.

describe('POST /v1/orders/:id/confirm', () => {
  let app: INestApplication;
  let preparer: OrderDatabasePreparer;

  beforeAll(async () => {
    const { app: testApp, preparer: p } = await createOrderTestApp();
    app = testApp;
    preparer = p;
  });

  beforeEach(async () => {
    await preparer.clearAll();
  });

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

  it('BR-002: confirm order when DRAFT — returns 200 and creates outbox event', async () => {
    // Arrange
    const orderId = '11111111-1111-1111-1111-111111111111';

    await preparer
      .createCustomer({ id: 'c-sberprime', email: 'buyer@sber.ru' })
      .createOrder({ id: orderId, customerId: 'c-sberprime', status: 'DRAFT' })
      .prepare();

    // Act
    const response = await request(app.getHttpServer())
      .post(`/v1/orders/${orderId}/confirm`)
      .set('Authorization', `Bearer ${successToken()}`);

    // Assert
    expect(response.status).toBe(200);
  });
});

clearAll() в beforeEach гарантирует чистое состояние перед каждым тестом независимо от порядка запуска. В тесте — только дополнительный create*-setup для конкретного сценария.

Фабрика и передача DataSource

Препарер получает DataSource напрямую — тот же экземпляр, что использует сервис. Нет расхождения между тем, что пишет тест, и что читает репозиторий.

export async function createOrderTestApp(): Promise<{
  app: INestApplication;
  preparer: OrderDatabasePreparer;
}> {
  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(CLOCK)
    .useValue({ now: () => new Date('2026-05-26T10:00:00.000Z') })
    .compile();

  const app = moduleRef.createNestApplication();
  await app.init();

  const dataSource = moduleRef.get<DataSource>(DataSource);
  const preparer = new OrderDatabasePreparer(dataSource);

  return { app, preparer };
}

Один DataSource — нет изоляции транзакций между препарером и приложением, что в тестах и нужно: тест видит то, что записал, и приложение тоже.

Пример с Outbox

NODETEST-19: вместо ожидания Kafka проверяем Outbox-таблицу через препарер.

findOutboxEvents(eventType: string): this {
}

async findOutboxByAggregate(aggregateId: string): Promise<Array<{ event_type: string; payload: Record<string, unknown> }>> {
  return this.dataSource.query(
    'SELECT event_type, payload FROM outbox WHERE aggregate_id = $1',
    [aggregateId],
  );
}
it('BR-005: confirm order — creates OrderConfirmed event in outbox', async () => {
  const orderId = '33333333-3333-3333-3333-333333333333';

  // Arrange
  await preparer
    .createCustomer({ id: 'c-ozon', email: 'seller@ozon.ru' })
    .createOrder({ id: orderId, customerId: 'c-ozon', status: 'DRAFT' })
    .prepare();

  // Act
  await request(app.getHttpServer())
    .post(`/v1/orders/${orderId}/confirm`)
    .set('Authorization', `Bearer ${successToken()}`)
    .expect(200);

  // Assert
  const events = await preparer.findOutboxByAggregate(orderId);
  expect(events).toHaveLength(1);
  expect(events[0].event_type).toBe('OrderConfirmed');
  expect(events[0].payload).toMatchObject({ orderId, status: 'CONFIRMED' });
});

Outbox-таблица — прямая замена проверки Kafka-события в интеграционном тесте. Kafka в тесте не поднимается — только Postgres.

Что запрещено

АнтипаттернПравилоЧто взамен
dataSource.query('INSERT ...') прямо в теле тестаNODETEST-9метод create* в препарере
Моковый репозиторий вместо реальной БД в integration-тестеNODETEST-9реальный DataSource, реальный Postgres
synchronize: true в тестовом конфиге DataSourceNODETEST-X3миграции один раз в globalSetup
dataSource.dropDatabase() перед каждым тестомNODETEST-10DELETE через DatabasePreparer.clearAll()
FK-порядок нарушен — clearOrders() перед clearOrderItems()NODETEST-11сначала зависимые таблицы, потом родительские
Promise.all([clearOrders(), clearOrderItems()]) без гарантии порядкаNODETEST-11последовательное выполнение через queue
Один препарер для всего сервисаNODETEST-9отдельный <Domain>DatabasePreparer на Bounded Context
prepare() не вызван — очередь не выполненаNODETEST-9всегда await preparer....prepare() в конце цепочки

Куда дальше

  • Базовые правила — Test.createTestingModule, globalSetup, Testcontainers.
  • Fluent builders — что передавать в create*(...): builder с дефолтами.
  • Один тест — полный пример с Arrange через DatabasePreparer, Act через supertest, Assert по Outbox.
  • Persistence — TypeORM + migrations — схема таблиц и FK-структура, которую повторяет препарер.