Обзор Event-Driven архитектуры

В event-driven архитектуре (EDA) сервисы общаются, продюсируя и потребляя события, а не делая прямые API-вызовы. Когда что-то происходит в Сервисе A (пользователь оформляет заказ), он публикует событие. Другие сервисы реагируют на это событие независимо.

Такой подход обеспечивает слабую связанность, высокую масштабируемость и устойчивость — но вносит сложности тестирования, отсутствующие в синхронных системах: eventual consistency, порядок событий, дублирование доставки и сложные режимы отказов.

Event Sourcing

Вместо хранения текущего состояния сущности, event sourcing хранит последовательность событий, приведших к этому состоянию.

Традиционный подход (на основе состояния):

Заказ #123: status=отправлен, total=99.99, items=[A, B]

Подход event sourcing:

Событие 1: OrderCreated { orderId: 123, items: [A, B] }
Событие 2: PaymentReceived { orderId: 123, amount: 99.99 }
Событие 3: OrderShipped { orderId: 123, trackingNumber: "ABC123" }

Текущее состояние получается воспроизведением всех событий по порядку.

Тестирование Event Sourcing

Тест 1: Реконструкция состояния Дана последовательность событий — проверьте, что текущее состояние aggregate корректно.

test('состояние заказа после создания и оплаты', () => {
  const events = [
    { type: 'OrderCreated', data: { orderId: '123', items: ['A', 'B'], total: 99.99 } },
    { type: 'PaymentReceived', data: { orderId: '123', amount: 99.99 } },
  ];

  const order = replayEvents(new Order(), events);

  expect(order.status).toBe('paid');
  expect(order.total).toBe(99.99);
  expect(order.items).toEqual(['A', 'B']);
});

Тест 2: Порядок событий важен Применение событий в другом порядке должно давать другое состояние (или быть отклонено).

Тест 3: Согласованность snapshot-ов Если система использует snapshot-ы, проверьте, что состояние из snapshot + последующие события равно состоянию при воспроизведении всех событий.

CQRS (Command Query Responsibility Segregation)

CQRS разделяет модель записи (commands) и модель чтения (queries). Commands порождают события, обновляющие write store. Projector обрабатывает события для обновления оптимизированных для чтения представлений.

Command → Write Model → Events → Projector → Read Model

Тестирование CQRS

Тестирование стороны команд:

  • Отправка валидной команды порождает ожидаемые события.
  • Отправка невалидной команды отклоняется с корректной ошибкой.
  • Команды применяют бизнес-правила (нельзя отправить неоплаченный заказ).

Тестирование стороны чтения (projections):

  • После обработки событий read model отражает корректное состояние.
  • Projection-ы корректно обрабатывают события не по порядку.
  • Пересборка projection-ов даёт тот же результат, что и инкрементальные обновления.

Тестирование разрыва согласованности: После успеха команды есть задержка до обновления read model.

Паттерн Saga

Saga-и координируют распределённые транзакции между сервисами. Есть два типа:

Saga на основе хореографии

Каждый сервис слушает события и решает, что делать. Без центрального координатора.

OrderService: OrderCreated →
PaymentService: (слушает) → PaymentProcessed →
InventoryService: (слушает) → StockReserved →
ShippingService: (слушает) → ShipmentCreated

Тестирование хореографии:

  1. Запустите начальное событие и проверьте завершение всей цепочки.
  2. Симулируйте сбой на каждом шаге и проверьте запуск компенсирующих событий.
  3. Проверьте сценарии таймаутов — что происходит, если сервис не отвечает?

Saga на основе оркестрации

Центральный оркестратор координирует шаги saga.

Тестирование оркестрации:

  1. Happy path: Все шаги успешны → saga завершается.
  2. Сбой на каждом шаге: Проверьте корректное выполнение компенсирующих действий.
  3. Crash/перезапуск оркестратора: Проверьте возобновление saga с места остановки.

Тестирование Eventual Consistency

