Микросервисы разрезали монолит на десятки независимых служб, и тут же родилась новая боль. Сервис заказов ходит в сервис склада за остатками. Команда склада переименовала поле в ответе, выкатилась, и сервис заказов сломался, хотя его кода никто не трогал. Классический способ ловить такое это сквозные тесты, поднимающие обе службы разом. Но сквозные тесты медленны, хрупки и требуют живого окружения со всеми сервисами сразу, а при десятках служб это превращается в кошмар координации.

Контрактное тестирование решает задачу иначе и куда дешевле. Вместо того чтобы поднимать обе стороны вместе, оно фиксирует ожидания потребителя к поставщику в виде контракта и проверяет каждую сторону по отдельности. Потребитель описывает, какой ответ он ждёт от поставщика. Поставщик проверяет, что действительно умеет такой ответ выдать. Если поставщик меняет программный интерфейс так, что ломает ожидания потребителя, проверка падает ещё до выкатки, а не на проде в три часа ночи.

Pact это один из самых популярных инструментов контрактного тестирования со стороны потребителя, с поддержкой множества языков от Java и Python до JavaScript и платформы dotnet, что делает его удачным выбором для разноязычной среды микросервисов. Дальше идёт разбор полного рабочего процесса: как потребитель порождает контракт, как поставщик его проверяет, зачем нужен брокер для хранения контрактов и как ворота развёртывания не дают выкатить несовместимую версию. Кода ровно столько, сколько нужно увидеть механику, и весь он в отдельных блоках с разбором вокруг.

Подход со стороны потребителя и его принципиальное отличие от проверки поставщика

Ключевое слово во всей идее это со стороны потребителя. Контракт рождается не там, где служба реализована, а там, где её потребляют. Сервис заказов как потребитель описывает, как именно он ожидает взаимодействовать с сервисом склада, и это описание становится контрактом. Поставщик затем обязан доказать, что соответствует ожиданиям своих потребителей.

Логика тут глубже, чем кажется. Поставщик может реализовать сотню полей и десяток методов, но потребителю важна лишь та горстка, которую он реально использует. Контракт со стороны потребителя фиксирует именно фактическое использование, а не всю поверхность программного интерфейса. Это даёт поставщику бесценную видимость: он узнаёт, какие части его службы кто-то действительно дёргает, а какие поля не нужны никому и могут быть безопасно удалены. Список потребителей и их ожиданий складывается в полную карту реального использования службы.

Сам процесс укладывается в четыре шага. Потребитель пишет тест, описывающий ожидаемое взаимодействие, и это есть контракт. Инструмент порождает из теста файл контракта, тот самый пакт. Поставщик прогоняет проверочные тесты против этого файла, доказывая, что способен выполнить контракт. И если поставщик меняет программный интерфейс так, что ломает контракт, тест падает до развёртывания. Контрактное тестирование при этом не столько технический приём, сколько техника сотрудничества: оно выстраивает ясные линии общения между командами служб и теми, кто их потребляет.

Тест потребителя порождает контракт на конкретном примере

Начнём со стороны потребителя, потому что именно там рождается контракт. Команда сервиса заказов пишет тест, который описывает ожидаемое взаимодействие с сервисом склада. Тест объявляет, что при запросе остатков по товару ожидается ответ определённой формы. Pact поднимает у потребителя фиктивного поставщика, который отвечает ровно так, как описано, и потребительский код проверяется против этого фиктивного ответа.

const { PactV3, MatchersV3 } = require('@pact-foundation/pact')
const { like, integer } = MatchersV3

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'InventoryService',
})

provider
  .given('товар 7 есть на складе')
  .uponReceiving('запрос остатков по товару 7')
  .withRequest({ method: 'GET', path: '/inventory/7' })
  .willRespondWith({
    status: 200,
    body: { sku: like('A1'), stock: integer(42) },
  })

Тонкость, которую важно ухватить, спрятана в сопоставителях. В описании ответа стоит не точное число сорок два, а указание, что там должно быть целое число. Это принципиально. Контракт фиксирует форму и тип данных, а не конкретные значения, потому что значения меняются от запроса к запросу, а ломает интеграцию именно изменение структуры. Сопоставители вроде требования похожести или целочисленности делают контракт устойчивым к нормальной изменчивости данных и чувствительным к настоящим поломкам формы.

После прогона теста инструмент порождает файл контракта. Это документ, описывающий каждое ожидаемое взаимодействие: какой запрос потребитель шлёт и какой ответ ожидает в терминах формы. Файл и есть тот пакт, который дальше отправится поставщику на проверку.

