Learning Platform
Глоссарий Troubleshooting
Урок 14.03 · 25 мин
Начальный
Pactcontract-testingconsumer-drivenOpenAPISpecmaticmicroservices

Contract testing: Pact и schema testing для распределённых систем

В микросервисной архитектуре две команды пишут две стороны одного API. Команда A делает сервис orders, который дёргает users. Команда B делает users. Каждая катит свой релиз отдельно. Вопрос: как гарантировать, что в момент деплоя users команда A не получит сюрприз — переименованное поле, удалённый endpoint, другой формат даты?

Три ответа на этот вопрос:

  • End-to-end тесты — поднять обе команды в staging, прогнать сценарии. Медленно, нестабильно, дорого.
  • Schema testing — обе стороны валидируются против OpenAPI-схемы. Хорошо, если схема — единый источник правды.
  • Contract testing — consumer пишет тест, описывающий, что он ожидает от provider, формирует контракт, provider при деплое проверяется против этого контракта.

В этом уроке: Pact (главная библиотека для consumer-driven contract testing), workflow от теста до Pact Broker, сравнение с OpenAPI-tools (Specmatic, Dredd), когда какой подход выбирать.


Проблема: микросервисы и неявные контракты

Junior-разработчик впервые видит микросервисы и удивляется, почему все так серьёзно про «контракты». Причина проста.

Что ломается без контрактных тестов

Каждая зависимость -- потенциальная точка скрытого breaking change

Service A (consumer)Сервис A зависит от B. Команда A не контролирует B, но падает, если B сломался
HTTP call
Service B (provider)Сервис B катит изменения. Команда B не знает всех своих consumer и что они используют
Время t1: всё работаетA зовёт B, получает поле user_email, всё счастливо
Время t2: B релизитКоманда B переименовала user_email в email. Тесты B зелёные. CI зелёный. Релиз катится
Время t3: A падает в продеA приходит в B за полем user_email, его нет, KeyError, alert. Команда A узнаёт о breaking change постфактум
Кто виноват?Технически -- A не сломали. Технически -- B сделали breaking change. Реально: команды не разговаривают

Без контрактных тестов B не знает, что A зависит от поля user_email. A не знает, что B собирается его переименовать. Production узнаёт первым.

Contract testing решает эту проблему явно: A описывает, что использует, в виде формального контракта. B при каждом деплое валидируется против всех контрактов всех своих consumer. Если B что-то сломал — узнаёт до релиза.


Schema Registry: режимы совместимости BACKWARD/FORWARD/FULL

Pact: consumer-driven contracts

Pact — библиотека и спецификация для consumer-driven contract testing. Существует на десятке языков (Java, Ruby, Go, Python, JS); pact-python для Python.

Идея «consumer-driven»: контракт пишет consumer, а не provider. Логика:

  • Только consumer знает, что именно ему нужно. Provider может вернуть 50 полей, но consumer использует 5. Контракт фиксирует только эти 5.
  • Provider обязан выполнить все контракты всех своих consumer. Это даёт ему карту: «вот что от меня ждут».
  • Provider может добавлять поля сколько угодно — это не ломает contracts. Удалять или менять — ломает, и Pact об этом сообщит.
pip install pact-python
# на 2026 -- версия 2.x

Полный workflow Pact

Шаг 1: Consumer пишет тест

Consumer запускает в тесте mock-сервер от Pact, описывает: «когда я отправлю такой-то запрос, я ожидаю такой-то ответ». Pact записывает это во внутренний state.

# tests/test_pact_user_client.py
import atexit
from pact import Consumer, Provider

# Создаём mock-сервер на этапе module load
pact = Consumer("orders-service").has_pact_with(
    Provider("users-service"),
    host_name="localhost",
    port=1234,
    pact_dir="./pacts",
)
pact.start_service()
atexit.register(pact.stop_service)


