Опирается на правила: NODETEST-12NODETEST-14 из Node Test Strategy → раздел 4. Fluent builders.

Важно знать

  • На каждую таблицу — отдельный builder с with*()-методами и build().
  • Разумные дефолтыuuid(), UTC-время, статус по умолчанию; в тесте перезаписываем только поля, важные для сценария.
  • build() всегда возвращает новый объект — один builder можно вызвать несколько раз без мутации.
  • Сравнение времени с БД — PostgreSQL timestamptz хранит микросекунды, JS Date — миллисекунды; усекать через .toISOString() или .getTime(), не сравнивать Date-объекты «как есть».
  • Builder — обычный класс, без декораторов NestJS и DI; создаётся через new OrderBuilder().
  • Без builder-а каждый тест дублирует 10–15 полей вставки — важные различия тонут в шуме.
  • Используется в Arrange-фазе совместно с DatabasePreparer: builder строит объект, preparer вставляет его в БД.

OrderBuilder, CustomerBuilder, ProductBuilder — builder-ы тестовых данных. Без них каждый тест содержит длинный инициализирующий объект на 10–15 полей: важное поле (status, customerId) теряется среди boilerplate. Builder оставляет виден только тот аспект, который тест проверяет.

Структура builder-а

NODETEST-12: на каждую таблицу — builder с with*()-методами и build().

import { randomUUID } from 'crypto';

export interface OrderRow {
  id: string;
  customerId: string;
  status: string;
  totalAmount: number;
  createdAt: Date;
  updatedAt: Date;
}

export class OrderBuilder {
  private id: string = randomUUID();
  private customerId: string = randomUUID();
  private status: string = 'DRAFT';
  private totalAmount: number = 10000;
  private createdAt: Date = new Date('2026-01-15T10:00:00.000Z');
  private updatedAt: Date = new Date('2026-01-15T10:00:00.000Z');

  withId(id: string): this {
    this.id = id;
    return this;
  }

  withCustomerId(customerId: string): this {
    this.customerId = customerId;
    return this;
  }

  withStatus(status: string): this {
    this.status = status;
    return this;
  }

  withTotalAmount(amount: number): this {
    this.totalAmount = amount;
    return this;
  }

  withCreatedAt(createdAt: Date): this {
    this.createdAt = createdAt;
    return this;
  }

  build(): OrderRow {
    return {
      id: this.id,
      customerId: this.customerId,
      status: this.status,
      totalAmount: this.totalAmount,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
    };
  }
}

Что важно:

  • Обычный TypeScript-класс, без @Injectable() и DI. Создаётся через new OrderBuilder().
  • Поля с дефолтами — в конструкторе выставлены разумные значения, метод with*() возвращает this.
  • build() собирает объект из текущих полей. Если вызвать дважды — два независимых объекта.
  • Фиксированное время в дефолте — не new Date(), а литерал. Это предотвращает расхождение между тестами, запущенными в разное время.

Разумные дефолты

NODETEST-13: в тесте перезаписываем только поля, важные для сценария.

it('confirm order when DRAFT — returns 200', async () => {
  const orderId = '11111111-1111-1111-1111-111111111111';
  const customerId = 'cust-sberprime-001';

  const order = new OrderBuilder()
    .withId(orderId)
    .withStatus('DRAFT')
    .build();

  await preparer.createOrder(order).prepare();

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

  expect(response.body.status).toBe('CONFIRMED');
});

customerId, totalAmount, createdAt — не указаны, берутся дефолты. Тест читается: «есть заказ в статусе DRAFT с известным id». Всё остальное — шум, который builder скрывает.

Сравнение с вариантом без builder-а:

// без builder-а — 10+ строк шума в каждом тесте
const order = {
  id: orderId,
  customerId: 'some-customer',
  status: 'DRAFT',
  totalAmount: 10000,
  createdAt: new Date(),
  updatedAt: new Date(),
};
await dataSource.query(
  `INSERT INTO orders (id, customer_id, status, total_amount, created_at, updated_at)
   VALUES ($1, $2, $3, $4, $5, $6)`,
  [order.id, order.customerId, order.status, order.totalAmount, order.createdAt, order.updatedAt],
);

Builder убирает десять строк вставки, оставляя два поля, которые меняют поведение системы.

Сравнение времени с PostgreSQL

NODETEST-14: PostgreSQL timestamptz хранит микросекунды, JS Date — миллисекунды. Сравнивать нужно с учётом точности.

Проблема:

Вставляем:  2026-01-15T10:00:00.000Z     ← Date в JS (ms)
В PG:       2026-01-15T10:00:00.000000+00 ← PG хранит мкс
Читаем:     2026-01-15T10:00:00.000000Z   ← pg-драйвер вернёт Date

