{ "index": 161, "slug": "editorial-2023-07-mechanism-contract-tests", "title": "Почему schema match не равен совместимости API", "excerpt": "Ответ API может пройти схему и всё равно сломать действие на экране. Разбираем три уровня контракта: форму JSON, ожидание consumer и проверку provider.", "contentHtml": "
Экран получает HTTP 200, обязательные ключи на месте, типы совпадают с OpenAPI. Но кнопка продления не появляется: ответ содержит state: \"active\" и renewalAt: null. Схема допускает оба значения. Consumer ожидал дату, по которой можно показать действие. Пользователь видит неполный сценарий, поддержка получает жалобу, а команда спорит, был ли релиз совместимым.
Цена ошибки растёт из-за ложного зелёного сигнала. Проверка JSON подтверждает форму, но не подтверждает, что consumer сможет закончить свой сценарий. Provider считает, что поле не менялось. Consumer видит изменение смысла. Владелец релиза видит успешный job и не получает основания остановить выкладку.
\nТезис статьи простой: контракт API состоит как минимум из трёх разных доказательств. Schema match проверяет структуру. Consumer expectation проверяет нужное поведение. Provider verification проверяет, что конкретная версия provider действительно отвечает опубликованному interaction. Один результат нельзя выдавать за другой.
\nСначала отделите форму от смысла. OpenAPI описывает интерфейс, который могут использовать люди и инструменты. Schema Object задаёт типы, обязательность, перечисления и допустимые варианты. Это хороший барьер против пропавшего ключа, числа вместо строки и неизвестного значения enum.
\nНо схема не знает, какое действие должен показать конкретный экран. Поле renewalAt может быть nullable для одного клиента и обязательным условием для другого сценария. Поэтому второй уровень должен принадлежать consumer: «для экрана продления активная подписка должна иметь применимую дату». Это уже не только свойство JSON. Это правило принятия решения.
Третий уровень связывает ожидание с provider. Provider verification исполняет interaction на согласованном состоянии provider и сравнивает фактический ответ с контрактом. Без такого результата у команды есть описание ожидания, но нет доказательства, что provider его выполнил.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Ответ 200, экран не показывает действие | Смысл поля шире ожидания consumer | Воспроизвести decision rule на ответе active + null | Уточнить state, добавить явное поле или изменить consumer |
| Пропал ключ или изменился тип | Нарушена schema-граница | Проверить required, type, enum и nullable для версии схемы | Исправить provider либо согласовать версионное изменение |
| Consumer contract зелёный, provider не проверен | Проверили только mock-ответ | Найти результат verification для версии provider и provider state | Не называть выпуск совместимым до реального результата |
| Один interaction зелёный, старый клиент сломан | Contract покрывает не всех consumers | Сверить список клиентов и поддерживаемые версии | Добавить interaction или ограничить решение областью проверки |
Рассмотрим искусственный сценарий GET /v1/subscriptions/sub-42. Имена synthetic-portal-web и synthetic-billing-api нужны только для объяснения механизма. Это не лог реального сервиса, не результат запуска и не утверждение о production.
Общая schema может разрешать такой ответ:
\n{\n \"id\": \"sub-42\",\n \"state\": \"active\",\n \"renewalAt\": null\n}\nНа уровне формы ответ выглядит допустимым. На уровне consumer он не подходит экрану продления: у экрана нет даты и он не должен выдумывать её. Правило можно записать рядом с interaction:
\nconst expectation = ({ state, renewalAt }) =>\n state === 'active' && isFutureIsoDate(renewalAt);\n\nexpect(expectation(response)).toBe(true);\nЭтот код показывает только идею decision rule. Он не валидирует OpenAPI-документ, не вызывает HTTP, не поднимает provider и не запускает Pact. В реальном тесте надо определить формат даты, часовой пояс, момент отсчёта и provider state. Если эти условия не названы, тест может пройти на случайных данных и не защищать нужный сценарий.
\nТеперь различие видно на трёх ответах. active с будущей датой может пройти форму и ожидание. active + null может пройти форму, но нарушить ожидание consumer. Число в state должно остановиться уже на схеме. Такая классификация полезнее единого флага compatible: true: она показывает, где именно возникло расхождение.
Начните с одного действия consumer, а не со всего API. Назовите метод, путь, вход, ожидаемый статус и минимальный ответ. Затем запишите provider state. Формулировка «подписка активна» слишком общая, если экрану нужна именно дата продления после текущего момента. State должен объяснять, почему provider обязан вернуть нужные данные.
\nОтдельно укажите отрицательный путь. Например: если state равен active, но renewalAt отсутствует или уже прошёл, consumer не показывает кнопку продления и сообщает, что действие недоступно. Это не означает, что поле надо сделать non-null для всех клиентов. Отрицательная ветка фиксирует решение одного сценария.
Храните рядом версии. Укажите версию schema, consumer contract, provider и provider state. Версия OpenAPI не заменяет версию API-сборки. Версия consumer не доказывает, какую сборку provider проверяли. Эти указатели нужны, чтобы зелёный результат можно было воспроизвести и связать с конкретным изменением.
\nSchema проверяет допустимость значения, а не его полезность для каждого клиента. Nullable-поле может быть корректным по общему договору и непригодным для конкретного действия. Enum может сохранить прежний набор строк, но поменять бизнес-смысл каждой строки. HTTP 200 может сообщать об успешной обработке запроса, но не о готовности пользовательского шага.
\nConsumer-driven contract помогает сузить проверку до реальной потребности клиента. Он не пытается описать все возможные ответы provider. Это достоинство для быстрого feedback, но и ограничение: неохваченный consumer остаётся неохваченным. Список interactions надо поддерживать вместе со списком клиентов, иначе команда легко перенесёт результат одного экрана на весь API.
\nProvider verification тоже не даёт универсальной гарантии. Она подтверждает конкретные interactions в подготовленных состояниях. Она не заменяет авторизацию, миграцию данных, нагрузочные проверки, совместимость старых мобильных версий и наблюдение после выкладки. Эти проверки отвечают на другие вопросы.
\nУчебный код выше не доказывает совместимость реальных версий. В нём нет сети, broker, Pact, авторизации, зависимостей provider и production-данных. Даже корректный локальный результат означает только то, что правило примера отделяет форму от смысла. Нельзя писать в release-описании «provider verified», если запуск provider verification не состоялся.
\nЕсли verification не прошла, сначала сохраните исходный contract и ответ. Затем решите, где находится граница изменения. Иногда provider должен вернуть прежний смысл. Иногда consumer должен перестать трактовать active слишком узко. Иногда безопаснее добавить новое поле и временно поддержать оба варианта. Автоматически делать nullable-поле обязательным нельзя: это может сломать другие сценарии.
Rollback также требует конкретики. Назовите версии, которые можно вернуть, данные, уже записанные новым кодом, и consumer, который ещё читает старый ответ. Snapshot JSON не откатывает endpoint, базу, флаг или опубликованный артефакт. Если эти условия неизвестны, готовность к rollback не доказана.
\nИзменение готово к выпуску, когда выполнены все четыре условия: schema проверена для нужной версии; consumer expectation содержит положительную и отрицательную ветки; provider state и версия provider названы; provider verification дала сохранённый результат для каждого consumer, которого затрагивает изменение. Если хотя бы одного пункта нет, вывод должен звучать точнее: «форма проверена», «ожидание записано» или «verification не запускалась». Слово «совместимо» оставляйте только для доказанной области.
\n