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
Без контрактных тестов 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-файла. Для каждого:
- Поднимает provider в state, указанном в
providerState. - Отправляет запрос из
request. - Сравнивает ответ с
response. - Если совпадает — 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
Контракт перетекает из consumer в provider через Broker
Ключевая магия — Pact Broker. Без него можно жить, копируя pact-файлы руками или через шары, но Broker делает три полезные вещи:
- Хранит pact-файлы между релизами.
- Сводит consumer-версии и provider-версии в матрицу совместимости —
pact-broker can-i-deploy --pacticipant orders-service --version sha-abc123 --to-environment production. Команда возвращает yes/no: можно ли катить эту версию consumer в окружение, где работает текущая версия provider. - Уведомляет через 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 не падает.
Два подхода: consumer описывает чего хочет vs провайдер описывает что отдаёт
Простое правило выбора:
- Внутренние микросервисы, ваши команды -> Pact. Consumer-driven отражает реальные зависимости команд.
- Публичный API, OpenAPI-first -> schemathesis/Specmatic. Контракт = открытая документация.
- Оба возможны: Pact для внутренних потребителей + OpenAPI для публики.
Vs end-to-end тесты
Логичный вопрос: «зачем Pact, если можно поднять обе сервиса в staging и прогнать e2e?» Ответ — масштабируемость и скорость.
Pact решает проблему e2e -- медленные, нестабильные, требуют поднятого окружения
Это не значит «выкинуть e2e». E2e всё ещё нужны — для smoke-тестов критических сценариев. Но 90% покрытия даёт contract testing, а 10% — несколько ключевых e2e.
Когда Pact обязателен, когда излишен
Pact — нетривиальная инфраструктура. Brokers, CI integration, provider state setup, координация команд. Не каждому проекту это окупается.
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.
Итог
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.