Тестирование распределённых систем: уровни, границы, асинхронность

Юнит-тесты не поймают то, что ломается между сервисами: несовместимый контракт, потерянное сообщение, гонку в eventual consistency. Как выстроить уровни для распределённой системы — юнит → интеграция (Testcontainers) → контрактные тесты (вместо хрупких e2e) → немного e2e, где тест-дубли ставить на границах, и как тестировать асинхронность и eventual так, чтобы тесты не были флаки. Стратегия, а не набор фреймворков

С распределённой системой ломается привычная тестовая интуиция: код каждого сервиса покрыт юнит-тестами, все зелёные — а на проде падает, потому что один сервис поменял формат события, второй не так понял порядок сообщений, а третий словил гонку в eventual consistency. Эти классы багов живут между сервисами, и юнит-тесты их принципиально не видят. При этом наивный ответ — «напишем много e2e» — даёт медленный, флаки и дорогой в поддержке сьют. Эта статья — про стратегию: какие уровни тестов и где ставить границы, чтобы ловить межсервисные баги дёшево и надёжно.

Дополняет тестирование в разных языках (пирамида) взглядом на систему, а не отдельный сервис.

Пирамида тестирования распределённой системы: широкое основание юнит-тестов → интеграционные (Postgres и Kafka через Testcontainers) → контрактные (рукопожатие сервисов и паспорт-контракт) → тонкая вершина e2e; сбоку перечёркнутый анти-паттерн «мороженое» и таймлайн асинхронности/eventual

В статье

Что ломается между сервисами

Юнит-тест проверяет функцию в вакууме: подали вход — получили выход. Но межсервисные баги живут не внутри функции, а в зазоре между двумя процессами, у которых нет ни общей памяти, ни общей транзакции, ни общего понимания времени. Четыре класса таких багов повторяются из системы в систему.

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

Доставка, порядок, дубли. Брокер даёт at-least-once: сообщение может прийти дважды, может прийти позже соседнего, может застрять и прийти через минуту. Потребитель, написанный в предположении «ровно один раз, строго по порядку», ломается на первом же ретрае продюсера. Идемпотентность и порядок — тема transactional outbox/inbox.

Гонки в eventual consistency. Записали в сервис A, тут же читаем из его реплики или из спроецировавшего событие сервиса B — а там ещё старое значение. Тест, который пишет и сразу читает, будет то зелёным, то красным в зависимости от того, успела ли реплика догнать. Это не флаки теста — это свойство системы, и его надо тестировать явно (см. сильная vs eventual согласованность).

Время. Таймауты, TTL, окна ретраев, дедлайны. Логика, завязанная на now(), недетерминирована по определению: тест проходит днём и падает на границе суток, проходит на быстрой машине и падает на загруженном CI-агенте.

Общая черта: ни один из этих багов не виден изнутри одного сервиса. Их ловят тесты, которые смотрят на границу — и делают это дёшево, не поднимая всю систему целиком.

Уровни: пирамида для распределёнки

Классическая пирамида тестов (много быстрых юнитов, меньше интеграционных, минимум e2e) остаётся верной и для распределённой системы — меняется только акцент на среднем слое. Для одного сервиса основа — юниты. Для системы из сервисов основной вес переносится на интеграционные и контрактные тесты: именно они покрывают те самые границы, а юниты по-прежнему держат бизнес-логику внутри.

