Как тестировать систему, использующую паттерн Saga для управления распределенными транзакциями?

Ответ

Тестирование Saga критически важно, так как паттерн управляет сложными сценариями с откатами. Фокус должен быть на целостности процесса и надежности компенсаций.

Стратегия тестирования:

  1. Тестирование успешного сценария (Happy Path): Проверка, что все шаги Saga выполняются последовательно и система приходит в согласованное состояние.
  2. Тестирование откатов (Rollback Scenarios): Симуляция сбоя на каждом возможном шаге Saga. Убедиться, что все уже выполненные шаги корректно компенсируются в обратном порядке.
  3. Тестирование идемпотентности: Компенсирующие операции и, по возможности, сами шаги Saga должны быть идемпотентными (повторный вызов не должен ломать систему). Это защита от повторной доставки сообщений.
  4. Тестирование таймаутов и сетевых проблем: Проверка поведения Saga при недоступности сервиса, долгом ответе или разрыве соединения.

Практический пример теста на Python с использованием pytest и моков:

import pytest
from unittest.mock import Mock, patch
from my_app.saga import OrderSaga, PaymentService, InventoryService, NotificationService

# 1. Тест успешного выполнения
class TestOrderSagaHappyPath:
    def test_saga_completes_successfully(self):
        # Arrange
        saga = OrderSaga(order_id=123)
        payment_mock = Mock(spec=PaymentService)
        inventory_mock = Mock(spec=InventoryService)
        # ... моки других сервисов

        # Act
        result = saga.execute()

        # Assert
        assert result.is_success()
        payment_mock.charge.assert_called_once_with(order_id=123)
        inventory_mock.reserve.assert_called_once_with(order_id=123)
        # Проверяем, что компенсации НЕ вызывались
        payment_mock.refund.assert_not_called()
        inventory_mock.release.assert_not_called()

# 2. Тест отката при ошибке на втором шаге
class TestOrderSagaCompensation:
    def test_saga_compensates_on_failure(self):
        # Arrange
        saga = OrderSaga(order_id=456)
        payment_mock = Mock(spec=PaymentService)
        # Инвентарь выбросит исключение при резервировании
        inventory_mock = Mock(spec=InventoryService)
        inventory_mock.reserve.side_effect = Exception("Out of stock")

        # Act
        result = saga.execute()

        # Assert
        assert result.is_failure()
        # Проверяем, что оплата прошла, но потом была отменена
        payment_mock.charge.assert_called_once()
        payment_mock.refund.assert_called_once_with(order_id=456)  # Компенсация!
        # Проверяем, что последующие шаги (например, уведомление) не запускались
        # notification_mock.send.assert_not_called()

# 3. Тест идемпотентности компенсации
    def test_compensation_is_idempotent(self):
        # Симуляция ситуации, где сообщение о компенсации может прийти дважды
        payment_mock = Mock(spec=PaymentService)
        # Первый вызов refund работает, второй не должен ничего ломать
        saga = OrderSaga(order_id=789)
        # ... настройка сбоя и первый вызов execute
        saga.execute()

        # Имитируем повторный вызов компенсации
        saga.compensate()  # Внутри должен вызывать payment_mock.refund

        # Убеждаемся, что refund был вызван, но логика внутри сервиса идемпотентна
        # (например, проверяет статус перед действием).
        # На уровне теста Saga мы проверяем, что повторный вызов не бросает исключений.
        assert True  # Если не упало — уже хорошо. Лучше проверить состояние БД.

Ключевые аспекты для проверки в интеграционных/Е2Е тестах:

  • Согласованность данных: После отката Saga данные во всех затронутых сервисах должны вернуться в состояние, согласованное с бизнес-правилами (деньги возвращены, товары разрезервированы).
  • Логирование и трассируемость: В логах должен оставаться четкий след (correlation ID) всех шагов и компенсаций для возможности отладки в продакшене.
  • Восстановление после сбоя (Recovery): Тестирование механизма, который «добивает» прерванные Saga после перезапуска сервиса.

Ответ 18+ 🔞

Да ты посмотри, какая хуйня сложная вырисовалась! Сага, блядь. Не сказка, а паттерн такой, сука, который как змея кусает себя за хвост, а потом ещё и откатывается, если что не так. Тестировать это дело — это не хуй собачий, это надо с умом подходить, а то такую кашу в продакшене заваришь, что овердохуища компенсаций не хватит.

Главная мысль, которую в башке держать надо: проверяй, что весь процесс — цельный, а откаты — надёжные, как швейцарские часы, блядь. Если один шаг пизданулся, все предыдущие должны отмениться так, будто их и не было.

