{"index":160,"slug":"editorial-2023-07-field-contract-tests","title":"Контракт API прошёл schema-проверку, но сломал сценарий: как найти расхождение смысла","excerpt":"Валидный JSON не доказывает совместимость consumer и provider. Разбираем случай с nullable-полем, отделяем форму ответа от его смысла и задаём проверяемый шлюз перед выпуском.","contentHtml":"
Представим знакомый симптом: frontend отправляет запрос, получает 200, ответ проходит проверку OpenAPI-схемы, но на экране не появляется действие, ради которого пользователь открыл страницу. В ответе есть state: "active", а renewalAt равен null. С точки зрения формы JSON корректен. С точки зрения экрана продления дата обязательна, поэтому кнопка остаётся недоступной.
Цена такой ошибки — не только сломанный экран. Команда видит зелёный schema-check и может решить, что provider совместим с клиентом. Затем она либо выпускает несовместимую версию, либо откатывает полезное изменение, не установив причину. Разберём, какую проверку добавить между «ответ имеет правильную форму» и «пользовательский сценарий может продолжиться».
Ниже не отчёт о конкретном production-инциденте, а воспроизводимый учебный сценарий. Он отделяет три утверждения: схема допускает тело, consumer умеет обработать тело, а provider действительно возвращает его при нужном состоянии данных. Только последнее проверяется на работающем provider.
Schema описывает структуру сообщения: обязательные свойства, типы, перечисления, формат, статус ответа и допустимость null. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12 и может описывать входные и выходные данные. Если контракт допускает renewalAt: null, валидатор не обязан знать, что конкретному экрану для этого значения нужна отдельная ветка.
Это полезная, но ограниченная гарантия. Схема отвечает на вопрос «можно ли представить этот JSON в объявленной форме?», а не «сможет ли каждый consumer выполнить свой сценарий?». Одно и то же поле может быть необязательным для списка подписок и обязательным для экрана продления.
openapi: 3.1.0\ncomponents:\n schemas:\n Subscription:\n type: object\n required: [state, renewalAt]\n properties:\n state:\n type: string\n enum: [active, paused]\n renewalAt:\n type: [string, 'null']\n format: date-timeВ этом фрагменте свойство обязательно как ключ, но его значение может быть null. Такая модель честно описывает форму. Она не говорит, что при state: active дата обязана существовать. Если это бизнес-правило, его нужно выразить отдельным условием, полем вроде canRenew или interaction конкретного consumer.
В HTTP-интеграции consumer — приложение, которое отправляет запрос, а provider — приложение, которое возвращает ответ. Consumer-driven contract фиксирует не всю модель provider, а минимальный обмен, который нужен конкретному consumer: запрос, ожидаемый ответ и, если требуется, состояние provider.
Допустим, frontend строит экран по правилу: при state: active и строковой renewalAt показываем кнопку продления; при null показываем состояние «дата недоступна» и не создаём действие с выдуманной датой. Это expectation одного сценария, а не общая истина обо всех клиентах API.
Плохой контракт проверяет только state === "active". Он пропустит ответ, с которым реальный экран не может продолжить работу. Слишком строгий контракт тоже вреден: если consumer не использует displayName, требование этого поля запретит provider безопасно убрать ненужную деталь. Контракт должен фиксировать используемое поведение, а не копировать весь ответ.
| Наблюдение | Что доказано | Чего ещё нет | Следующий шаг |
|---|---|---|---|
200 и schema PASS | Ответ соответствует объявленной форме | Consumer может выполнить сценарий | Проверить expectation поля и ветки UI |
| Consumer test PASS | Клиент отправляет запрос и понимает ожидаемый пример | Работающая версия provider отдаёт его | Опубликовать contract и запустить provider verification |
| Provider verification PASS | Provider ответил ожидаемым образом в заданном состоянии | Все сценарии и окружения покрыты | Проверить список consumer, версии и матрицу |
can-i-deploy PASS | Broker нашёл совместимые версии в окружении | Бизнес-правило не ошибочно и не забыты внешние зависимости | Оставить функциональные, e2e и операционные проверки |
Сначала проверим расхождение без сети и тестового фреймворка. Команда создаёт тот же ответ, который валиден по форме, и сравнивает две разные проверки. Она запускается в Node.js 18 или новее.
node - <<'NODE'\nconst response = {\n status: 200,\n body: { state: 'active', renewalAt: null },\n};\n\nconst schemaAccepts =\n response.status === 200 &&\n response.body.state === 'active' &&\n 'renewalAt' in response.body &&\n (response.body.renewalAt === null ||\n typeof response.body.renewalAt === 'string');\n\nconst renewalScenarioAccepts =\n schemaAccepts && typeof response.body.renewalAt === 'string';\n\nconsole.log({ schemaAccepts, renewalScenarioAccepts });\n// { schemaAccepts: true, renewalScenarioAccepts: false }\nNODEРезультат не доказывает дефект provider. Он доказывает несовпадение между формулировками «null разрешён типом» и «экрану продления нужна дата». Владелец consumer должен выбрать договорённость:
active только вместе с датой, а отсутствие даты получает состояние, например pending_renewal.canRenew: true|false, а дата обязательна только при canRenew: true.null остаётся допустимым, но consumer скрывает кнопку, показывает объяснение и не подставляет текущую дату.Выбор зависит от смысла поля, обратной совместимости и того, какие старые версии consumer уже работают с provider. Удобство валидатора не является бизнес-правилом.
Для contract test опишите один сценарий как самостоятельную interaction. Название должно объяснять состояние и потребность, а не только HTTP-метод: «активная подписка с доступным продлением возвращает дату». В запросе оставьте только те заголовки, параметры и поля, которые действительно формирует клиент.
given('active subscription with renewal date')\nuponReceiving('request for renewal details')\n .withRequest('GET', '/subscriptions/42')\nwillRespondWith(200, {\n state: 'active',\n renewalAt: '2030-07-25T10:00:00Z',\n});Синтаксис зависит от языка и версии Pact, поэтому фрагмент показывает структуру, а не готовый файл для любого проекта. Настоящий тест обязан вызывать ваш API-клиент, а не повторять запрос через случайный fetch из теста. Иначе контракт может быть зелёным, хотя рабочий клиент отправляет другой URL или неверно разбирает ответ.
Добавьте отдельную interaction для недоступного продления, если consumer должен её обрабатывать: например, canRenew: false и renewalAt: null. Не смешивайте состояния в одном тесте и не делайте тесты зависимыми друг от друга. Provider verification должна уметь подготовить каждое состояние независимо.
Consumer test работает с mock provider и формирует pact-файл. Он отвечает на вопрос «понимает ли consumer ожидаемый обмен и формирует ли правильный запрос?». Следующий шаг — provider verification: Pact воспроизводит interaction против настоящего provider и сравнивает фактический ответ с минимальным ожидаемым.
Перед каждой interaction provider должен попасть в названное состояние: запись подписки существует, дата рассчитана, авторизация разрешена. State setup не должен зависеть от порядка других тестов. Если provider использует базу или downstream-сервис, настройте изолированный fixture или стабилизируйте зависимость в рамках тестовой архитектуры.
Лог с телом state: active не заменяет verification: он может относиться к другой сборке, среде или данным. В результат включите имя consumer, provider, версию pact, версию provider, provider state, окружение и ссылку на результат. Так failure можно связать с изменением, а не искать его по времени.
Broker связывает версии. Consumer публикует contract, provider получает его и публикует verification. Затем шлюз проверяет версию, которую собираются выпустить, против версий интеграций, уже находящихся в окружении.
# перед deploy: VERSION — конкретная версия приложения\npact-broker can-i-deploy \\\n --pacticipant WebApp \\\n --version VERSION \\\n --to-environment staging \\\n --broker-base-url \"$PACT_BROKER_BASE_URL\"\n\n# после успешного deploy в staging\npact-broker record-deployment \\\n --pacticipant WebApp \\\n --version VERSION \\\n --environment stagingПодставляйте реальный идентификатор сборки и окружение команды. Конкретная версия предпочтительнее latest: результат не меняется из-за гонки параллельных сборок. Для старых Broker документация описывает tag-based режим, но его нельзя смешивать с режимом environments без единой договорённости о том, что означает окружение.
В pipeline разделите статусы: schema failure останавливает проверку формы; consumer failure означает проблему клиента; provider failure — несовместимость provider с interaction; отсутствие результата — not proven, а не PASS. Выпуск разрешается только после успешной проверки нужных версий.
Когда экран сломался после успешной schema-проверки, не начинайте с обвинения backend или frontend. Идите от наблюдаемого эффекта к самому узкому доказательству.
Content-Type, тело, версии consumer и provider. Секреты и персональные данные удалите.null, неизвестного enum, 4xx и 5xx.Contract test покрывает только зафиксированные interactions. Он не перечисляет все ответы provider и не доказывает корректность бизнес-решения. Если consumer забыл проверить отсутствие даты, зелёный contract закрепит неполный сценарий. Это ошибка покрытия.
Тесты также не заменяют проверки авторизации, feature flags, миграций, таймаутов, повторов, лимитов, производительности и доступности downstream-систем. UI-тест остаётся полезен для композиции экрана, но не должен превращать каждую пиксельную деталь в API interaction.
Semantic failure не означает автоматически, что provider нужно откатить. Provider мог сохранить корректную широкую семантику, а consumer ошибочно трактовал active как гарантию даты. Сначала установите владельца правила и обратимость изменения. Если consumer не известен, provider state отсутствует или verification не опубликована, выпуск блокируется как недоказанный.
Изменение готово к выпуску, когда для каждого затронутого consumer названы положительные и отрицательные сценарии, schema соответствует фактической модели, API-клиент проверен на mock provider, verification прошла в требуемом состоянии, результат связан с конкретными версиями, а Broker подтверждает совместимость с целевым окружением. Любой неизвестный пункт получает статус «не доказано» и явное действие.
В нашем примере тело с active и renewalAt: null проходит широкую schema-проверку, но не удовлетворяет сценарию, которому нужна дата. Это сигнал уточнить контракт, но ещё не доказательство поломки provider. Доказательство появится после воспроизведения interaction на provider с корректно подготовленным состоянием.