Контрактное тестирование между сервисами: Pact и его альтернативы
Микросервисы интегрируются через API. Тесты у каждой команды свои. Каждая ходит в моки соседей. На стейдже всё зелено. На проде ловите 500 от orders, потому что payments выкатил релиз, в котором поле currency переименовали в currency_code. Никаких e2e-тестов это не поймало — они проверяли счастливый путь, а сломалось на интеграции.
Контрактное тестирование закрывает этот разрыв: тест проверяет не реализацию, а контракт между двумя сервисами. Pact — самый известный инструмент в этой нише, но не единственный. За двенадцать лет я внедрял contract testing трижды — каждый раз с поправкой на стек и зрелость команды. Разберу, как это работает на практике, какие есть альтернативы и где это окупается, а где — головняк ради красивого слайда.
Что такое контракт
Контракт — это формальное описание ожиданий потребителя от провайдера. «Если я отправлю POST /payments с таким телом, я ожидаю получить 201 с такой структурой ответа.» Это не схема API в общем виде, а именно те части, которые потребитель использует.
Здесь ключевое отличие от OpenAPI-контракта: OpenAPI описывает всё, что умеет провайдер. Pact-контракт описывает что нужно конкретному потребителю. Если потребитель не использует поле discount, оно не попадает в контракт, и провайдер может его удалить, не сломав потребителя.
Consumer-driven подход
В Pact контракт пишет потребитель, не провайдер. Логика такая: провайдер не знает, какие поля и какие edge-case'ы нужны каждому потребителю. Потребитель знает свои требования. Поэтому он формулирует их в виде теста — и этот тест становится контрактом.
Workflow выглядит так:
- Команда orders пишет тест, в котором мокает payments через Pact-mock и вызывает свой код.
- Pact записывает запросы и ожидаемые ответы в JSON-файл (pact-файл).
- Pact-файл публикуется в Pact Broker — общее хранилище контрактов.
- Команда payments в своём CI запускает
pact-verifier, который проигрывает все опубликованные контракты против реального сервиса. - Если хоть один контракт не выполняется — билд красный.
Получается: потребитель определяет, что ему нужно, провайдер обязан это поддержать. Любое изменение провайдера, ломающее контракт, проваливает CI до того, как код попадёт в прод.
Минимальный пример
Потребитель в Kotlin с JUnit и Pact JVM:
@ExtendWith(PactConsumerTestExt::class)
class PaymentsClientPactTest {
@Pact(consumer = "orders", provider = "payments")
fun chargeSuccessful(builder: PactDslWithProvider): RequestResponsePact =
builder
.given("user with id 42 exists")
.uponReceiving("a charge request")
.method("POST")
.path("/payments")
.body("""{"userId":42,"amount":1000,"currency":"RUB"}""")
.willRespondWith()
.status(201)
.body("""{"id":"pay-1","status":"charged"}""")
.toPact()
@Test
@PactTestFor(pactMethod = "chargeSuccessful")
fun `client charges and parses response`(mockServer: MockServer) {
val client = PaymentsClient(mockServer.getUrl())
val result = client.charge(userId = 42, amount = 1000, currency = "RUB")
assertEquals("charged", result.status)
}
}Запуск этого теста создаёт orders-payments.json — pact-файл. Он публикуется в Broker:
pact-broker publish ./build/pacts \
--consumer-app-version=$GIT_SHA \
--branch=$BRANCH \
--broker-base-url=https://pact.example.comНа стороне payments в CI:
pact-verifier --provider=payments \
--pact-broker-base-url=https://pact.example.com \
--provider-base-url=http://localhost:8080 \
--provider-version=$GIT_SHAVerifier берёт все pact-файлы для payments, проигрывает запросы против запущенного сервиса, сравнивает ответы. Любое расхождение — fail.
Provider states
Главная сложность контрактного теста на стороне провайдера — подготовка состояния. Контракт говорит «when user with id 42 exists, charge returns 201». Это значит, что перед запуском теста payments должен иметь юзера с id 42 в каком-то состоянии.
Pact решает это через provider states — именованные «фикстуры», которые провайдер реализует у себя. На каждый given(...) из контракта провайдер регистрирует setup-логику.
@State("user with id 42 exists")
fun userWithIdExists() {
userRepository.save(User(id = 42, balance = 10_000))
}На практике эти setups быстро превращаются в зоопарк: каждый потребитель просит своё состояние, и в провайдере появляется десяток методов вида «order in pending», «user with overdue balance», «product reserved by another order». Я обычно ввожу набор стандартных state'ов и прошу потребителей переиспользовать.
Где Pact окупается
Не на любом проекте.
Окупается:
- 5+ микросервисов, активно интегрирующихся.
- Частые независимые релизы (каждая команда деплоит свой сервис).
- Команды разные, общаются неидеально, и без формальных контрактов меняют API без предупреждения.
- Уже есть боль от integration-багов на проде.
Избыточен:
- Монолит с парой внутренних API: достаточно обычных integration-тестов.
- Один сервис с публичным API без потребителей внутри организации: OpenAPI + version policy решают задачу.
- Команда из 3 человек, которая знает все интеграции по памяти.
- Async/event-driven система, где взаимодействие через Kafka/брокер: Pact умеет события, но это сложнее, и часто проще schema registry.
Альтернативы Pact
Pact не единственный, и для разных контекстов лучше подходят разные инструменты.
Schema-first с OpenAPI
Если у вас стабильный публичный API, можно обойтись OpenAPI как контрактом. Провайдер публикует спеку, потребители валидируют свои клиенты против неё. Тесты — обычные интеграционные.
Минус — это provider-driven: провайдер описывает всё, и потребитель сам выбирает, что использовать. Удалили поле, которое использовал один из десяти потребителей — узнаете на проде.
Плюс — простота. Если у вас единственный язык контракта (REST + OpenAPI) и нет 20 потребителей у каждого сервиса, это часто достаточно.
Spring Cloud Contract
Альтернатива Pact в JVM-мире. Провайдер пишет контракты на Groovy/YAML, генерирует из них стабы для потребителей и тесты для себя. Подход provider-driven.
Я использовал на одном проекте: больше дисциплины со стороны провайдера, меньше работы потребителю. Хорошо, когда провайдер один и потребителей много (например, общий core-сервис, к которому ходит десяток клиентов).
Pact для событий
В Pact есть message contracts — для асинхронной интеграции. Потребитель пишет: «когда мне приходит событие OrderCreated, оно должно иметь такие поля». Pact verifier проверяет, что провайдер публикует события правильной формы.
На практике для Kafka-интеграций я часто предпочитаю Schema Registry с Avro или Protobuf: сильная типизация, эволюция схемы, поддержка инструментов. Pact-message — когда брокер уже стоит, и не хочется ставить Schema Registry ради одной задачи.
Verify через прод-трафик
Подход «consumer-driven contract на проде»: записываем реальные запросы потребителей, превращаем их в контракт. Инструменты — VCR, Hoverfly, в Pact-экосистеме есть pact-go с похожей функцией.
Хорошо для legacy-систем, где написать контракт «вручную» — две недели работы. Плохо для нового кода: контракт получается размытый, и любой неоптимальный запрос фиксируется как «ожидание».
Pact Broker и can-i-deploy
Pact без Broker'а — half-system. Broker — это сервис, который хранит контракты, отслеживает совместимость, отвечает на вопрос «можно ли сейчас задеплоить версию X».
Главная команда:
pact-broker can-i-deploy \
--pacticipant payments \
--version $GIT_SHA \
--to-environment productionВозвращает 0 если все потребители совместимы с этой версией провайдера, 1 если нет. Включается в CI как gate перед деплоем.
Без can-i-deploy у вас есть контракты, но нет автоматизации совместимости. Команды смотрят дашборд Broker'а и принимают решение глазами — это плохо масштабируется.
Подводные камни
Из того, что меня кусало.
Контракты пишутся «как реализовано», а не «как нужно». Потребитель копирует пример ответа из реального API, не задумываясь, какие поля он реально использует. В контракте оказывается всё, провайдер боится менять что угодно. Решение — code review контрактов: «зачем тебе здесь поле created_at, ты его не используешь».
Flaky verifier. Тесты провайдера падают то на одном контракте, то на другом из-за гонок в setup'е. Лекарство — изоляция данных в provider states: каждый state работает на своём наборе данных, не пересекается с другими.
Versioning контрактов. Потребитель А обновился, контракт изменился, потребитель Б сидит на старой версии. Если не отслеживать версии, провайдер может закосить совместимость с одним, не заметив. Broker умеет тегировать версии контрактов, и verifier должен проверять все активные.
Контракты в обход CI. Команда пишет контракт, не публикует, и тестирует «локально». Провайдер о нём не знает. Через месяц приходит — «у нас падает прод». Pact не работает без дисциплины публикации в общий Broker.
Слишком жёсткие matcher'ы. Контракт говорит «ответ должен быть строкой '2026-03-15T10:30:00Z'» — а провайдер вернул '2026-03-15T10:30:00.123Z'. Падает, хотя семантически всё ок. Используйте matcher'ы по типу/формату, а не по точному значению.
С чего начать
Если решили внедрить:
- Выберите одну пару сервисов, где интеграция реально болит. Не пытайтесь сразу покрыть всё.
- Поставьте Broker (managed Pactflow или self-hosted в Docker за полдня).
- Напишите 2–3 контракта на самые частые сценарии. Не пытайтесь покрыть все edge-case'ы сразу.
- Подключите verifier на стороне провайдера в CI.
- Добавьте can-i-deploy перед production-deploy.
- Покажите команде первый случай, когда контракт поймал реальный breaking change. Это лучшая агитация.
Дальше расширяйте по мере необходимости. Пытаться внедрить везде сразу — типичная причина, по которой контрактное тестирование умирает на старте: команды видят бюрократию, не видят пользы.
Что запомнить
Контрактное тестирование — это про дисциплину интеграции, не про красивые отчёты. Pact работает там, где есть реальная боль от breaking changes между независимо релизящимися сервисами. На простых системах он избыточен, на сложных — спасает релизы.
Главное — consumer-driven подход, Broker с can-i-deploy и matcher'ы по форме, а не по значению. Без этих трёх вещей у вас будет «контрактное тестирование» в кавычках, которое не предотвращает того, ради чего его внедряли.