Как эту поебень тестировать, чтобы не обосраться:

  1. Счастливый путь (Happy Path): Ну тут всё просто, как три копейки. Запустил сагу — она пробежалась по всем шагам, как хорошая борзая, и всё сделала. Проверяешь, что система в итоге в том состоянии, в котором и должна быть. Никаких компенсаций, нихуя лишнего.
  2. Сценарии отката (Rollback): А вот тут начинается цирк, блядь. Нужно на каждом, блядь, шаге симулировать пиздец. Упала оплата? Откат. Упало резервирование товара? Откат, и оплата уже должна вернуться. Упала отправка письма? Откат, и товар разрезервирован, и деньги назад. Проверяешь, что компенсации сработали в обратном порядке — как шаги выполнялись.
  3. Идемпотентность: Это слово, блядь, выучить надо. Означает, что если ты вызовешь одну и ту же операцию (особенно компенсацию) дважды — ничего не сломается. Сообщения в очередях могут прилететь повторно, и система не должна от этого взорваться, как хитрая жопа. Компенсирующие операции обязаны быть идемпотентными.
  4. Таймауты и проблемы сети: А что будет, если сервис завис? Или сеть легла? Сага не должна висеть вечно. Проверяешь, как она реагирует на долгий ответ или полную недоступность.

Смотри, как это примерно в коде выглядит (Python, pytest):

import pytest
from unittest.mock import Mock, patch
from my_app.saga import OrderSaga, PaymentService, InventoryService, NotificationService

# 1. Тест, когда всё прошло гладко
class TestOrderSagaHappyPath:
    def test_saga_completes_successfully(self):
        # Подготовка (Arrange)
        saga = OrderSaga(order_id=123)
        payment_mock = Mock(spec=PaymentService) # Мок сервиса оплаты
        inventory_mock = Mock(spec=InventoryService) # Мок сервиса запасов
        # ... и так далее

        # Действие (Act)
        result = saga.execute()

        # Проверка (Assert)
        assert result.is_success() # Всё ок
        payment_mock.charge.assert_called_once_with(order_id=123) # Деньги списались
        inventory_mock.reserve.assert_called_once_with(order_id=123) # Товар зарезервирован
        # Важно! Компенсации НЕ должны были вызваться
        payment_mock.refund.assert_not_called()
        inventory_mock.release.assert_not_called()

# 2. Тест, когда всё пошло по пизде на втором шаге
class TestOrderSagaCompensation:
    def test_saga_compensates_on_failure(self):
        # Подготовка
        saga = OrderSaga(order_id=456)
        payment_mock = Mock(spec=PaymentService)
        # Сервис инвентаря сейчас кинет исключение, типа "нет на складе"
        inventory_mock = Mock(spec=InventoryService)
        inventory_mock.reserve.side_effect = Exception("Out of stock")

        # Действие
        result = saga.execute()

        # Проверка
        assert result.is_failure() # Сага упала
        # Но! Оплата-то уже прошла...
        payment_mock.charge.assert_called_once()
        # ...а значит должна быть отменена! Вот она, компенсация!
        payment_mock.refund.assert_called_once_with(order_id=456)
        # И следующие шаги (типа уведомления) даже не стартовали
        # notification_mock.send.assert_not_called()

# 3. Тест идемпотентности компенсации
    def test_compensation_is_idempotent(self):
        # Ситуация: сообщение о компенсации прилетело два раза
        payment_mock = Mock(spec=PaymentService)
        saga = OrderSaga(order_id=789)
        # Настраиваем сбой, сага выполняется и запускает компенсацию
        saga.execute()

        # БАМ! Имитируем повторный вызов компенсации (вдруг сообщение продублировалось)
        saga.compensate()  # Внутри опять дергается payment_mock.refund

        # Суть в том, что повторный вызов не должен ничего сломать.
        # Сервис оплаты внутри себя должен проверять: "А не вернул ли я уже деньги?"
        # Для теста саги главное — что не вылетело исключение.
        assert True  # Не упало — уже хорошо. В реальности смотри состояние БД.

А для интеграционных или Е2Е тестов смотри сюда, что важно:

  • Согласованность данных, ёпта: После всей этой свистопляски с откатами данные во всех сервисах должны быть как будто ничего и не происходило. Деньги — на месте, товары — свободны. Никаких призрачных списаний или вечных резервов.
  • Логи и трассировка: В логах должен быть ясный след, как у слона в посудной лавке. По correlation_id можно восстановить всю цепочку: что сделали, что откатили, где накосячили. Без этого в продакшене — пиши пропало, будешь как слепой щенок тыкаться.
  • Восстановление после пиздеца (Recovery): А что если сервис с сагой упал посередине процесса? А потом поднялся. Должен быть механизм, который поднимет все незавершённые саги и доведёт их до ума — либо выполнит, либо, блядь, откатит. Это тоже надо тестировать, иначе они так и будут висеть мёртвым грузом.