flowchart TB E["e2e — критичный путь
несколько сценариев
реальная система целиком"] C["Контрактные тесты
совместимость границ
без поднятия всей системы"] I["Интеграционные тесты
реальные БД/брокеры
через Testcontainers"] U["Юнит-тесты
бизнес-логика внутри сервиса
быстрые, изолированные"] E --> C --> I --> U classDef top fill:#e8d9c0,stroke:#8a7a5c,stroke-width:1px; classDef mid fill:#f0e6d2,stroke:#8a7a5c,stroke-width:1px; classDef base fill:#f7f1e3,stroke:#8a7a5c,stroke-width:1px; class E top; class C,I mid; class U base;

flowchart TB
  E["e2e — критичный путь
несколько сценариев
реальная система целиком"] C["Контрактные тесты
совместимость границ
без поднятия всей системы"] I["Интеграционные тесты
реальные БД/брокеры
через Testcontainers"] U["Юнит-тесты
бизнес-логика внутри сервиса
быстрые, изолированные"] E --> C --> I --> U classDef top fill:#e8d9c0,stroke:#8a7a5c,stroke-width:1px; classDef mid fill:#f0e6d2,stroke:#8a7a5c,stroke-width:1px; classDef base fill:#f7f1e3,stroke:#8a7a5c,stroke-width:1px; class E top; class C,I mid; class U base;
Уровни тестов для распределённой системы: широкое основание из юнитов, содержательный слой интеграции и контрактов, тонкая верхушка e2e
  • Юнит. Чистая логика: расчёт, валидация, конечный автомат, обработчик события как функция «состояние + событие → новое состояние». Без сети, без БД, микросекунды на тест. Их должно быть много.
  • Интеграция. Сервис против реальных зависимостей — своей базы, своего брокера, поднятых в контейнере. Проверяет то, что юнит проверить не может: работает ли SQL, правильно ли сериализуется событие, ловится ли ограничение уникальности. Медленнее юнитов, но всё ещё в рамках go test / mvn test.
  • Контракт. Совместимость границы между двумя сервисами — без поднятия обоих одновременно. Быстрые, детерминированные, ловят именно рассинхрон формата.
  • e2e. Несколько сценариев на критичном пути через реально развёрнутую систему. Дорогие, медленные, наиболее хрупкие — поэтому их немного.

Антипаттерн — перевёрнутая пирамида (её же называют «мороженым»: тонкий слой юнитов и раздутая верхушка e2e и ручных проверок). Соблазн понятен: «раз баги между сервисами — давайте тестировать всё вместе». Но e2e-сьют, на который переносят основную нагрузку, деградирует предсказуемо: он медленный (минуты на прогон), флаки (любой сетевой чих красит билд), и по красному тесту невозможно понять, какой из пяти сервисов виноват. Каждый баг границы, который можно поймать контрактным тестом за миллисекунды, в e2e ловится за минуты и с худшей диагностикой. e2e — это дымовая проверка «система вообще жива на главном сценарии», а не основной инструмент. Тот же разбор пирамиды с языкового угла — в тестировании в разных языках.

Интеграция и границы: Testcontainers

Ключевой сдвиг для среднего слоя: перестать мокать собственную инфраструктуру. Мок БД проверяет ваши представления о том, как ведёт себя БД, а не саму БД. Он не поймает опечатку в SQL, нарушение ограничения, особенность сериализации jsonb, поведение ON CONFLICT. Testcontainers поднимает настоящую Postgres (или Kafka, Redis, что нужно) в Docker-контейнере на время теста и гасит после — тест идёт против реальной зависимости, оставаясь при этом самодостаточным и повторяемым. Идею популяризировал в том числе Мартин Фаулер, разбирая интеграционные тесты против реальных, а не подменённых зависимостей.

@Testcontainers
class OrderRepositoryIT {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:18-alpine");

    DataSource dataSource;

    @BeforeEach
    void setUp() {
        dataSource = buildDataSource(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword());
        // накатываем реальную схему — теми же миграциями, что в проде
        Flyway.configure().dataSource(dataSource).load().migrate();
    }

    @Test
    void savesAndReadsOrder() {
        var repo = new OrderRepository(dataSource);

        var id = repo.save(new Order("SKU-1", 3));
        var loaded = repo.findById(id).orElseThrow();

        assertThat(loaded.sku()).isEqualTo("SKU-1");
        assertThat(loaded.quantity()).isEqualTo(3);
    }
}

Тест накатывает те же миграции, что и прод, и работает с тем же движком БД — значит, ловит рассинхрон между кодом и схемой ещё в CI. Testcontainers есть для Go (testcontainers-go), Java, Python, Node, Rust и других — общий приём, сквозной по языкам (тестирование в разных языках сводит их в таблицу). Для брокера — ровно так же: поднять реальный Kafka/RabbitMQ в контейнере и проверить, что событие действительно сериализуется, публикуется и вычитывается, а не что «мок вернул то, что мы в него положили».

Где тест-дубль оправдан, а где — вредит

Testcontainers не отменяет тест-дубли — он смещает границу, где их уместно ставить. Правило простое: дублируйте то, что вам не принадлежит и что дорого/опасно вызывать по-настоящему; поднимайте по-настоящему то, что принадлежит вам.

  • Своя БД, свой брокер — реальные (контейнер). Это ваша инфраструктура, её поведение — часть контракта вашего сервиса. Мок здесь прячет именно те баги, ради которых пишется тест.
  • Внешний платный/сторонний API — дубль. Платёжный шлюз, SMS-провайдер, чужой сервис за пределами вашей команды. Дёргать его в каждом прогоне CI — дорого, медленно, недетерминированно, а иногда просто нельзя. Здесь оправдан стаб/фейк (или контрактная запись, см. ниже).
  • Соседний внутренний сервис — не мок, а контракт. Поднимать его целиком в вашем тесте — это уже e2e со всеми его минусами. Мокать вручную — вы зафиксируете своё представление о его API, которое разойдётся с реальностью. Правильный ответ — контрактный тест.

Различают мок (проверяет факт взаимодействия), стаб (отдаёт заготовленный ответ) и фейк (лёгкая рабочая реализация, например in-memory). Избыток моков — частый признак того, что тесты проверяют реализацию, а не поведение: они краснеют при любом рефакторинге, ничего при этом не находя.

Контрактные тесты вместо хрупких e2e

Контрактный тест отвечает на вопрос «совместимы ли продюсер и потребитель» — не поднимая их вместе. Идея consumer-driven contracts: потребитель формулирует ожидание от API («на GET /stock/SKU-1 жду 200 с полями sku и целым available»), это ожидание записывается как контракт (pact-файл), и затем обе стороны проверяются против него по отдельности. Потребитель гоняет свои тесты против мок-сервера, поднятого из контракта; продюсер отдельно проверяет, что он этому контракту удовлетворяет. Если продюсер сломает совместимость — красным станет его сборка, до деплоя, а не e2e через неделю. Инструмент-де-факто — Pact; саму идею контрактных тестов детально разбирал Мартин Фаулер.

@ExtendWith(PactConsumerTestExt.class)
class InventoryClientPactTest {

    @Pact(consumer = "order-service", provider = "inventory-service")
    RequestResponsePact stockAvailable(PactDslWithProvider builder) {
        return builder
            .given("SKU-1 has 5 units in stock")     // состояние провайдера
            .uponReceiving("a stock query for SKU-1")
                .path("/stock/SKU-1").method("GET")
            .willRespondWith()
                .status(200)
                .body(new PactDslJsonBody()
                    .stringValue("sku", "SKU-1")
                    .integerType("available", 5))    // тип, не конкретное значение
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "stockAvailable")
    void readsAvailableStock(MockServer mockServer) {
        var client = new InventoryClient(mockServer.getUrl());

        var stock = client.stockOf("SKU-1");

        assertThat(stock.available()).isEqualTo(5);
    }
}

Обратите внимание на integerType вместо конкретного 5: контракт фиксирует форму (поле есть, оно целое), а не точное значение — иначе он превратится в хрупкую копию продакшн-данных. Сгенерированный pact-файл затем проверяется на стороне провайдера в его собственном пайплайне.

Для синхронного HTTP контракт естественно вырастает из contract-first и OpenAPIСкоро: спецификация и есть машиночитаемый контракт, против которого валидируются обе стороны. Для асинхронных событий роль контракта играет схема события (Avro/Protobuf/JSON Schema в реестре схем) плюс правила совместимости при эволюции — см. контракты событий и эволюцию схем. Pact тоже умеет message pacts для событий, но принцип тот же: зафиксировать форму сообщения как проверяемый артефакт.

Асинхронность и время: ожидание вместо sleep

Асинхронный тест выглядит невинно: опубликовали команду, подождали, проверили результат. Проблема — в «подождали». Thread.sleep(500) / time.Sleep(500ms) — источник половины флаки в межсервисных сьютах: на быстрой машине 500 мс избыточны (тест тормозит на ровном месте), на медленном CI-агенте их не хватает (тест краснеет, хотя система работает). Фиксированная пауза угадывает время, которое угадать нельзя.

Правильный приём — опрос условия с дедлайном: проверяем результат в цикле, с коротким интервалом, пока не выполнится или не выйдет таймаут. Тест завершается сразу, как только eventual-состояние сошлось, и падает осмысленно, если не сошлось за разумный срок.

// waitFor опрашивает cond с интервалом, пока не станет true или не выйдет timeout.
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
    t.Helper()
    deadline := time.Now().Add(timeout)
    for time.Now().Before(deadline) {
        if cond() {
            return // условие сошлось — выходим немедленно
        }
        time.Sleep(50 * time.Millisecond) // короткий шаг опроса, не «угаданная» пауза
    }
    t.Fatalf("condition not met within %s", timeout)
}

func TestOrderProjectionEventuallyUpdated(t *testing.T) {
    publishOrderCreated(t, "SKU-1", 3)

    // проекция обновляется асинхронно — ждём именно её сходимости, а не «полсекунды»
    waitFor(t, 5*time.Second, func() bool {
        got, err := readProjection(t, "SKU-1")
        return err == nil && got.Quantity == 3
    })
}

Свой waitFor писать не обязательно — это ровно то, что делают готовые хелперы: require.Eventually в testify (Go), Awaitility (Java) с его await().atMost(...).until(...), аналоги в других экосистемах. Важен принцип, а не библиотека: опрашивать условие, а не спать наугад.

Второй рычаг — детерминизм над eventual там, где это возможно. Если обработчик завязан на текущее время, не читайте now() напрямую — прокидывайте Clock/таймер как зависимость и подменяйте в тесте на управляемые часы. Тогда «прошло 30 секунд, ретрай должен сработать» проверяется без реального ожидания 30 секунд и без флаки на границе суток. Всё, что можно превратить из «подождём и посмотрим» в «зададим время явно» — стоит превратить: детерминированный тест не бывает флаки. Про то, как флаки-тесты отравляют пайплайн и что с этим делает CI, — в CI-пайплайне на GitHub Actionsготовится, с 22 октября.

Отказы: инъекция сбоев, ретраи, идемпотентность

Распределённая система обязана переживать сбои — значит, сбои надо тестировать так же, как happy path. Проверяется не «система работает, когда всё хорошо», а «система ведёт себя корректно, когда зависимость отвалилась, ответила с таймаутом или узел упал посреди обработки».

  • Инъекция сбоя. Заставить зависимость вести себя плохо специально: стаб внешнего API, отвечающий 503 или зависающий до таймаута; брокер, разорвавший соединение; узел, упавший между записью и подтверждением. Testcontainers-окружение позволяет и грубые приёмы — остановить контейнер БД посреди теста и проверить, что сервис деградирует предсказуемо, а не сыплет неинформативными 500. Это тестовая проверка паттернов устойчивостиготовится, с 14 сентября: retry с backoff, circuit breaker, таймауты.
  • Ретраи и идемпотентность вместе. Ретрай без идемпотентности — способ продублировать эффект: повторно списать деньги, дважды создать заказ. Тест должен доставлять одно и то же сообщение дважды (имитируя at-least-once) и проверять, что эффект применился один раз: остаток изменился однократно, в inbox/таблице обработанных — одна запись. Это прямая проверка inbox-дедупликации: подать событие с тем же ключом повторно и убедиться, что второй проход — no-op.
  • Порядок и дубли. Сознательно подать события в переставленном порядке и с повтором — и проверить, что обработчик либо устойчив к переупорядочиванию, либо корректно буферизует/отбрасывает «из будущего».

Смысл — превратить «надеюсь, ретраи настроены правильно» в исполняемое утверждение. Пока сбой не воспроизведён в тесте, вы не знаете, как система на него реагирует, — вы это узнаете в проде.

Checklist: тестовая стратегия распределённой системы

  1. Покрыта ли бизнес-логика юнитами — быстрыми, без сети и БД?
  2. Идут ли интеграционные тесты против реальных своей БД и брокера (Testcontainers), а не их моков?
  3. Проведена ли граница тест-дублей осознанно: внешний платный API — дубль, своя инфраструктура — реальная?
  4. Есть ли контрактные тесты на границах между сервисами вместо попытки покрыть их через e2e?
  5. Зафиксированы ли схемы событий как проверяемый контракт (реестр схем, правила совместимости)?
  6. Заменены ли sleep на ожидание условия с дедлайном во всех асинхронных тестах?
  7. Прокинуто ли время как зависимость там, где логика завязана на таймауты/TTL?
  8. Есть ли тесты на сбои: отказ зависимости, таймаут, падение узла?
  9. Проверяется ли идемпотентность повторной доставкой одного и того же сообщения?
  10. Остался ли e2e тонким слоем на критичном пути, а не основой сьюта?

Если на пункт 2 ответ «мокаем» — интеграционные тесты проверяют ваши представления об инфраструктуре, а не её. Если «не знаем» на 9 — система, вероятно, дублирует эффекты при ретраях, просто это ещё не всплыло.

Из практики

Три наблюдения, которые повторяются чаще остальных.

Флаки-тест — это не «перезапустим, авось позеленеет», а найденный баг. Тест, который мигает, почти всегда указывает на реальную гонку или на sleep, угадывающий тайминг. «Лечить» его ретраем прогона — значит прятать сигнал. Правильно — либо сделать ожидание условным (waitFor/Eventually), либо превратить недетерминизм в детерминизм через управляемое время. Замалчивание флаки деградирует доверие ко всему сьюту: команда начинает перезапускать красный билд не глядя — и однажды перезапускает настоящий баг.

Контрактный тест окупается на втором сервисе. Пока сервис один, contract-инфраструктура кажется оверинжинирингом. Как только потребителей у API становится двое и больше, консьюмер-контракты — единственный способ менять продюсера, не устраивая ручной обход всех потребителей и не молясь на e2e. Дешевле всего завести их сразу, а не после первого инцидента «продюсер выкатил несовместимое изменение в пятницу».

Больше e2e не значит надёжнее — обычно наоборот. Соблазн ответить на межсервисный баг ещё одним e2e-сценарием силён и почти всегда ошибочен. Каждый добавленный e2e замедляет пайплайн и добавляет источник флаки, при этом покрывая один узкий путь. Тот же класс багов почти всегда ловится дешевле уровнем ниже: контрактным или интеграционным тестом с лучшей локализацией причины. Растущий e2e-сьют — сигнал, что средний слой пирамиды провис.

Про стенд: запускаемые версии этих приёмов — в стенде digital-cookbook/testing/distributed/: интеграция на Testcontainers (реальные Postgres + Kafka), consumer-driven контракт на Pact, ожидание eventual через Awaitility (без sleep), идемпотентность при повторной доставке. Полный набор — интеграция на Postgres, async-eventual на Kafka, контракты на Pact и идемпотентность — проверен живьём на нативном Docker Engine (через Docker-in-Docker, движок Docker 29.6.1): Tests run: 7, Failures: 0 — контейнеры поднимаются, все тесты зелёные. Нюанс среды: на Docker Desktop с ограниченным docker_cli-proxy тот же прогон не идёт (docker-java не форвардит порты container-create; для Docker 29 нужна Testcontainers ≥ 1.21) — что перепроверить и как воспроизвести на нативном движке, разобрано в README стенда. Ограничение — в среде, а не в assertions.

Что дальше

Тестовая стратегия — это проекция архитектурных решений на проверяемые утверждения. Смежные темы на сайте:

Первоисточники концепций: Мартин Фаулер — о пирамиде тестов, интеграционных тестах против реальных зависимостей и контрактных тестах; документация Testcontainers и Pact; Awaitility для условного ожидания.

Обсуждение в Telegram

Присоединиться →

Комментарии