Опирается на правила:
NODETEST-12…NODETEST-14из Node Test Strategy → раздел 4. Fluent builders.
Важно знать
- На каждую таблицу — отдельный builder с
with*()-методами иbuild().- Разумные дефолты —
uuid(), UTC-время, статус по умолчанию; в тесте перезаписываем только поля, важные для сценария.build()всегда возвращает новый объект — один builder можно вызвать несколько раз без мутации.- Сравнение времени с БД — PostgreSQL
timestamptzхранит микросекунды, JSDate— миллисекунды; усекать через.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-12 | builder с разумными дефолтами |
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() с DI | NODETEST-13 | plain TypeScript класс, new ProductBuilder() |
Прямой INSERT в теле теста вместо builder+preparer | NODETEST-12 | builder → preparer.create*(...) → prepare() |
withX(undefined) для сброса поля к дефолту | NODETEST-13 | отдельный метод withoutX() или не вызывать withX() |
Куда дальше
- node/database-preparer — куда передаётся результат
build(). - node/one-test — полный пример теста с builder в Arrange-фазе.
- node/basics — принципы интеграционного теста и детерминизм.
- PG schema — типы и точность —
timestamptzи почему миллисекунды.