Брокер как централизованное хранилище контрактов между командами

Файл контракта бесполезен, если лежит на машине одного разработчика. Его нужно где-то хранить так, чтобы команда поставщика могла его забрать и проверить, а команда потребителя обновить. Эту роль играет брокер контрактов, центральное хранилище, через которое стороны обмениваются пактами. Потребитель публикует свой контракт в брокер с правильным версионированием и тегами, поставщик забирает его оттуда для проверки.

# потребитель публикует контракт в брокер
pact-broker publish ./pacts \
  --consumer-app-version="$GIT_SHA" \
  --branch="$GIT_BRANCH" \
  --broker-base-url="$PACT_BROKER_URL"

Брокер даёт гораздо больше, чем просто склад файлов. Он хранит все версии контрактов, помечает их тегами окружений вроде стейджинга и продакшена, и рисует полную карту зависимостей: какие потребители зависят от какого поставщика. Команда поставщика видит в брокере всех своих потребителей и их ожидания в одном месте, что вскрывает неиспользуемые поля и недопонимания о том, как службу на самом деле используют. Версионирование тут критично, потому что в любой момент в разных окружениях живут разные версии служб, и брокер должен знать, какая версия контракта какой версии службы соответствует.

Правильное версионирование завязывают на идентификатор коммита и ветку, чтобы каждый контракт однозначно привязывался к конкретному состоянию кода. Это позволяет брокеру точно отвечать на вопрос совместимости конкретных версий потребителя и поставщика, а не абстрактных служб вообще.

Поставщик проверяет контракт и роль состояний поставщика

Получив контракт из брокера, поставщик доказывает, что способен его выполнить. Проверочный тест поставщика берёт каждое взаимодействие из контракта, шлёт описанный запрос реальному поставщику и сверяет, что ответ соответствует ожидаемой форме. Результат проверки публикуется обратно в брокер, чтобы обе стороны видели статус совместимости.

# поставщик проверяет контракт и публикует результат
PACT_PUBLISH_VERIFICATION_RESULTS=true pytest -m provider

Здесь всплывает важное понятие состояний поставщика. Контракт часто описывает взаимодействие в определённых условиях, например запрос остатков по товару, который есть на складе. Чтобы проверить такое взаимодействие, поставщик должен сначала привести себя в нужное состояние, то есть подготовить данные так, чтобы товар действительно был на складе. Эти подготовительные шаги и называются состояниями поставщика, они задаются для каждого условия из контракта.

Состояния поставщика это одновременно сила и слабость подхода. Сила в том, что они позволяют проверять контракт детерминированно, в точно заданных условиях. Слабость в том, что сложные состояния тяжело поддерживать. Поэтому опытная рекомендация звучит так: держите состояния поставщика минимальными. Чем сложнее подготовка состояния, тем труднее её сопровождать, а значит, программные интерфейсы стоит проектировать так, чтобы они проверялись простой настройкой. Простое состояние это признак здорового дизайна службы.

Отдельно стоит отметить, что проверочные тесты поставщика разумно гонять в его собственном пайплайне, а не в общем окружении. Контрактные тесты должны быть быстрыми, изолированными и срабатывать на каждом изменении, что несовместимо с медленным общим стендом.

Ворота развёртывания не дают выкатить несовместимую версию

Вся машинерия контрактов обретает смысл в момент выкатки, и инструментом этого момента служат ворота развёртывания. Прежде чем выкатить любую службу, специальный механизм спрашивает брокер, безопасно ли это: совместима ли разворачиваемая версия со всеми версиями её партнёров в целевом окружении. Если совместимость подтверждена проверками, выкатка разрешается, если нет, она блокируется.

# проверить, можно ли выкатывать в продакшен
pact-broker can-i-deploy \
  --pacticipant OrderService \
  --version="$GIT_SHA" \
  --to-environment production

Принципиальная оговорка о том, что эти ворота надо делать обязательными. Без автоматических ворот развёртывания результаты проверки контрактов носят лишь рекомендательный характер: они что-то говорят, но ничего не запрещают, и команда легко выкатывает несовместимую версию вопреки красному статусу. Только превращение ворот в обязательный блокирующий шаг пайплайна даёт настоящую защиту. Поэтому зрелые команды автоматизируют этот вопрос совместимости и завязывают на него разрешение выкатки.

Есть и важная оговорка про порядок внедрения, продиктованная природой подхода как техники сотрудничества. Поскольку контрактное тестирование это в первую очередь способ наладить общение между командами, начинать стоит с согласия команд по процессу, а не с навязывания обязательных блокирующих ворот сразу. Сначала простейшая ненавязчивая настройка, чтобы оценить, подходит ли подход проекту, потом постепенное ужесточение. Внедрённые силой блокирующие шаги без согласия команд вызывают отторжение и саботаж.

