Опирается на правила: NODETEST-1, NODETEST-2, NODETEST-3, NODETEST-X1, NODETEST-X2 из Node Test Strategy → раздел 1. Базовые правила.

Важно знать

  • Тест должен быть быстрым и детерминированным. setTimeout-ожидание и polling — признак недетерминированного дизайна, не способ «дать событию время».
  • Интеграционный тест = Test.createTestingModule({...}).compile() + app.init() + реальный PostgreSQL (testcontainers-node) + HTTP-вызов через supertest.
  • Внешние HTTP-сервисы (платёж, каталог, логистика) — nock или msw; не трогаем реальные endpoints.
  • Kafka и Redis не поднимаем — конфиг integration-test их отключает; события проверяются через Outbox-таблицу.
  • Время и UUID — кастомные провайдеры с токенами CLOCK и UUID_PROVIDER; в тесте — .overrideProvider(CLOCK).useValue(fixedClock).
  • Один тест — один сценарий, AAA-структура (// Arrange, // Act, // Assert).
  • Jest — дефолт; Vitest допустим, API override провайдеров и fake timers эквивалентны.

UCP-подход к тестам в Node строится вокруг той же идеи, что и в Java: тест быстрый (миллисекунды, не секунды), детерминированный (одинаковый результат при каждом прогоне), простой (один сценарий). Разница в инструментах: вместо @SpringBootTestTest.createTestingModule, вместо @MockitoBean.overrideProvider, вместо Awaitility — его полное отсутствие.

Что входит в интеграционный тест

NODETEST-1: полный стек внутри сервиса, минус внешние системы.

ЧастьГде в тесте
NestJS applicationTest.createTestingModule({...}).compile() + app.init()
PostgreSQLPostgreSqlContainer (testcontainers-node), globalSetup
HTTP-клиентsupertest(app.getHttpServer()).post(...)
Внешние HTTPnock или msw (node-интерсептор)
KafkaНЕ поднимаем; проверяем Outbox-таблицу через DatabasePreparer
RedisНЕ поднимаем; конфиг integration-test отключает кеш
ВремяoverrideProvider(CLOCK).useValue(fixedClock)
UUIDoverrideProvider(UUID_PROVIDER).useValue(fixedUuidProvider)

Это даёт:

  • End-to-end внутри сервиса — от HTTP-запроса до строки в PostgreSQL.
  • Без асинхронных рассинхронизаций — нет ожидания Kafka consumer-а.
  • Время прогона — один тест занимает 30–150 мс; контейнер поднимается один раз в globalSetup.
it('BR-001: confirm order when DRAFT — returns 200 and creates outbox event', async () => {
  const orderId = '11111111-1111-1111-1111-111111111111';
  const now = new Date('2026-05-26T10:00:00.000Z');

  // Arrange
  await preparer.createOrder({ id: orderId, status: 'DRAFT', customerId: 'c1' }).prepare();

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

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

  const outbox = await dataSource.query('SELECT event_type FROM outbox WHERE aggregate_id = $1', [orderId]);
  expect(outbox).toHaveLength(1);
  expect(outbox[0].event_type).toBe('OrderConfirmed');
});

Все тесты детерминированные

NODETEST-2: никаких await new Promise(r => setTimeout(r, ...)), polling-циклов и new Date()/randomUUID() в домене напрямую.

// ПЛОХО — тест зависит от скорости CI-машины
await new Promise(r => setTimeout(r, 500));
const order = await orderRepository.findById(orderId);
expect(order).toBeDefined();

// ПЛОХО — polling скрывает недетерминированный дизайн
let order;
while (!order) {
  order = await orderRepository.findById(orderId);
  await new Promise(r => setTimeout(r, 100));
}

Что плохо:

  • Нестабильно — на медленном CI падает, на быстром проходит; «починка» — увеличить задержку, снова нестабильно.
  • Медленно — каждый setTimeout — потеря сотен миллисекунд; сотня тестов = минуты.
  • Скрывает ошибки — асинхронная проблема «событие запоздало на 80 мс» проходит при задержке 500 мс, но в production клиент уже получил timeout.

Корректно — синхронный поток:

  1. CLOCK через DI-токен → в тесте fixedClock с заданным значением.
  2. UUID_PROVIDER через DI-токен → в тесте фиксированный провайдер.
  3. Outbox-relay вызывается явно в тесте, не ждём фонового @Interval.
  4. Kafka/Redis отключены конфигом — нет ожидания consumer-а.
export const CLOCK = Symbol('CLOCK');
export const UUID_PROVIDER = Symbol('UUID_PROVIDER');

export interface Clock {
  now(): Date;
}

export interface UuidProvider {
  generate(): string;
}
const fixedClock: Clock = { now: () => new Date('2026-05-26T10:00:00.000Z') };
const fixedUuid = '11111111-1111-1111-1111-111111111111';
const fixedUuidProvider: UuidProvider = { generate: () => fixedUuid };

moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideProvider(CLOCK).useValue(fixedClock)
  .overrideProvider(UUID_PROVIDER).useValue(fixedUuidProvider)
  .compile();

NODETEST-X2: прямые вызовы new Date() и randomUUID() в Handler/Service/Aggregate запрещены — делают тест недетерминированным.

Если логика строится на таймерах (setInterval, setTimeout внутри сервиса) — в тесте jest.useFakeTimers() + jest.advanceTimersByTime(ms), не реальное ожидание.

AAA-структура

NODETEST-3: один тест — один сценарий, три блока с пустой строкой между ними.

it('BR-ORDER-7: cancel order when CANCELLED — returns 409', async () => {
  const orderId = '22222222-2222-2222-2222-222222222222';

  // Arrange
  await preparer
    .createCustomer({ id: 'c1', email: 'customer@sber.ru' })
    .createOrder({ id: orderId, status: 'CANCELLED', customerId: 'c1' })
    .prepare();

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

  // Assert
  expect(response.status).toBe(409);
  expect(response.body.code).toBe('ORDER_ALREADY_CANCELLED');
});

Каждый блок — одна ответственность:

  • Arrange — очистка БД (через DatabasePreparer), создание фикстур, настройка nock-стабов.
  • Act — один HTTP-вызов через supertest; не несколько, не «создать и сразу проверить».
  • Assert — проверки ответа и побочных эффектов (строки в БД, Outbox-события).

Не «проверить весь жизненный цикл в одном тесте»

// ПЛОХО — мега-тест проверяет 5 переходов состояния
it('order lifecycle', async () => {
  // создать заказ
  // подтвердить
  // оплатить
  // отгрузить
  // отменить
});

Что плохо:

  • Если падает шаг 3 — неизвестно, что не так с шагами 4–5.
  • Имя теста не описывает ни одного конкретного сценария.
  • Нельзя запустить отдельный сценарий через --testNamePattern.

Корректно — отдельные it:

it('BR-001: create order when valid — returns 201', async () => { ... });
it('BR-002: confirm order when DRAFT — returns 200', async () => { ... });
it('BR-003: pay order when CONFIRMED — returns 200', async () => { ... });
it('BR-ORDER-7: cancel order when CANCELLED — returns 409', async () => { ... });

Имена тестов

NODETEST-16: формат '<action> when <condition> — <expected>' внутри it(...).

it('create order when valid — returns 201', async () => { ... });
it('create order when customer not found — returns 404', async () => { ... });
it('confirm order when DRAFT — returns 200 and creates outbox event', async () => { ... });
it('confirm order when RESERVATION_FAILED — returns 409', async () => { ... });
it('get order when not found — returns 404', async () => { ... });

BR-код цитируется в начале имени теста — связывает его со спецификацией:

describe('POST /v1/orders/:id/confirm', () => {
  it('BR-002: confirm order when DRAFT — returns 200', async () => { ... });
  it('BR-007: confirm order when RESERVATION_FAILED — returns 409', async () => { ... });
});

describe-блок отражает endpoint или вариант использования; it — конкретный сценарий с BR-кодом.

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

АнтипаттернПравилоЧто взамен
await new Promise(r => setTimeout(r, ...)) в тестеNODETEST-X1синхронный flow, overrideProvider(CLOCK)
Polling-цикл while (!result)NODETEST-X1вызвать relay/worker явно
new Date() в Handler/Service/Aggregate напрямуюNODETEST-X2токен CLOCK, override в TestingModule
randomUUID() в Handler/Service/Aggregate напрямуюNODETEST-X2токен UUID_PROVIDER, override в TestingModule
Один it на весь жизненный цикл заказаNODETEST-3один it = один сценарий
Kafka-контейнер в базовом integration-тестеNODETEST-X4Outbox-таблица через DatabasePreparer
it без BR-кода в имениNODETEST-16'BR-NNN: ...' в начале имени
Мок бизнес-логики (Handler/Aggregate) через jest.mock() в integration-тестеNODETEST-X5мок только для внешней системы (nock/msw)

Куда дальше

  • TestingModule — платформенный setup — globalSetup, контейнер, фабрика модуля.
  • DatabasePreparer — fluent setup БД: clear*, create*, prepare().
  • Fluent builders — билдеры сущностей с дефолтами.
  • Один тест — структура, имена, supertest, successToken.
  • Kafka, Redis, async — по умолчанию НЕТ — Outbox-подход.
  • Пирамида тестов — что чем покрывать: unit агрегата, unit контроллера, интеграционный, E2E.