Если в тесте сохраняем через new Date() и сравниваем объекты === или expect(actual).toEqual(expected) — часто работает. Но стоит добавить к дефолту new Date() вместо фиксированного литерала — расхождение гарантировано, потому что PG возвращает объект Date с нулевой субмиллисекундной частью, а new Date() может иметь субмиллисекунды (в зависимости от среды).

Безопасное сравнение — через ISO-строку:

const row = await preparer.findOrder(orderId);

expect(row.created_at.toISOString()).toBe('2026-01-15T10:00:00.000Z');

Или через getTime() после усечения:

const truncateToMs = (d: Date) => d.getTime();

expect(truncateToMs(row.created_at)).toBe(truncateToMs(expectedDate));

Либо — хранить дефолт в builder-е как фиксированный литерал без субмиллисекундной части:

private createdAt: Date = new Date('2026-01-15T10:00:00.000Z');

Тогда toISOString() вернёт ровно то, что было вставлено.

Опасный паттерн:

// Плохо — new Date() в builder-е создаёт разные значения на каждый build()
private createdAt: Date = new Date();

// Плохо — сравниваем объекты Date через toEqual; Jest сравнивает getTime(),
// но если PG-драйвер добавляет субмиллисекундную часть — flaky
expect(row.created_at).toEqual(new Date());

Builder + DatabasePreparer

NODETEST-12 / NODETEST-9: builder строит объект, препарер вставляет его в БД. Связка видна в Arrange-фазе:

it('cancel order when CONFIRMED — returns 200 and writes outbox', async () => {
  const orderId = '22222222-2222-2222-2222-222222222222';
  const customerId = 'cust-sber-002';

  const customer = new CustomerBuilder()
    .withId(customerId)
    .build();

  const order = new OrderBuilder()
    .withId(orderId)
    .withCustomerId(customerId)
    .withStatus('CONFIRMED')
    .build();

  await preparer
    .clearOrders()
    .clearCustomers()
    .createCustomer(customer)
    .createOrder(order)
    .prepare();

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

  expect(response.body.status).toBe('CANCELLED');

  const outbox = await preparer.findOutboxEvents('OrderCancelled');
  expect(outbox).toHaveLength(1);
  expect(outbox[0].payload.orderId).toBe(orderId);
});

Arrange-фаза читается сверху вниз: очистка → данные для контекста (Customer) → данные для сценария (Order). Builder выделяет поля, влияющие на поведение: status: 'CONFIRMED'.

Несколько builder-ов в одном тесте

Когда сценарий проверяет поведение при нескольких объектах:

it('create order when product out of stock — returns 409', async () => {
  const productId = 'prod-laptop-001';
  const customerId = 'cust-sberprime-003';

  const product = new ProductBuilder()
    .withId(productId)
    .withStock(0)
    .build();

  const customer = new CustomerBuilder()
    .withId(customerId)
    .build();

  await preparer
    .clearOrders()
    .clearProducts()
    .clearCustomers()
    .createCustomer(customer)
    .createProduct(product)
    .prepare();

  await request(app.getHttpServer())
    .post('/v1/orders')
    .set('Authorization', `Bearer ${customerToken(customerId)}`)
    .send({ productId, quantity: 1 })
    .expect(409);
});

Каждый builder — своя переменная. Имя переменной (product, customer) читается как «в БД есть продукт с нулевым stock и покупатель».

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

АнтипаттернПравилоЧто взамен
Объект-литерал с 10+ полями прямо в тестеNODETEST-12builder с разумными дефолтами
new Date() в дефолтах builder-аNODETEST-13фиксированный UTC-литерал new Date('...')
expect(row.created_at).toEqual(new Date())NODETEST-14сравнение через .toISOString()
Один builder на все таблицыNODETEST-12отдельный builder на каждую таблицу/сущность
build() мутирует и возвращает тот же объектNODETEST-12каждый build() — новый объект
Builder как @Injectable() с DINODETEST-13plain TypeScript класс, new ProductBuilder()
Прямой INSERT в теле теста вместо builder+preparerNODETEST-12builder → preparer.create*(...)prepare()
withX(undefined) для сброса поля к дефолтуNODETEST-13отдельный метод withoutX() или не вызывать withX()

Куда дальше

  • node/database-preparer — куда передаётся результат build().
  • node/one-test — полный пример теста с builder в Arrange-фазе.
  • node/basics — принципы интеграционного теста и детерминизм.
  • PG schema — типы и точностьtimestamptz и почему миллисекунды.