awesome-everything EN
↑ Обратно к восхождению

Инженерная практика

Безопасная эволюция контрактов и пределы контрактного тестирования

Суть Сломай провайдера безопасно через expand-then-contract, дай pending и WIP pacts впитать новые ожидания, не роняя сборку, и знай жёсткие пределы: контракты тестируют форму, а не бизнес-логику, требуют известных консьюмеров и никогда не заменяют пару настоящих e2e smoke-тестов.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 17 min

Команде pricing нужно переименовать amount_cents в price_minor_units — имя, которое читают три нижестоящих консьюмера. Инстинкт Сэма — сделать это одним PR: переименовать поле, обновить схему, выкатить. Собственные контрактные тесты провайдера остаются зелёными, потому что в брокере лежат лишь текущие pacts консьюмеров, и все они по-прежнему ожидают amount_cents… стоп, нет — они ожидают старое имя, так что верификация против переименованного провайдера падает для всех трёх. Хорошо, гейт поймал это. Но теперь Сэм застрял: он не может задеплоиться, не сломав три команды, и не может заставить три команды меняться с ним в едином шаге. Контрактный гейт сказал ему, что изменение ломающее; он не сказал, как его всё равно выкатить. Весь квартал контрактные тесты тихо заменяли дюжину флакающих кросс-сервисных e2e-наборов — и этот успех ровно и есть причина, по которой никто не заметил тот единственный баг, который они так и не поймали: в прошлом месяце pricing вернул структурно-идеальный amount_cents: -500 для возврата, все контракты прошли, а checkout списал отрицательный баланс. Форма была верна. Число было неверным. Ни один контрактный тест на свете не ассертит этого.

Expand-then-contract: как сломать провайдера, не сломав консьюмеров

У consumer-driven контрактного гейта есть встроенная асимметрия, ощущаемая как ловушка при первом столкновении: он заблокирует ломающее изменение провайдера, но блокировка — не то же, что разрешение. Тебе всё равно надо выкатить переименование. Техника называется expand-then-contract (также параллельное изменение или аддитивное изменение), и она превращает одно ломающее изменение в три неломающих деплоя, каждый из которых держит все контракты зелёными.

  1. Expand. Задеплой провайдера, поддерживающего обе формы, старую и новую, одновременно. Для переименования Сэма это значит возвращать amount_cents и price_minor_units с одним и тем же значением. Каждый существующий pact всё ещё верифицируется — консьюмеры читают amount_cents, и оно по-прежнему там, — так что can-i-deploy говорит «да», и провайдер выкатывается с нулём изменений у консьюмеров.
  2. Migrate. По одному, каждый в своём графике, каждый консьюмер обновляет свой тест на чтение price_minor_units, что перегенерирует его pact на ожидание нового поля. Провайдер уже возвращает его, так что pact каждого мигрировавшего консьюмера верифицируется немедленно. Единого шага нет: три команды мигрируют за три спринта, если хотят.
  3. Contract. Как только can-i-deploy подтверждает, что ни один pact консьюмера в проде больше не ссылается на amount_cents — брокер знает это из матрицы деплоя, — провайдер деплоит версию, удаляющую старое поле. От него больше ничто не зависит, так что удаление неломающее.

Дисциплина в том, что ты никогда не удаляешь на том же шаге, где добавляешь. Окно, где обе формы существуют, — цена эволюции без простоя; can-i-deploy брокера — то, что говорит тебе, когда это окно можно безопасно закрыть. Пропусти шаг expand, и ты снова координируешь переименование в «день флага» через три команды — ровно ту боль, ради устранения которой контракты и существуют.

Pending и WIP pacts: не дай ещё-не-построенной фиче покраснеть провайдера

Expand-then-contract обрабатывает изменение, ведомое провайдером. Зеркальная проблема — изменение, ведомое консьюмером: консьюмер добавляет новое ожидание (новый эндпоинт, новое поле) до того, как провайдер это реализовал. Консьюмер публикует новый pact, следующий прогон верификации провайдера переигрывает его, провайдер пока его не удовлетворяет — и основная сборка провайдера краснеет из-за фичи, к которой команда провайдера даже не приступала. Это задом наперёд: незавершённая работа консьюмера не должна ломать пайплайн непричастного провайдера.

Pending pacts это чинят. Когда консьюмер публикует содержимое контракта, которое провайдер ни разу успешно не верифицировал, брокер помечает его как pending. Провайдер всё равно прогоняет верификацию против него и всё равно отправляет результат обратно консьюмеру — так консьюмер получает честную обратную связь, — но pending-падение не меняет код выхода сборки провайдера. Сборка остаётся зелёной. В тот момент, когда этот pact однажды успешно верифицируется, он переходит из состояния pending; с этого момента падение — настоящая регрессия и уронит сборку. Включается через enablePending: true в конфиге верификации провайдера.

