Опирается на правила: NODETEST-19, NODETEST-20, NODETEST-21, NODETEST-22, NODETEST-X4 из Node Test Strategy → раздел 6. Kafka, Redis, async — по умолчанию НЕТ.

Важно знать

  • Kafka не поднимаем в integration-тестах — проверяем Outbox-таблицу напрямую через SQL или DatabasePreparer.
  • Redis не поднимаем — конфиг integration-test отключает кеш (in-memory/none).
  • Idempotent consumer тестируется как NestJS-провайдер: await handler.handle(testEvent), без брокера и microservices-транспорта.
  • Outbox-relay и @Interval-джобы вызываются в тесте явно — не ждём фонового воркера.
  • Testcontainers Kafka в базовом integration-тесте запрещён — раздувает время, превращает тест в smoke.
  • Цель — детерминированные, быстрые тесты без async-индетерминизма.
  • Если Kafka всё же нужна — отдельный jest-проект с тегом e2e, отдельный CI-этап.

Главный принцип UCP-тестов в Node: синхронность. Каждое async-добавление в тест (Kafka-контейнер, Redis-контейнер, polling-ожидание) добавляет неопределённость, замедление и нестабильность. Вместо этого — Outbox-таблица, прямые вызовы провайдеров и конфигурационное отключение кеша.

Kafka — не поднимаем

NODETEST-19: Outbox-проверка вместо Kafka consumer.

В production-коде handler пишет событие в outbox:

@Injectable()
export class ConfirmOrderHandler {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly outboxRepository: OutboxRepository,
    @Inject(CLOCK) private readonly clock: Clock,
    @Inject(UUID_PROVIDER) private readonly uuidProvider: UuidProvider,
  ) {}

  async handle(command: ConfirmOrderCommand): Promise<Order> {
    const order = await this.orderRepository.findById(command.orderId);
    if (!order) throw new OrderNotFoundException(command.orderId);

    order.confirm();
    await this.orderRepository.save(order);

    await this.outboxRepository.append({
      id: this.uuidProvider.generate(),
      aggregateType: 'Order',
      aggregateId: order.id,
      eventType: 'OrderConfirmed',
      payload: { orderId: order.id, customerId: order.customerId },
      occurredAt: this.clock.now(),
    });

    return order;
  }
}

В тесте — не поднимаем Kafka, не ждём outbox-relay. Проверяем строку в outbox сразу после HTTP-вызова:

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

  // Arrange
  await preparer
    .createCustomer({ id: 'c1', email: 'customer@sber.ru' })
    .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, payload FROM outbox WHERE aggregate_id = $1',
    [orderId],
  );
  expect(outbox).toHaveLength(1);
  expect(outbox[0].event_type).toBe('OrderConfirmed');
  expect(outbox[0].payload.orderId).toBe(orderId);
});

Что это даёт:

  • Синхронность — строка в outbox создана внутри транзакции, видна сразу после commit.
  • Полная проверка — что именно попадёт в Kafka: event_type, payload, aggregate_id.
  • Скорость — не нужно ждать Kafka producer flush или consumer poll.

Что не проверяется: что Kafka доставила сообщение до consumer-а на другом сервисе. Это другой уровень — E2E (см. NODETEST-28).

Redis — не поднимаем

NODETEST-20: конфигурационное отключение кеша.

В config/integration-test.yaml (или env-файле):

cache:
  driver: none

Или через ConfigService-override в тестовой фабрике модуля:

moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideProvider(ConfigService)
  .useValue({
    get: (key: string) => {
      if (key === 'cache.driver') return 'none';
      return realConfig.get(key);
    },
  })
  .compile();

CacheModule с драйвером none (или store: 'memory' с TTL 0) обходит Redis полностью. @CacheInterceptor-декорированные методы выполняются как обычные методы без кеша — тест проверяет бизнес-логику, не поведение кеша.

Если нужно специально проверить кеш (cache-aside, invalidation) — отдельный тест с явным in-memory store:

beforeAll(async () => {
  moduleRef = await Test.createTestingModule({ imports: [AppModule] })
    .overrideModule(CacheModule)
    .useModule(CacheModule.register({ store: 'memory', ttl: 60 }))
    .compile();
  app = moduleRef.createNestApplication();
  await app.init();
});

Большинство integration-тестов не трогают кеш — cache.driver=none в дефолте тест-конфига.

Idempotent consumer — прямой вызов

NODETEST-21: handler как обычный NestJS-провайдер.

В production NestJS microservice:

@Controller()
export class PaymentEventsController {
  constructor(private readonly handler: PaymentEventHandler) {}

  @EventPattern('payment.charged')
  async onPaymentCharged(@Payload() data: PaymentChargedEvent): Promise<void> {
    await this.handler.handle(data);
  }
}

@Injectable()
export class PaymentEventHandler {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly processedEventRepository: ProcessedEventRepository,
  ) {}

  async handle(event: PaymentChargedEvent): Promise<void> {
    const alreadyProcessed = await this.processedEventRepository.exists(event.eventId, 'order-payment');
    if (alreadyProcessed) return;

    const order = await this.orderRepository.findBySagaId(event.sagaId);
    if (!order) throw new OrderNotFoundException(event.sagaId);

    order.applyPayment(event.amount);
    await this.orderRepository.save(order);
    await this.processedEventRepository.markProcessed(event.eventId, 'order-payment');
  }
}

В тесте — не поднимаем Kafka, не используем microservices-транспорт. Достаём PaymentEventHandler как провайдер и вызываем напрямую:

