Опирается на правила:
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;
}
Чистит одну таблицу. Не TRUNCATE — DELETE достаточно быстр для тестовых объёмов и не требует прав 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: порядок методов в цепочке = порядок исполнения.
Схема: customers ← orders (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 в тестовом конфиге DataSource | NODETEST-X3 | миграции один раз в globalSetup |
dataSource.dropDatabase() перед каждым тестом | NODETEST-10 | DELETE через 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-структура, которую повторяет препарер.