Опирается на правила:
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-X4 | Outbox-проверка через SQL |
await new Promise(r => setTimeout(r, ...)) для ожидания consumer-а | NODETEST-X1 | await handler.handle(event) напрямую |
| Redis-контейнер в integration-тесте | NODETEST-20 | cache.driver=none в конфиге |
ClientKafka.send() + polling в тесте | NODETEST-X4 | прямой вызов провайдера |
Ждём @Interval-джобу реального таймера | NODETEST-22 | ручной вызов relay.relay() |
jest.spyOn(handler, 'handle') в integration-тесте | NODETEST-X5 | реальный handler; мок только для внешней системы |
Kafka в globalSetup рядом с Postgres | NODETEST-X4 | отдельный e2e jest-проект |
Куда дальше
- Базовые правила — синхронность как основа всех тестов.
- DatabasePreparer —
clearOutbox(),createOutboxEvent(),createProcessedEvent(). - Один тест — assert Outbox в Assert-блоке.
- Внешние HTTP — nock/msw — стабы внешних сервисов.
- Что НЕ покрывается интеграционными — где Kafka E2E-тест уместен.
- Kafka → Outbox Publishing — что именно проверяем в тесте.