С распределённой системой ломается привычная тестовая интуиция: код каждого сервиса покрыт юнит-тестами, все зелёные — а на проде падает, потому что один сервис поменял формат события, второй не так понял порядок сообщений, а третий словил гонку в eventual consistency. Эти классы багов живут между сервисами, и юнит-тесты их принципиально не видят. При этом наивный ответ — «напишем много e2e» — даёт медленный, флаки и дорогой в поддержке сьют. Эта статья — про стратегию: какие уровни тестов и где ставить границы, чтобы ловить межсервисные баги дёшево и надёжно.
Дополняет тестирование в разных языках (пирамида) взглядом на систему, а не отдельный сервис.
В статье
- Что ломается между сервисами
- Уровни: пирамида для распределёнки
- Интеграция и границы: Testcontainers
- Где тест-дубль оправдан, а где — вредит
- Контрактные тесты вместо хрупких e2e
- Асинхронность и время: ожидание вместо sleep
- Отказы: инъекция сбоев, ретраи, идемпотентность
- Checklist: тестовая стратегия распределённой системы
- Из практики
- Что дальше
Что ломается между сервисами
Юнит-тест проверяет функцию в вакууме: подали вход — получили выход. Но межсервисные баги живут не внутри функции, а в зазоре между двумя процессами, у которых нет ни общей памяти, ни общей транзакции, ни общего понимания времени. Четыре класса таких багов повторяются из системы в систему.
Несовместимый контракт. Продюсер поменял формат: переименовал поле, сделал опциональное обязательным, поменял тип. Его собственные тесты зелёные — он тестировал себя. Потребитель падает на десериализации или, хуже, молча читает null там, где раньше было значение. Это разновидность схемной эволюции — подробно разобранная в контрактах событий и эволюции схем.
Доставка, порядок, дубли. Брокер даёт at-least-once: сообщение может прийти дважды, может прийти позже соседнего, может застрять и прийти через минуту. Потребитель, написанный в предположении «ровно один раз, строго по порядку», ломается на первом же ретрае продюсера. Идемпотентность и порядок — тема transactional outbox/inbox.
Гонки в eventual consistency. Записали в сервис A, тут же читаем из его реплики или из спроецировавшего событие сервиса B — а там ещё старое значение. Тест, который пишет и сразу читает, будет то зелёным, то красным в зависимости от того, успела ли реплика догнать. Это не флаки теста — это свойство системы, и его надо тестировать явно (см. сильная vs eventual согласованность).
Время. Таймауты, TTL, окна ретраев, дедлайны. Логика, завязанная на now(), недетерминирована по определению: тест проходит днём и падает на границе суток, проходит на быстрой машине и падает на загруженном CI-агенте.
Общая черта: ни один из этих багов не виден изнутри одного сервиса. Их ловят тесты, которые смотрят на границу — и делают это дёшево, не поднимая всю систему целиком.
Уровни: пирамида для распределёнки
Классическая пирамида тестов (много быстрых юнитов, меньше интеграционных, минимум 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;
- Юнит. Чистая логика: расчёт, валидация, конечный автомат, обработчик события как функция «состояние + событие → новое состояние». Без сети, без БД, микросекунды на тест. Их должно быть много.
- Интеграция. Сервис против реальных зависимостей — своей базы, своего брокера, поднятых в контейнере. Проверяет то, что юнит проверить не может: работает ли 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: тестовая стратегия распределённой системы
- Покрыта ли бизнес-логика юнитами — быстрыми, без сети и БД?
- Идут ли интеграционные тесты против реальных своей БД и брокера (Testcontainers), а не их моков?
- Проведена ли граница тест-дублей осознанно: внешний платный API — дубль, своя инфраструктура — реальная?
- Есть ли контрактные тесты на границах между сервисами вместо попытки покрыть их через e2e?
- Зафиксированы ли схемы событий как проверяемый контракт (реестр схем, правила совместимости)?
- Заменены ли
sleepна ожидание условия с дедлайном во всех асинхронных тестах? - Прокинуто ли время как зависимость там, где логика завязана на таймауты/TTL?
- Есть ли тесты на сбои: отказ зависимости, таймаут, падение узла?
- Проверяется ли идемпотентность повторной доставкой одного и того же сообщения?
- Остался ли 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 и property-based по восьми языкам.
- Transactional Outbox/Inbox — что именно проверяют тесты на идемпотентность и порядок.
- Контракты событий и эволюция схем и contract-first с OpenAPIСкоро — контракты, против которых гоняются consumer-driven тесты.
- Сильная vs eventual согласованность — почему «записал и сразу прочитал» — это тест на гонку.
- Паттерны устойчивостиготовится, с 14 сентября — то, что проверяют тесты на сбои.
- CI-пайплайн на GitHub Actionsготовится, с 22 октября — где всё это гоняется и как пайплайн относится к флаки.
Первоисточники концепций: Мартин Фаулер — о пирамиде тестов, интеграционных тестах против реальных зависимостей и контрактных тестах; документация Testcontainers и Pact; Awaitility для условного ожидания.
Комментарии