def test_get_user_by_id():
    expected_response = {
        "id": 1,
        "email": "[email protected]",
        "name": "Alice",
    }

    (pact
        .given("a user with id 1 exists")
        .upon_receiving("a request for user 1")
        .with_request("get", "/users/1", headers={"Accept": "application/json"})
        .will_respond_with(200, body=expected_response)
    )

    with pact:
        # Внутри блока: реальный HTTP-вызов к localhost:1234 (Pact mock)
        from myapp.user_client import get_user
        user = get_user(user_id=1, base_url="http://localhost:1234")

    # Если запрос соответствует ожиданию -- тест проходит, Pact записывает interaction
    assert user["email"] == "[email protected]"

Обратите внимание на ключевые элементы:

  • given("a user with id 1 exists")provider state. Описание состояния, в котором provider должен находиться, чтобы вернуть такой ответ. Provider потом эту state-функцию реализует.
  • upon_receiving — человекочитаемое описание сценария.
  • with_request — то, что consumer отправит.
  • will_respond_with — то, что consumer ожидает в ответе.

После прохождения всех тестов Pact пишет в ./pacts/orders-service-users-service.json контракт.

Шаг 2: Pact JSON

Сгенерированный файл выглядит примерно так:

{
  "consumer": { "name": "orders-service" },
  "provider": { "name": "users-service" },
  "interactions": [
    {
      "description": "a request for user 1",
      "providerState": "a user with id 1 exists",
      "request": {
        "method": "GET",
        "path": "/users/1",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": {
          "id": 1,
          "email": "[email protected]",
          "name": "Alice"
        }
      }
    }
  ],
  "metadata": {
    "pactSpecification": { "version": "2.0.0" }
  }
}

Этот JSON — артефакт, который consumer публикует в Pact Broker (об этом дальше) или просто кладёт в репо provider.

Шаг 3: Provider verifies pact

Провайдер запускает свой реальный сервис и прогоняет на нём interaction-ы из pact-файла. Для каждого:

  1. Поднимает provider в state, указанном в providerState.
  2. Отправляет запрос из request.
  3. Сравнивает ответ с response.
  4. Если совпадает — pass. Если нет — fail с указанием, что именно разошлось.
# users-service/tests/verify_pacts.py
from pact import Verifier

verifier = Verifier(
    provider="users-service",
    provider_base_url="http://localhost:8000",
)

# Запуск проверки
exit_code, _ = verifier.verify_pacts(
    "./pacts/orders-service-users-service.json",
    provider_states_setup_url="http://localhost:8000/_pact/setup-state",
)

assert exit_code == 0

provider_states_setup_url — endpoint, который provider реализует у себя для подготовки state. Pact перед каждым interaction шлёт туда POST с именем state, provider у себя готовит данные.

# users-service/main.py (фрагмент)
from fastapi import FastAPI

app = FastAPI()

@app.post("/_pact/setup-state")
def setup_state(state: dict):
    name = state["state"]
    if name == "a user with id 1 exists":
        # Создаём в БД пользователя с id=1
        db.users.upsert({"id": 1, "email": "[email protected]", "name": "Alice"})
    return {"ok": True}

Архитектура Pact end-to-end

Pact workflow от consumer до provider verify

Контракт перетекает из consumer в provider через Broker

Consumer (CI)
Pact Broker
Provider (CI)
pytest tests/contract/pact publish + tag (consumer-version)webhook: new contract for users-servicepact get pacts for verificationverify each interactionpublish verification result

Ключевая магия — Pact Broker. Без него можно жить, копируя pact-файлы руками или через шары, но Broker делает три полезные вещи:

  1. Хранит pact-файлы между релизами.
  2. Сводит consumer-версии и provider-версии в матрицу совместимости — pact-broker can-i-deploy --pacticipant orders-service --version sha-abc123 --to-environment production. Команда возвращает yes/no: можно ли катить эту версию consumer в окружение, где работает текущая версия provider.
  3. Уведомляет через webhooks — когда consumer публикует новый pact, провайдер запускает verify.

Сравнение: consumer-driven vs schema-first

Есть второй подход — schema-first contract testing на базе OpenAPI. Главные инструменты:

  • Specmatic — тестирует и consumer, и provider против OpenAPI-схемы. Schema = контракт.
  • Dredd — гоняет реальные HTTP-запросы по OpenAPI-схеме, проверяет, что provider возвращает то, что описано.
  • schemathesis — property-based testing для API на основе OpenAPI: генерирует случайные валидные запросы и проверяет, что provider не падает.