Самая сложная часть тестирования event-driven систем — проверка, что сервисы в итоге достигнут согласованного состояния.

Паттерн Polling

async function waitForConsistency(checkFn, timeout = 10000, interval = 500) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    const result = await checkFn();
    if (result) return result;
    await new Promise(resolve => setTimeout(resolve, interval));
  }
  throw new Error('Превышен таймаут согласованности');
}

test('заказ появляется в read model после создания', async () => {
  await commandBus.send(new CreateOrderCommand({ items: ['A'] }));

  const order = await waitForConsistency(async () => {
    const result = await readModel.getOrder('123');
    return result?.status === 'created' ? result : null;
  });

  expect(order.items).toEqual(['A']);
});

Тестирование идемпотентности

Проверьте, что обработка одного события дважды даёт тот же результат:

test('событие оплаты идемпотентно', async () => {
  const event = { type: 'PaymentReceived', orderId: '123', amount: 99.99 };

  await processEvent(event);
  await processEvent(event); // дублирование доставки

  const payments = await db.getPayments('123');
  expect(payments.length).toBe(1); // не 2
  expect(payments[0].amount).toBe(99.99);
});

Эволюция схемы событий

По мере развития систем схемы событий меняются. Потребители должны обрабатывать как старые, так и новые форматы.

Упражнение: Тестирование event-driven системы

Спроектируйте и выполните тесты для event-driven системы электронной коммерции.

Описание системы

Система состоит из четырёх сервисов, общающихся через события:

OrderService → PaymentService → InventoryService → NotificationService

События:

  1. OrderCreated — публикуется при оформлении заказа
  2. PaymentProcessed — публикуется при подтверждении оплаты
  3. PaymentFailed — публикуется при отклонении оплаты
  4. StockReserved — публикуется при резервировании запасов
  5. StockInsufficient — публикуется при недостатке запасов
  6. OrderCancelled — компенсирующее событие при сбое шага
  7. RefundInitiated — компенсирующее событие при необходимости возврата

Задание 1: Тест Happy Path

Напишите тест, проверяющий полный поток:

  1. Опубликуйте OrderCreated с деталями заказа.
  2. Дождитесь появления PaymentProcessed.
  3. Дождитесь появления StockReserved.
  4. Проверьте, что статус заказа в read model — “confirmed”.
  5. Проверьте, что запасы уменьшились.

Задание 2: Тесты компенсации

Для каждой точки сбоя проверьте корректный откат saga:

Сценарий A: Сбой оплаты

  1. Опубликуйте OrderCreated.
  2. PaymentService публикует PaymentFailed.
  3. Проверьте публикацию OrderCancelled.
  4. Проверьте статус заказа “cancelled” в read model.

Сценарий B: Недостаточно запасов

  1. Опубликуйте OrderCreated.
  2. PaymentService публикует PaymentProcessed.
  3. InventoryService публикует StockInsufficient.
  4. Проверьте публикацию RefundInitiated (компенсация оплаты).
  5. Проверьте публикацию OrderCancelled.

Задание 3: Тест идемпотентности

  1. Опубликуйте одно и то же событие OrderCreated дважды (симуляция дублирования).
  2. Проверьте, что в базе создан только один заказ.
  3. Проверьте, что была только одна попытка оплаты.

Задание 4: Тест порядка событий

  1. Опубликуйте PaymentProcessed раньше OrderCreated (не по порядку).
  2. Проверьте, что система обрабатывает это корректно.

Задание 5: Проверка Eventual Consistency

  1. Создайте заказ через endpoint команды.
  2. Немедленно запросите read model — он может быть ещё не обновлён.
  3. Делайте polling read model до появления заказа (с таймаутом).
  4. Зафиксируйте задержку согласованности.
  5. Повторите 10 раз и рассчитайте среднюю задержку.

Результаты

  1. Тестовый код для каждого сценария с assertions.
  2. Отчёт по таймингам с задержками eventual consistency.
  3. Документация обнаруженных race condition-ов или пограничных случаев.