Контракты для асинхронных сообщений и встраивание в пайплайн

Микросервисы общаются не только синхронными запросами, но и через очереди сообщений, и контрактное тестирование покрывает оба случая. В асинхронном варианте контракт описывает не пару запрос-ответ, а форму сообщения, которое поставщик кладёт в очередь, а потребитель оттуда читает. Логика та же: потребитель фиксирует, какие поля сообщения он реально использует, а поставщик доказывает, что порождает сообщения нужной формы. Это важно, потому что событийная архитектура страдает от тех же поломок формы, что и синхронная, только обнаружить их ещё труднее из-за отложенной природы обмена.

Встраивание в пайплайн непрерывной интеграции укладывается в понятную последовательность шагов на обеих сторонах. На стороне потребителя пайплайн прогоняет контрактные тесты и публикует свежий контракт в брокер при каждом изменении. На стороне поставщика пайплайн забирает контракты из брокера, прогоняет проверку и публикует результат обратно. Перед выкаткой обе стороны спрашивают ворота развёртывания о совместимости.

# типичная цепочка шагов потребителя в пайплайне
npm test                          # прогон контрактных тестов
pact-broker publish ./pacts ...   # публикация контракта
pact-broker can-i-deploy ...      # проверка перед выкаткой

Тонкость в том, что публиковать результаты проверки в брокер обычно нужно только из пайплайна, а не при локальном прогоне разработчика. Локальный запуск тестов служит для отладки и не должен засорять брокер промежуточными результатами, поэтому переменную публикации результатов включают исключительно в окружении сборки. Эта мелочь спасает брокер от шума и сохраняет его картину совместимости достоверной.

Ещё одна практика зрелых команд состоит в том, чтобы относиться к падению контрактного теста как к инциденту продакшена. Упавший контракт означает, что прямо сейчас две службы несовместимы, и игнорировать это так же опасно, как игнорировать боевую аварию. Такое отношение держит контрактную дисциплину живой, а не превращает её в формальность, на которую все махнули рукой.

Двунаправленный режим и когда контрактное тестирование уместно

Существует разновидность подхода, снимающая часть нагрузки с поставщика. В двунаправленном режиме поставщику не нужно добавлять проверочный код. Вместо этого он публикует формальное описание своего программного интерфейса в виде спецификации, а платформа сама сличает контракт потребителя с этой спецификацией и проверяет совместимость. Со стороны потребителя ничего не меняется, он по-прежнему описывает ожидания и порождает контракт. Удобство в том, что от поставщика требуется лишь поддерживать актуальное описание интерфейса, которое у многих команд и так есть, без написания отдельных тестов.

Где контрактное тестирование уместно. Прежде всего во внутренней архитектуре микросервисов, где команда контролирует обе стороны общения и ценит знание не только о том, кого служба потребляет, но и о том, кто потребляет её саму. Оно сильно там, где много служб на программных интерфейсах или асинхронных сообщениях, где нужна координация жизненного цикла контрактов между несколькими командами, и где важна возможность независимых выкаток без общего стенда.

Где оно избыточно или неуместно. Контрактное тестирование плохо подходит для проверки внешних программных интерфейсов, которые команда не контролирует, ведь чужого поставщика нельзя заставить проверять контракт. Оно избыточно для простой системы из пары служб, где сквозных тестов хватает с запасом, а накладные расходы на брокер и процесс не окупаются. И оно не заменяет функциональное тестирование самой логики служб, проверяя лишь совместимость их стыка, а не правильность поведения внутри.

Итог укладывается в одну мысль. Сквозные тесты ловят несовместимость служб дорого и поздно, требуя поднимать всё разом в живом окружении. Контрактное тестирование со стороны потребителя ловит ту же несовместимость дёшево и рано, проверяя каждую сторону по отдельности против общего контракта. Потребитель порождает контракт из своих реальных ожиданий, брокер хранит контракты и рисует карту зависимостей, поставщик доказывает соответствие через проверку с минимальными состояниями, а обязательные ворота развёртывания не дают выкатить несовместимую версию. Pact делает это удобно для разноязычной среды, но главная его ценность не в технике, а в сотрудничестве: контракт это общий язык между командами, и внедрять его надо через согласие, а не через силу. Команда, которая выстроила этот процесс, выкатывает службы независимо и уверенно. Команда, которая полагается на сквозные тесты при десятках служб, тонет в хрупкой координации и ловит поломки уже на проде.