Pact vs Schema-first

Два подхода: consumer описывает чего хочет vs провайдер описывает что отдаёт

Pact (consumer-driven)Контракт = срез того, что использует consumer. Если в API 50 полей, а consumer берёт 5 -- в pact 5 полей. Provider может добавлять поля без слома
ПолезенКогда у одного provider много consumer и каждый использует разный набор полей. Когда нужен инструмент 'can-i-deploy'
СлабостьТолько то, что consumer реально вызывает. Если consumer не дёргает endpoint -- pact не покрывает. Провайдер может удалить unused endpoint и никто не узнает
Schema-first (OpenAPI)Контракт = OpenAPI-спецификация, ground truth. Provider обязан соответствовать схеме. Consumer обязан использовать только то, что в схеме
ПолезенКогда у вас уже есть OpenAPI как первоисточник. Когда provider публичный и contract = публичная документация. Когда генерируется код клиентов из схемы
СлабостьНе показывает, кто чем пользуется. Провайдер не знает, можно ли удалить endpoint, потому что схема не отражает реальное использование

Простое правило выбора:

  • Внутренние микросервисы, ваши команды -> Pact. Consumer-driven отражает реальные зависимости команд.
  • Публичный API, OpenAPI-first -> schemathesis/Specmatic. Контракт = открытая документация.
  • Оба возможны: Pact для внутренних потребителей + OpenAPI для публики.

Vs end-to-end тесты

Логичный вопрос: «зачем Pact, если можно поднять обе сервиса в staging и прогнать e2e?» Ответ — масштабируемость и скорость.

Contract testing vs E2E

Pact решает проблему e2e -- медленные, нестабильные, требуют поднятого окружения

E2E: 10 сервисовПоднять все 10 в staging. Подготовить тестовые данные. Прогнать сценарий. Один сценарий = 5-10 минут
Сложность: O(n²)Каждый сервис должен работать с каждым. Любой падающий сервис ломает все тесты. Ложноположительные срабатывания
Contract: 10 сервисовКаждая пара (consumer, provider) тестируется независимо. Consumer mock'ает provider, provider verify pact. Параллельно, без staging
Сложность: O(n)Каждый сервис тестирует только своих direct провайдеров. Один тест занимает секунды. Можно гонять в каждом PR

Это не значит «выкинуть e2e». E2e всё ещё нужны — для smoke-тестов критических сценариев. Но 90% покрытия даёт contract testing, а 10% — несколько ключевых e2e.


Когда Pact обязателен, когда излишен

Pact — нетривиальная инфраструктура. Brokers, CI integration, provider state setup, координация команд. Не каждому проекту это окупается.

TIP

Pact окупается, когда у вас 5+ микросервисов, 2+ команды, регулярные релизы (раз в неделю или чаще), и хотя бы раз в месяц случаются breaking changes между сервисами. На монолите или маленькой системе из двух сервисов одной команды — overkill, проще e2e или просто общий integration-тест.

Признаки, что вам нужен Pact:

  • Несколько команд катают независимо.
  • Сервисы общаются по HTTP/REST.
  • Уже были инциденты «команда B сломала контракт, команда A узнала в проде».
  • В CI хочется иметь команду «можно ли катить версию X в окружение Y» (can-i-deploy).

Признаки, что Pact пока не нужен:

  • Один монолит или 2-3 сервиса одной команды.
  • Все сервисы release-ятся одновременно (нет независимости).
  • Внешние API (с ними Pact работает плохо — у вас нет влияния на provider).

Pact для внешних API: не работает

Один важный момент: Pact для внешних API (GitHub, Stripe, Twilio) не имеет смысла. Логика consumer-driven предполагает, что provider сможет verify ваш pact. У GitHub нет CI-job, который verify ваш контракт. Они даже не знают, что вы существуете.

Для внешних API правильные инструменты — VCR (как в прошлом уроке), responses/respx (как в позапрошлом) и schemathesis на их OpenAPI-схеме (если они публикуют).