WIP pacts надстраиваются над pending. Pending всё ещё требует, чтобы провайдеру указали, какие pacts подтягивать (по тегу или ветке). WIP pacts (includeWipPactsSince с датой) заставляют провайдера автоматически подтягивать любой новый, ещё-не-верифицированный pact, применимый к нему, — без изменения конфига под каждую фичу консьюмера. Для WIP pacts флаг pending зашит включённым, так что они тоже никогда не уронят сборку. Вместе они позволяют консьюмеру выложить новое ожидание в понедельник, увидеть настоящую обратную связь верификации, а команда провайдера подхватывает его, когда готова, — без красной сборки, давящей на любую из сторон в «день флага».

Состояние pactЧто это значитВлияние на сборку провайдера
Verified (обычный)Провайдер уже удовлетворял этот pact раньшеПадение роняет сборку (настоящая регрессия)
PendingНовое/изменённое содержимое, ещё ни разу не верифицированное, подтянуто по тегуВерифицируется + репортится, но падение НЕ роняет сборку
WIPЛюбой новый неверифицированный pact, авто-подтянутый с датыФлаг pending зашит включённым — никогда не роняет сборку

Bi-directional contract testing: сравни два артефакта, не запуская код провайдера

У классической consumer-driven верификации есть цена, которую большинство команд недооценивает: провайдер обязан исполнять pacts консьюмера. Провайдер запускает свой настоящий сервис, настраивает каждый providerState, переигрывает каждое взаимодействие и ассертит ответы. Это требует, чтобы команда провайдера подключила обработчики состояний и прогоняла тесты консьюмеров, которыми не владеет, — трение, растущее с каждым консьюмером.

Bi-directional contract testing (BDCT) разводит две стороны. Провайдер публикует артефакт, который уже производит, — свою OpenAPI-спецификацию — в брокер, вообще без исполнения тестов на стороне провайдера. Каждый консьюмер публикует свой pact как обычно. Брокер затем статически сравнивает pact консьюмера с OpenAPI провайдера: содержит ли спека каждый эндпоинт, поле и тип, которые ожидает pact? Совместимость — это проверка документа против документа, а не прогон кода. В этом и привлекательность: провайдер не добавляет тестового кода, лишь публикует спеку, которую он, вероятно, и так поддерживает, а брокер говорит обеим сторонам, подходят ли они друг другу.

Цена — доверие, возложенное на спеку. CDC верифицирует ожидания консьюмера против фактического работающего поведения провайдера; BDCT верифицирует их против объявленного поведения провайдера. Если OpenAPI-спека врёт — описывает поле, которое код не возвращает, или упускает то, которое возвращает, — BDCT пропускает контракт, который CDC уронил бы. BDCT честна ровно настолько, насколько честна спека, поэтому зрелые BDCT-сетапы порождают или валидируют спеку из собственных тестов провайдера, а не поддерживают её вручную. Заметь также, что BDCT — фича PactFlow/SmartBear, а не часть OSS Pact Broker.

Почему это работает

Почему «никакого исполнения кода провайдера» — это весь смысл. В большом хозяйстве дорогая часть CDC — верификация провайдера: каждый провайдер должен подняться, засеять N состояний провайдера и переиграть M взаимодействий консьюмеров на каждой релевантной сборке. BDCT заменяет это сравнением строк и типов двух статических документов в брокере, что почти мгновенно и не требует тестового окружения. Ты выкупаешь обратно время CI на стороне провайдера и убираешь координацию обработчиков состояний — ценой доверия тому, что OpenAPI-спека совпадает с задеплоенным кодом. Поэтому senior-ход — выводить спеку из настоящих тестов провайдера: это восстанавливает ground-truth, который у классического CDC был бесплатно, сохраняя при этом дешёвое сравнение BDCT.

Жёсткие пределы: форма — не семантика, а известные консьюмеры — не все консьюмеры

Контрактное тестирование ограничено тем, что контракт способен выразить, и senior заслуживает доверие, называя эти границы прежде, чем кто-то откроет их в проде.

  • Оно тестирует форму и взаимодействие, никогда — бизнес-логику или семантику. Контракт ассертит, что amount_cents — целое; он не может ассертить, что целое — правильная цена, неотрицательно или совпадает с суммой заказа. Возврат, вернувший -500, имел идеальную форму и сломал checkout. Контрактные тесты ловят структурные поломки между сервисами; они слепы к неверным значениям, неверным вычислениям, испорченным данным и сломанным многошаговым воркфлоу.
  • Ему нужны известные консьюмеры. Consumer-driven контракт — это объединение того, что объявляют известные консьюмеры. Для публичного API с неизвестными, внешними консьюмерами некому авторить pacts и нет способа перечислить, от чего зависят, — так что CDC просто неприменим. (Spec-first или BDCT против опубликованного OpenAPI лучше подходят там, но даже это не знает, какие внешние клиенты читают какое поле.)
  • Оно добавляет реальную инфраструктуру. Брокер, который надо держать и защищать, публикация pact, вшитая в CI каждого консьюмера, верификация, вшитая в CI каждого провайдера, гейты can-i-deploy, запись деплоев. Эти накладные расходы оправданы между горсткой внутренних сервисов, часто меняющихся; это перебор для двух сервисов, общающихся через стабильный, редко меняющийся интерфейс.
  • Оно может дать ложную уверенность, когда состояния провайдера расходятся с реальными данными. Верификация прогоняется против данных, которые ты настроил в каждом providerState. Если твои фикстуры опрятнее прода — без null’ов, без legacy-строк, без бардака «валюта когда-то хранилась как строка», — каждый контракт проходит, пока настоящий провайдер, накормленный реальными данными, возвращает формы, которых твои фикстуры никогда не порождали. Зелёные контракты тогда сертифицируют фикцию.