describe('PaymentEventHandler', () => {
  let app: INestApplication;
  let handler: PaymentEventHandler;
  let preparer: OrderDatabasePreparer;

  beforeAll(async () => {
    ({ app, preparer } = await createTestApp());
    handler = app.get(PaymentEventHandler);
  });

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

  it('BR-005: payment charged transitions order to PAID', async () => {
    const orderId = '22222222-2222-2222-2222-222222222222';
    const sagaId = '33333333-3333-3333-3333-333333333333';

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

    const event: PaymentChargedEvent = {
      eventId: '44444444-4444-4444-4444-444444444444',
      sagaId,
      amount: '100.00',
      currency: 'RUB',
      occurredAt: new Date('2026-05-26T10:00:00.000Z'),
    };

    // Act
    await handler.handle(event);

    // Assert
    const [order] = await dataSource.query(
      'SELECT status FROM orders WHERE id = $1',
      [orderId],
    );
    expect(order.status).toBe('PAID');

    const [processed] = await dataSource.query(
      'SELECT event_id FROM processed_event WHERE event_id = $1',
      [event.eventId],
    );
    expect(processed).toBeDefined();
  });

  it('BR-005: duplicate payment event — idempotent, order stays PAID', async () => {
    const orderId = '55555555-5555-5555-5555-555555555555';
    const sagaId = '66666666-6666-6666-6666-666666666666';
    const eventId = '77777777-7777-7777-7777-777777777777';

    // Arrange
    await preparer
      .createCustomer({ id: 'c2', email: 'customer2@sber.ru' })
      .createOrder({ id: orderId, sagaId, status: 'PAID', customerId: 'c2' })
      .createProcessedEvent({ eventId, consumer: 'order-payment' })
      .prepare();

    const event: PaymentChargedEvent = {
      eventId,
      sagaId,
      amount: '100.00',
      currency: 'RUB',
      occurredAt: new Date('2026-05-26T10:00:00.000Z'),
    };

    // Act
    await handler.handle(event);

    // Assert
    const [order] = await dataSource.query(
      'SELECT status FROM orders WHERE id = $1',
      [orderId],
    );
    expect(order.status).toBe('PAID');

    const processed = await dataSource.query(
      'SELECT event_id FROM processed_event WHERE event_id = $1',
      [eventId],
    );
    expect(processed).toHaveLength(1);
  });
});

Без ClientKafka, без KafkaProducer.send(). Просто await handler.handle(event).

Async/Outbox-relay — синхронный вызов

NODETEST-22: переводим relay в синхрон.

Если сервис имеет фоновый воркер, публикующий outbox в Kafka (@Interval-джоба или отдельный процесс), — в тесте не ждём его. Вызываем relay явно:

// production — фоновая джоба
@Injectable()
export class OutboxRelayJob {
  constructor(
    private readonly outboxRepository: OutboxRepository,
    private readonly kafkaProducer: KafkaProducerPort,
  ) {}

  @Interval(5000)
  async relay(): Promise<void> {
    const pending = await this.outboxRepository.findPending(100);
    for (const event of pending) {
      await this.kafkaProducer.publish(event);
      await this.outboxRepository.markPublished(event.id);
    }
  }
}

В тесте — Kafka замокана, relay вызывается вручную:

it('BR-007: outbox relay marks events as published', async () => {
  // Arrange
  await preparer
    .createOutboxEvent({
      id: '88888888-8888-8888-8888-888888888888',
      eventType: 'OrderConfirmed',
      aggregateId: '11111111-1111-1111-1111-111111111111',
      status: 'PENDING',
    })
    .prepare();

  const kafkaProducerMock = { publish: jest.fn().mockResolvedValue(undefined) };

  moduleRef.get<OutboxRelayJob>(OutboxRelayJob);
  const relay = new OutboxRelayJob(
    moduleRef.get(OutboxRepository),
    kafkaProducerMock as unknown as KafkaProducerPort,
  );

  // Act
  await relay.relay();

  // Assert
  const [outboxRow] = await dataSource.query(
    'SELECT status FROM outbox WHERE id = $1',
    ['88888888-8888-8888-8888-888888888888'],
  );
  expect(outboxRow.status).toBe('PUBLISHED');
  expect(kafkaProducerMock.publish).toHaveBeenCalledTimes(1);
});

Ключевое: @Interval-джоба не запускается автоматически в тест-контексте, пока SchedulerRegistry не инициализирован и NestJS не стартует в режиме с планировщиком. Если автоматически запускается — отключаем в конфиге integration-test:

// app.module.ts
ScheduleModule.forRoot() // включается только при process.env.ENABLE_SCHEDULER !== 'false'
// test setup
process.env.ENABLE_SCHEDULER = 'false';

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

АнтипаттернПравилоЧто взамен
Testcontainers Kafka в базовом integration-тестеNODETEST-X4Outbox-проверка через SQL
await new Promise(r => setTimeout(r, ...)) для ожидания consumer-аNODETEST-X1await handler.handle(event) напрямую
Redis-контейнер в integration-тестеNODETEST-20cache.driver=none в конфиге
ClientKafka.send() + polling в тестеNODETEST-X4прямой вызов провайдера
Ждём @Interval-джобу реального таймераNODETEST-22ручной вызов relay.relay()
jest.spyOn(handler, 'handle') в integration-тестеNODETEST-X5реальный handler; мок только для внешней системы
Kafka в globalSetup рядом с PostgresNODETEST-X4отдельный e2e jest-проект

Куда дальше

  • Базовые правила — синхронность как основа всех тестов.
  • DatabasePreparer — clearOutbox(), createOutboxEvent(), createProcessedEvent().
  • Один тест — assert Outbox в Assert-блоке.
  • Внешние HTTP — nock/msw — стабы внешних сервисов.
  • Что НЕ покрывается интеграционными — где Kafka E2E-тест уместен.
  • Kafka → Outbox Publishing — что именно проверяем в тесте.