Pact Broker в CI/CD

Минимальная установка Pact Broker — Docker-контейнер от pactfoundation. Хранит pact-файлы и verification results.

docker run --rm -p 9292:9292 \
  -e PACT_BROKER_DATABASE_ADAPTER=sqlite \
  -e PACT_BROKER_DATABASE_NAME=:memory: \
  pactfoundation/pact-broker:latest

В реальном проекте — managed-сервис (Pactflow от SmartBear) или self-hosted с PostgreSQL.

CI integration:

# .github/workflows/consumer-tests.yml
name: Consumer contract tests
on: [push]

jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - name: Install
        run: pip install -e . pact-python pytest
      - name: Run consumer contract tests
        run: pytest tests/contract/
      - name: Publish pact to broker
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          pact-broker publish ./pacts \
            --consumer-app-version ${{ github.sha }} \
            --tag ${{ github.ref_name }}

      - name: Can I deploy?
        if: github.ref == 'refs/heads/main'
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          pact-broker can-i-deploy \
            --pacticipant orders-service \
            --version ${{ github.sha }} \
            --to-environment production

can-i-deploy — это та самая команда, ради которой все строилось: возвращает exit code 0/1 в зависимости от того, есть ли в Broker подтверждение, что текущая версия consumer совместима с текущей версией provider в production.


Подводные камни Pact

Provider state — самый частый источник боли

Provider-state setup (POST /_pact/setup-state) — это endpoint, который provider должен реализовать, чтобы готовить данные для каждого interaction. Это:

  • Дополнительный код в provider.
  • Только для тестов (нельзя катить в прод).
  • Требует подумать про идемпотентность: один и тот же state может вызываться много раз.

Решение — выделять provider-state в отдельный модуль, не пускать его в production-роутер, использовать тестовую БД на каждом запуске.

Ленивые matchers

Pact позволяет в will_respond_with указать не точные значения, а matchers: «должно быть число», «должно быть ISO-дата», «должна быть строка длиной 5+».

from pact.matchers import Like, Term

(pact
    .given("a user with id 1 exists")
    .upon_receiving("a request for user 1")
    .with_request("get", "/users/1")
    .will_respond_with(200, body={
        "id": 1,
        "email": Term(r"[\w\.-]+@[\w\.-]+", "[email protected]"),
        "created_at": Term(r"\d{4}-\d{2}-\d{2}T.*", "2024-01-15T10:00:00Z"),
        "name": Like("Alice"),
    })
)

Like — должно быть таким же типом. Term — должно матчиться regex. Используйте matchers всегда, когда не нужны точные значения, иначе тесты будут падать на любом изменении конкретики.

Версионирование

Каждый pact-файл связан с версией consumer (обычно git SHA). Каждый verification result — с версией provider. Broker строит матрицу. Если у вас слишком много версий — матрица разрастается; стандартная практика — теги (tag=production, tag=staging), и can-i-deploy ходит по тегам, а не по конкретным SHA.


Проверка знанийKnowledge check
Команда A катит сервис orders, который зовёт users у команды B. Команда B хочет переименовать в API поле email_address в email. Как Pact-инфраструктура должна на это отреагировать?
ОтветAnswer

Итог

Contract testing — инструмент для распределённых систем с независимыми релизами. Pact реализует consumer-driven подход: consumer описывает свои ожидания, provider verify против них. Pact Broker связывает обе стороны и даёт can-i-deploy — команду, которая проверяет совместимость версий перед релизом. Альтернатива — schema-first (OpenAPI + Specmatic/Dredd) для случаев, когда схема — единый источник правды.

Pact не заменяет unit-тесты, не заменяет VCR для внешних API, не заменяет e2e полностью. Он закрывает специфическую дыру: гарантию, что между ваши микросервисы не разъедутся по форматам данных в момент независимого релиза.

В следующем модуле — capstone-проект, в котором мы собираем всё знание курса (HTTP, форматы, OpenAPI, auth, retry, тестирование) в один production-ready ETL-pipeline.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. В чём ключевая идея 'consumer-driven' в Pact?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3