Вот почему senior-решение не «контракты или e2e», а оба, в пропорции. Контрактные тесты могут корректно заменить большую часть кросс-сервисных e2e для известных внутренних консьюмеров — они быстрее, стабильнее, изолируют падающую пару и не нуждаются в полном окружении. Но они не замена паре настоящих end-to-end smoke-тестов, прогоняющих фактически задеплоенные сервисы вместе на приближённых-к-реальным данных. Держи тонкий слой e2e для того, что ловит лишь настоящий прогон: корректность бизнес-логики через переходы, данные, не совпадающие с твоими фикстурами, auth и конфиг, вшитые от края до края. Пирамида — это много контрактных тестов, пара e2e smoke-тестов, а не всё из одного.

Расставь шаги по порядку

Упорядочьте шаги expand-then-contract, чтобы переименовать поле провайдера, не сломав трёх консьюмеров:

  1. 1 Задеплой провайдера, возвращающего И старое поле, И новое поле с одним и тем же значением
  2. 2 can-i-deploy проходит для провайдера — каждый существующий pact консьюмера всё ещё читает старое поле
  3. 3 Каждый консьюмер, в своём графике, переключает свой тест на чтение нового поля, перегенерируя свой pact
  4. 4 Матрица брокера подтверждает, что ни один pact консьюмера в проде больше не ссылается на старое поле
  5. 5 Задеплой провайдера, удаляющего старое поле — удаление теперь неломающее
Викторина

Консьюмер публикует pact для эндпоинта, который провайдер ещё не построил. С включёнными pending pacts, что происходит на основной сборке провайдера?

Викторина

Каждый контрактный тест проходит, и всё же pricing вернул amount_cents: -500 для возврата, и checkout списал отрицательный баланс. Почему контрактное тестирование это не поймало?

Выбери лучший вариант

Контрактные тесты теперь покрывают все ваши известные пары внутренних сервисов. Как им сидеть рядом с end-to-end тестами?

Вспомните перед уходом
  1. 01
    Провайдеру нужно переименовать поле, которое читают три консьюмера, но контрактный гейт это блокирует. Пройдитесь по тому, как это выкатить, и как pending/WIP pacts обрабатывают зеркальный случай изменения, ведомого консьюмером.
  2. 02
    Сопоставьте bi-directional contract testing с классической consumer-driven верификацией и назовите жёсткие пределы, означающие, что контракты не могут заменить все end-to-end тесты.
Итог

Контрактный гейт блокирует ломающее изменение провайдера, но не выкатывает его за тебя; expand-then-contract выкатывает, превращая одно ломающее изменение в три зелёных деплоя — задеплой провайдера, поддерживающего обе формы, старую и новую, мигрируй каждого консьюмера в его графике, затем удали старое поле, как только can-i-deploy подтвердит через матрицу, что ничто в проде его больше не читает, никогда не удаляя на том же шаге, где добавляешь. Зеркальная проблема — консьюмер, публикующий ожидание до того, как провайдер его построит: pending pacts позволяют провайдеру верифицировать и репортить это содержимое, пока падение остаётся неблокирующим, пока pact не верифицируется однажды, после чего падение — настоящая регрессия; WIP pacts расширяют это, авто-подтягивая любой новый неверифицированный pact с даты с зашитым включённым pending, так что незавершённая работа консьюмера никогда не краснит сборку провайдера. Bi-directional contract testing разводит стороны иначе — провайдер публикует лишь свою OpenAPI-спеку, а брокер статически сравнивает каждый pact с ней без прогона кода провайдера, дёшево и с малым трением, но честно ровно настолько, насколько честна спека, так что выводи спеку из настоящих тестов провайдера. Жёсткие пределы ограничивают всё это: контракты верифицируют форму и взаимодействие, никогда — бизнес-логику или семантику, так что идеально-сформированное неверное значение вроде amount_cents: -500 проскальзывает насквозь; им нужны известные консьюмеры, так что они не подходят публичным API с неизвестными клиентами; они добавляют накладные расходы брокера и CI, которые стоит платить лишь между часто меняющимися внутренними сервисами; и они дают ложную уверенность, когда фикстуры состояний провайдера чище продовых данных. Senior-решение — это пропорция, а не исключительность: контракты корректно заменяют большую часть кросс-сервисных e2e для известных внутренних консьюмеров, но пара настоящих end-to-end smoke-тестов на задеплоенных сервисах остаётся незаменимой для ловли корректности значений, данных, расходящихся с фикстурами, и вшитого auth, которые открывает лишь настоящий прогон.

Связанные уроки
Продолжить восхождение ↑Контрактное тестирование: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.