lenec ru

← все посты

Контрактное тестирование между сервисами: Pact и его альтернативы

16K

Микросервисы интегрируются через 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 выглядит так:

  1. Команда orders пишет тест, в котором мокает payments через Pact-mock и вызывает свой код.
  2. Pact записывает запросы и ожидаемые ответы в JSON-файл (pact-файл).
  3. Pact-файл публикуется в Pact Broker — общее хранилище контрактов.
  4. Команда payments в своём CI запускает pact-verifier, который проигрывает все опубликованные контракты против реального сервиса.
  5. Если хоть один контракт не выполняется — билд красный.

Получается: потребитель определяет, что ему нужно, провайдер обязан это поддержать. Любое изменение провайдера, ломающее контракт, проваливает 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_SHA

Verifier берёт все 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'ы по типу/формату, а не по точному значению.

С чего начать

Если решили внедрить:

  1. Выберите одну пару сервисов, где интеграция реально болит. Не пытайтесь сразу покрыть всё.
  2. Поставьте Broker (managed Pactflow или self-hosted в Docker за полдня).
  3. Напишите 2–3 контракта на самые частые сценарии. Не пытайтесь покрыть все edge-case'ы сразу.
  4. Подключите verifier на стороне провайдера в CI.
  5. Добавьте can-i-deploy перед production-deploy.
  6. Покажите команде первый случай, когда контракт поймал реальный breaking change. Это лучшая агитация.

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

Что запомнить

Контрактное тестирование — это про дисциплину интеграции, не про красивые отчёты. Pact работает там, где есть реальная боль от breaking changes между независимо релизящимися сервисами. На простых системах он избыточен, на сложных — спасает релизы.

Главное — consumer-driven подход, Broker с can-i-deploy и matcher'ы по форме, а не по значению. Без этих трёх вещей у вас будет «контрактное тестирование» в кавычках, которое не предотвращает того, ради чего его внедряли.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.