Контрактные тесты с Pact: где спасает, а где это оверкил
Контрактное тестирование закрывает пробел между unit-тестами и e2e: проверяет, что договор между двумя сервисами соблюдается. Кейс из жизни: фронт делает запрос на /api/orders, бекенд переименовал поле total в amount, e2e падает уже на стейдже, разработчик откатывает релиз. С контрактными тестами это ловится в момент билда, на стороне поставщика API, ещё до пуш-апрува.
Pact — самая известная библиотека для contract testing. Я гоняла её на трёх проектах, и не на каждом она прижилась. Покажу, где Pact реально спасает, где это оверкил, и какие альтернативы дешевле.
Что делает Pact
Идея простая. Есть consumer (тот, кто зовёт API) и provider (тот, кто его предоставляет). Consumer пишет тесты, в которых описывает «при таком запросе ожидаю такой ответ». Pact-библиотека из этих тестов генерирует JSON-файл — pact-контракт. Файл публикуется в общий брокер.
На стороне provider в CI запускается верификация: Pact дёргает реальный сервис теми запросами из контракта и сравнивает ответы. Если consumer ждёт поле total, а provider теперь возвращает amount — верификация падает.
Минимальный пример: consumer
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { fetchOrder } from '../src/orders-client';
const provider = new PactV3({
consumer: 'web-frontend',
provider: 'orders-service',
dir: path.resolve(__dirname, 'pacts'),
});
describe('OrdersClient', () => {
it('получает заказ по id', async () => {
await provider
.given('order o-1 exists')
.uponReceiving('a request for order o-1')
.withRequest({ method: 'GET', path: '/api/orders/o-1' })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 'o-1',
total: MatchersV3.decimal(199.99),
status: MatchersV3.regex(/^(new|paid|shipped)$/, 'paid'),
},
});
await provider.executeTest(async (mock) => {
const order = await fetchOrder(mock.url, 'o-1');
expect(order.id).toBe('o-1');
expect(order.total).toBe(199.99);
});
});
});
На выходе теста в pacts/web-frontend-orders-service.json появляется контракт. Дальше его нужно опубликовать в брокер.
Минимальный пример: provider
На бекенде (Python, FastAPI):
from pact import Verifier
verifier = Verifier(
provider="orders-service",
provider_base_url="http://localhost:8000",
)
success, _ = verifier.verify_with_broker(
broker_url="https://pact.example.com",
publish_version="1.42.0",
consumer_version_selectors=[{"deployedOrReleased": True}],
enable_pending=True,
state_change_url="http://localhost:8000/_pact/states",
)
assert success == 0
Бекенд должен поднять эндпоинт /_pact/states, который перед каждым контрактом приводит сервис в нужное состояние («заказ o-1 существует»). Это самая трудоёмкая часть — без хорошего state setup на проде не покрыть много кейсов.
Когда Pact реально спасает
Микросервисная архитектура с независимыми командами
5+ команд, каждая владеет своим сервисом, релизы независимые. Договоры между сервисами устные, ломаются регулярно. На onboarding нового сервиса — дни на интеграцию и неделя на «поймать все косяки».
Здесь Pact даёт реальный выигрыш. Каждая команда видит, какие consumers зависят от её API, в каких полях. Любая правка эндпоинта приводит к падению верификации, разработчик не может смержить PR, пока не договорится с consumers.
Внешние клиенты API
Если у тебя публичный API, который дергают мобильные приложения и сторонние клиенты, контракты — золото. Они фиксируют поведение, которое нельзя ломать без бамп мажорной версии. Брокер становится живой документацией: что ждут клиенты прямо сейчас.
Замена части e2e на быстрые проверки
Часть кейсов, которые раньше приходилось проверять полным e2e (поднять фронт, бекенд, БД, прокликать), теперь можно проверить контрактом. Быстрее на порядок. У меня на одном проекте Pact заместил ~30% e2e и сократил время прогона с 25 до 12 минут.
Когда Pact оверкил
Маленькая команда с монолитом
2–3 разработчика, один репозиторий, один билд. Здесь контракт между «фронт-частью репы» и «бекенд-частью репы» проще обеспечить TypeScript-типами, сгенерированными из OpenAPI или GraphQL-схемы. Pact добавляет инфраструктуру (брокер, верификации, state changes) без выгоды.
Один consumer на один provider
Если у бекенд-сервиса единственный клиент — фронт-приложение, и они в одной команде, ценность Pact падает. Верификация по типам и e2e на ключевые сценарии закрывает риск.
Меняющийся API на ранней стадии
В первые месяцы продукта API меняется ежедневно. Контракты переписываются чаще тестов. Поддержка Pact-инфраструктуры в это время — обуза. Лучше вернуться к идее «контракт — это OpenAPI», и проверять только на смоук-уровне.
Альтернативы Pact
OpenAPI + dredd / schemathesis
Если у тебя единый OpenAPI-спека, тулы вроде schemathesis генерируют тесты прямо из неё. Schemathesis на Python:
pip install schemathesis
schemathesis run https://api.example.com/openapi.json \
--hypothesis-deadline=2000 \
--checks all
Это property-based тесты на основе схемы. Ловят случаи «вернули 500 на пустую строку», «отдают неверный enum». На простых API закрывают 70% контрактных рисков.
Сравнение типов через codegen
Бекенд генерирует TypeScript-типы из своего OpenAPI или Protobuf. Фронт импортирует. Любое изменение поля ломает компиляцию фронта. Не «контрактный тест», а «контрактный билд», но во многих кейсах эффективнее.
Spec-first GraphQL
В GraphQL контракт встроен в схему. Изменение поля без deprecation breaks консьюмеров на этапе валидации запроса. Контрактные тесты как отдельный слой — практически не нужны.
Что попробовала и не зашло
- Pact на сервисе с одним consumer-ом. Поставила, поддерживала полгода, реальная польза — два пойманных бага. Цена — десятки часов разработчиков на инфраструктуру и state changes. Сняли.
- Pact в режиме «consumer не публикует контракт, provider тестирует против последней версии». Это не контрактные тесты, это просто mock-тесты бекенда. Pact в этом режиме лишний.
- Сложные state changes через UI: «логинись, создавай заказ, оплачивай». Долго, нестабильно. State changes должны делаться через служебные эндпоинты или прямую запись в БД, не через клик.
- Замена e2e на Pact полностью. Pact не проверяет UI-логику, фронтовые валидации, маршрутизацию. Минимальный e2e-набор всё равно нужен.
Чек-лист: брать ли Pact
- У тебя 3+ независимые команды на бекенде? — рассмотри.
- У одного API больше одного consumer-а? — рассмотри.
- Есть выделенный человек на инфраструктуру тестов? — рассмотри.
- Backend готов писать state-change эндпоинты? — обязательно для серьёзного применения.
- API живой и часто меняется ежедневно? — пока подожди, через пару месяцев вернись.
- Один монорепозиторий и одна команда? — типы из OpenAPI выгоднее.
Pact — отличный паттерн для крупных распределённых систем и обуза для маленьких команд. Прежде чем вкатываться, посчитай: сколько сервисов, сколько команд, сколько consumers на provider. Если цифры не складываются в пользу Pact, контрактные риски решаются проще: схема в OpenAPI, генерация типов, smoke-e2e на главные потоки. Это закрывает 80% случаев и экономит десятки часов в спринт.