Два сервиса работают вместе месяцами, а затем маленькое изменение в ответе одного API тихо ломает чужой интерфейс в дальнем углу системы. Классический ответ на такую боль, сквозные тесты на общем стенде, находит проблему поздно и стоит дорого. Pact предлагает другой маршрут: потребитель и провайдер проверяют совместимость по отдельности, сверяясь с общим контрактом. Статья разбирает механику Pact, эффект, который он даёт в реальных проектах, и цену, которую команда платит за поддержку контрактов, чтобы решение принималось с открытыми глазами, а не под впечатлением от восторженных туториалов.
Сквозные тесты не спасают от несовместимости ожиданий
Типовая сценка из практики. Команда сервиса заказов переименовывает поле userName в login, обновляет свою документацию, все юнит-тесты зелёные. Через два дня падает фронтенд профиля пользователя, который два месяца ждал своё окно релиза и получил расширенный состав ответа неожиданно. Никто не виноват, а время потеряно на двоих.
Корень беды в том, что между командами нет проверяемого соглашения. Есть устная договорённость, сгенерированная схема на половину правды и надежда, что общий стенд всё поймает. Сквозные тесты действительно ловят такие разрывы, но со смещением по времени на дни и с огромными накладными расходами: общая среда, синхронные релизы нескольких команд, часы прогона, ложные сбои из-за чужих правок. Команда узнаёт о проблеме тогда, когда контекст правки уже выветрился из головы.
Контрактное тестирование сдвигает проверку влево. Вместо совместного прогона сервисов каждая сторона проверяется изолированно против одного и того же артефакта, файла контракта. Ошибку ловят за минуты после коммита, а не после сборки стенда.
Потребительский тест генерирует файл контракта
Pact называет стороны потребитель и провайдер. Потребитель отправляет запросы и зависит от ответа, провайдер их обслуживает. Потребительский тест поднимает встроенный mock-сервер, фиксирует каждый сделанный запрос и связанный с ним ожидаемый ответ, а затем записывает всё в JSON-файл. Получается исполняемая спецификация: не схема на бумаге, а набор конкретных обменов, которые потребитель реально использует.
Пример на клиентской библиотеке для JavaScript, начиная с десятой версии поддерживающей третье поколение API:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike } = MatchersV3;
const provider = new PactV3({
consumer: 'web-client',
provider: 'orders-api',
});
it('возвращает список заказов', async () => {
await provider
.given('в системе есть два заказа')
.uponReceiving('запрос списка заказов')
.withRequest({
method: 'GET',
path: '/orders',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: eachLike({ id: like(12), total: like(1490) }),
})
.executeTest(async (mockServer) => {
const res = await fetch(mockServer.url + '/orders');
const list = await res.json();
expect(res.status).toBe(200);
expect(list).toHaveLength(2);
});
});
Схема OpenAPI, кстати, не решает эту задачу. Она статична и описывает все возможные состояния ресурса сразу, а живёт по факту в отрыве от кода и стареет. Контракт Pact, наоборот, порождается самими тестами и существует только из реально используемых обменов, поэтому врать ему неоткуда.
За один прогон можно задать целую цепочку взаимодействий: сначала создание заказа, затем его чтение, затем отмена. Порядок и зависимости фиксируются, и mock-сервер ведёт себя как аккуратный партнёр, который отвечает ровно тем, о чём договорились.
Здесь важны две вещи. Матчеры like и eachLike описывают форму, а не конкретные значения: провайдер вправе вернуть любой номер заказа и любую сумму, лишь бы тип и вложенность совпали. Метод given задаёт состояние провайдера, о котором договорились заранее, оно понадобится на другой стороне проверки. После прогона рядом с тестами появляется файл вида web-client-orders-api.json, и именно он, а не догадки, становится предметом спора между командами.
Верификация на провайдере ловит разрыв ожиданий
Вторая половина механики происходит на стороне провайдера. Verifier берёт контракт потребителя, воспроизводит записанные запросы против живого приложения, поднятого локально в тестовом окружении, и сверяет фактические ответы с обещанными. Псевдоним состояний из given привязывается к хукам, которые готовят фикстуры: создают записи, выставляют время, подставляют тестовый аккаунт.
Для Node-приложения проверка выглядит компактно:
const { Verifier } = require('@pact-foundation/pact');
new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactUrls: ['./pacts/web-client-orders-api.json'],
providerVersion: '2.3.1',
publishVerificationResult: true,
}).verifyProvider();
Для JVM-стека есть аналогичные интеграции с JUnit 5 и Spring, где верификация запускается как обычный тест по всем опубликованным контрактам сразу.
В JVM-стеке состояние провайдера оформляется аннотацией с тем же псевдонимом, что и в контракте:
@State("в системе есть два заказа")
void prepareOrders() {
orderRepository.save(new Order(12L, 1490));
orderRepository.save(new Order(13L, 720));
}
Ценность не только в факте падения, но и в диагностике. При переименовании поля Verifier показывает точное расхождение: ожидался ключ userName, пришёл login. В сквозном прогоне та же история выглядела бы невнятным отказом где-то внутри цепочки сервисов с вереницей логов. Отдельный плюс: провайдер проверяет только то, что реально использует потребитель. Неиспользуемая часть API остаётся свободной для любых правок, и лишние сбои не появляются.
Ловушка новичков в этом месте: если хуки состояний не описаны, Verifier не может подготовить окружение, часть взаимодействий пропускается, и создаётся обманчивое впечатление надёжности. Состояния провайдера требуют ухода не меньше, чем сами контракты.
Брокер контрактов превращает локальные проверки в конвейер
Обмен JSON-файлами через чат и репозиторий работает при двух сервисах, но рассыпается при десяти. Для этого случая существует Pact Broker, открытый сервис публикации и хранения контрактов, и его облачный вариант PactFlow. Потребительский билд публикует сгенерированный контракт с указанием имени сервиса, версии и ветки. Вебхуки брокера запускают сборку провайдера сразу после публикации, поэтому контраст проверяется автоматически, без напоминаний и ручных обходов.
Главная команда для релизного конвейера звучит так:
pact-broker can-i-deploy \
--pacticipant web-client \
--version 3f8a2c1 \
--to-environment production
Публикация тоже сводится к одной команде внутри потребительского пайплайна:
pact-broker publish ./pacts \
--consumer-app-version 3f8a2c1 \
--branch main \
--broker-base-url https://broker.example.internal
Брокер сопоставляет конкретную версию сервиса с матрицей верификаций всех партнёров и отвечает, безопасен ли выход в заданное окружение. Если потребитель новой версии ещё не проверен против провайдера в продакшене, деплой останавливается с понятной причиной. Появляются и мягкие режимы для новых веток: контракты в статусе работы в прогрессе не блокируют релиз, но требуют верификации в разумный срок. Всё вместе превращает совместимость из постфактум-расследования в критерий входа в окружение.
Реальная польза в предотвращённых ломающих изменениях
Что именно ловит Pact на практике. Переименование и удаление полей, смена типов, сужение допустимых значений, добавление обязательного поля, изменение семантики кодов ошибок, исчезновение эндпоинта. Всё то, что ломает интеграции без падения самих сервисов, потому что каждый по отдельности здоров и доволен собой.
Выгода проявляется тремя слоями. Первый, скорость обратной связи: разрыв обнаруживается в секунды на локальном прогоне или в ближайшем CI, а не на стенде через неделю. Второй, независимость релизов: команды перестают плясать вокруг общего окна деплоя, потому что уверенность в совместимости даёт матрица верификаций, а не синхронный выкат. Третий, дисциплина дизайна API: провайдер не может молча убрать поле, не узнав через упавший контракт, кто и зачем им пользуется. Это полезное социальное давление, оно делает обсуждения изменений предметными.
Отдельная сила, о которой часто забывают: контрактами покрываются не только HTTP-вызовы. Pact умеет фиксировать обмен через очереди, сгенерированное сообщение записывается как ожидаемый состав полезной нагрузки, а издатель и подписчик проверяют его по отдельности. Для событийных архитектур это закрывает ту же дыру, что и для синхронных вызовов: подписчик узнаёт о смене структуры события за секунды, а не по аварии в консьюмере через неделю.
Есть и экономия инфраструктуры. Набор контрактных проверок исполняется за доли секунды и не требует общей среды с очередями и синхронизацией, а полноценный сквозной контур можно оставить в единственном экземпляре для критичных сценариев. В микросервисной системе с десятком команд эта разница конвертируется в ощутимое ускорение всего цикла разработки.
Реальная цена поддержки контрактов, о которой туториалы молчат
Теперь про счёт, который выставляют после внедрения. Контракты надо поддерживать, и это не разовая настройка. Хуки состояний живут своей жизнью: миграции схемы меняют фикстуры, тестовые аккаунты протухают, хуки начинают падать по причинам, далёким от самих контрактов. С развитием API растут и выражения матчеров, вложенные коллекции поверх like превращаются в многоэтажные конструкции, которые не каждый новичок осилит с первого раза.
Комбинаторика тоже играет против. Десять потребителей одного провайдера означают десять контрактов, и правка ответа провайдера требует проверки против всех десяти. Обычно это положительный сценарий, упавший контракт указывает, кому станет плохо, но работа по согласованию не исчезает, она лишь становится видимой. Матрица верификаций в брокере разрастается вместе с числом версий, поэтому команде нужна политика чистки: старые версии удаляются или архивируются, иначе через полгода поиск по интерфейсу превращается в археологию. Библиотеки Pact обновляются, поколения API меняются, генерация JSON-файлов меняет структуру, и CI нужно периодически подпитывать совместимыми версиями. Брокер сам по себе сервис, его нужно хостить, обновлять и чистить от накопленных версий.
Существует и налог на мелочи. Для простых CRUD-эндпоинтов с очевидной логикой контрактные тесты нередко дублируют то, что и так проверяют юнит-тесты с моками. В команде из трёх человек с двумя сервисами Pact чаще оказывается роскошью, чем подспорьем.
Когда Pact окупается и как начать без надрыва
Критерии окупаемости складываются простые. Много сервисов, отдельные команды с независимыми релизами, общие API с реальной историей поломок, несколько потребителей у одного провайдера. Обратная ситуация, один монолит, одна команда, стабильный контракт, почти никогда не оправдывает вложений. Внедрение разумно вести небольшими шагами:
- Выберите одну пару потребитель и провайдер с недавней историей разрывов, чтобы эффект был виден на живом примере;
- Опишите контракт только по фактически используемым полям, не стремясь отразить весь API целиком;
- Поднимите верификацию в CI провайдера и договоритесь о стиле хуков состояний заранее;
- Подключите брокер, настройте публикацию и can-i-deploy на входе в тестовое окружение;
- Расширяйте охват на остальные пары лишь после того, как первые контракты поймали хотя бы один настоящий разрыв.
Честный вывод выглядит так. Pact не заменяет юнит-тесты и не отменяет сквозные сценарии, он сужает нишу последних и делает межсервисные соглашения проверяемым артефактом. Команда платит регулярными небольшими взносами: хуки, матчеры, публикация, обновления. Возврат приходит редкими, но крупными выплатами, когда кто-то ломает контракт, и конвейер останавливает это за минуты до продакшена. Если сервисов много и релизы независимы, первый пойманный разрыв обычно окупает всё внедрение. Если сервисов мало, лучше подождать, пока система дорастёт до настоящей микросервисной.