2 lines
22 KiB
JSON
2 lines
22 KiB
JSON
{"index":160,"slug":"editorial-2023-07-field-contract-tests","title":"Контракт API прошёл schema-проверку, но сломал сценарий: как найти расхождение смысла","excerpt":"Валидный JSON не доказывает совместимость consumer и provider. Разбираем случай с nullable-полем, отделяем форму ответа от его смысла и задаём проверяемый шлюз перед выпуском.","contentHtml":"<p>Представим знакомый симптом: frontend отправляет запрос, получает <code>200</code>, ответ проходит проверку OpenAPI-схемы, но на экране не появляется действие, ради которого пользователь открыл страницу. В ответе есть <code>state: "active"</code>, а <code>renewalAt</code> равен <code>null</code>. С точки зрения формы JSON корректен. С точки зрения экрана продления дата обязательна, поэтому кнопка остаётся недоступной.</p><p>Цена такой ошибки — не только сломанный экран. Команда видит зелёный schema-check и может решить, что provider совместим с клиентом. Затем она либо выпускает несовместимую версию, либо откатывает полезное изменение, не установив причину. Разберём, какую проверку добавить между «ответ имеет правильную форму» и «пользовательский сценарий может продолжиться».</p><p>Ниже не отчёт о конкретном production-инциденте, а воспроизводимый учебный сценарий. Он отделяет три утверждения: схема допускает тело, consumer умеет обработать тело, а provider действительно возвращает его при нужном состоянии данных. Только последнее проверяется на работающем provider.</p><h2>Что именно проверяет schema</h2><p>Schema описывает структуру сообщения: обязательные свойства, типы, перечисления, формат, статус ответа и допустимость <code>null</code>. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12 и может описывать входные и выходные данные. Если контракт допускает <code>renewalAt: null</code>, валидатор не обязан знать, что конкретному экрану для этого значения нужна отдельная ветка.</p><p>Это полезная, но ограниченная гарантия. Схема отвечает на вопрос «можно ли представить этот JSON в объявленной форме?», а не «сможет ли каждый consumer выполнить свой сценарий?». Одно и то же поле может быть необязательным для списка подписок и обязательным для экрана продления.</p><pre><code>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</code></pre><p>В этом фрагменте свойство обязательно как ключ, но его значение может быть <code>null</code>. Такая модель честно описывает форму. Она не говорит, что при <code>state: active</code> дата обязана существовать. Если это бизнес-правило, его нужно выразить отдельным условием, полем вроде <code>canRenew</code> или interaction конкретного consumer.</p><h2>Где появляется расхождение смысла</h2><p>В HTTP-интеграции consumer — приложение, которое отправляет запрос, а provider — приложение, которое возвращает ответ. Consumer-driven contract фиксирует не всю модель provider, а минимальный обмен, который нужен конкретному consumer: запрос, ожидаемый ответ и, если требуется, состояние provider.</p><p>Допустим, frontend строит экран по правилу: при <code>state: active</code> и строковой <code>renewalAt</code> показываем кнопку продления; при <code>null</code> показываем состояние «дата недоступна» и не создаём действие с выдуманной датой. Это expectation одного сценария, а не общая истина обо всех клиентах API.</p><p>Плохой контракт проверяет только <code>state === "active"</code>. Он пропустит ответ, с которым реальный экран не может продолжить работу. Слишком строгий контракт тоже вреден: если consumer не использует <code>displayName</code>, требование этого поля запретит provider безопасно убрать ненужную деталь. Контракт должен фиксировать используемое поведение, а не копировать весь ответ.</p><table><caption>Как читать результат проверки</caption><thead><tr><th>Наблюдение</th><th>Что доказано</th><th>Чего ещё нет</th><th>Следующий шаг</th></tr></thead><tbody><tr><td><code>200</code> и schema PASS</td><td>Ответ соответствует объявленной форме</td><td>Consumer может выполнить сценарий</td><td>Проверить expectation поля и ветки UI</td></tr><tr><td>Consumer test PASS</td><td>Клиент отправляет запрос и понимает ожидаемый пример</td><td>Работающая версия provider отдаёт его</td><td>Опубликовать contract и запустить provider verification</td></tr><tr><td>Provider verification PASS</td><td>Provider ответил ожидаемым образом в заданном состоянии</td><td>Все сценарии и окружения покрыты</td><td>Проверить список consumer, версии и матрицу</td></tr><tr><td><code>can-i-deploy</code> PASS</td><td>Broker нашёл совместимые версии в окружении</td><td>Бизнес-правило не ошибочно и не забыты внешние зависимости</td><td>Оставить функциональные, e2e и операционные проверки</td></tr></tbody></table><h2>Минимальный воспроизводимый пример</h2><p>Сначала проверим расхождение без сети и тестового фреймворка. Команда создаёт тот же ответ, который валиден по форме, и сравнивает две разные проверки. Она запускается в Node.js 18 или новее.</p><pre><code>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</code></pre><p>Результат не доказывает дефект provider. Он доказывает несовпадение между формулировками «<code>null</code> разрешён типом» и «экрану продления нужна дата». Владелец consumer должен выбрать договорённость:</p><ol><li><strong>Разные состояния.</strong> Provider возвращает <code>active</code> только вместе с датой, а отсутствие даты получает состояние, например <code>pending_renewal</code>.</li><li><strong>Явная возможность действия.</strong> Ответ содержит <code>canRenew: true|false</code>, а дата обязательна только при <code>canRenew: true</code>.</li><li><strong>Безопасная ветка consumer.</strong> <code>null</code> остаётся допустимым, но consumer скрывает кнопку, показывает объяснение и не подставляет текущую дату.</li></ol><p>Выбор зависит от смысла поля, обратной совместимости и того, какие старые версии consumer уже работают с provider. Удобство валидатора не является бизнес-правилом.</p><h2>Как зафиксировать interaction</h2><p>Для contract test опишите один сценарий как самостоятельную interaction. Название должно объяснять состояние и потребность, а не только HTTP-метод: «активная подписка с доступным продлением возвращает дату». В запросе оставьте только те заголовки, параметры и поля, которые действительно формирует клиент.</p><pre><code>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});</code></pre><p>Синтаксис зависит от языка и версии Pact, поэтому фрагмент показывает структуру, а не готовый файл для любого проекта. Настоящий тест обязан вызывать ваш API-клиент, а не повторять запрос через случайный <code>fetch</code> из теста. Иначе контракт может быть зелёным, хотя рабочий клиент отправляет другой URL или неверно разбирает ответ.</p><p>Добавьте отдельную interaction для недоступного продления, если consumer должен её обрабатывать: например, <code>canRenew: false</code> и <code>renewalAt: null</code>. Не смешивайте состояния в одном тесте и не делайте тесты зависимыми друг от друга. Provider verification должна уметь подготовить каждое состояние независимо.</p><h2>Как проверить настоящий provider</h2><p>Consumer test работает с mock provider и формирует pact-файл. Он отвечает на вопрос «понимает ли consumer ожидаемый обмен и формирует ли правильный запрос?». Следующий шаг — provider verification: Pact воспроизводит interaction против настоящего provider и сравнивает фактический ответ с минимальным ожидаемым.</p><p>Перед каждой interaction provider должен попасть в названное состояние: запись подписки существует, дата рассчитана, авторизация разрешена. State setup не должен зависеть от порядка других тестов. Если provider использует базу или downstream-сервис, настройте изолированный fixture или стабилизируйте зависимость в рамках тестовой архитектуры.</p><figure><img src=\"/assets/editorial/2023/contract-tests-2023-verification-gate.svg\" alt=\"Шлюз выпуска: consumer scenario формирует versioned contract, provider state подготавливает данные, verification даёт evidence для решения о deploy\" loading=\"lazy\" /><figcaption>Форма ответа — только первый шлюз. Для решения о выпуске нужны конкретная interaction, состояние provider, версии приложений и опубликованный результат verification.</figcaption></figure><p>Лог с телом <code>state: active</code> не заменяет verification: он может относиться к другой сборке, среде или данным. В результат включите имя consumer, provider, версию pact, версию provider, provider state, окружение и ссылку на результат. Так failure можно связать с изменением, а не искать его по времени.</p><h2>Как встроить проверку в выпуск</h2><p>Broker связывает версии. Consumer публикует contract, provider получает его и публикует verification. Затем шлюз проверяет версию, которую собираются выпустить, против версий интеграций, уже находящихся в окружении.</p><pre><code># перед 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</code></pre><p>Подставляйте реальный идентификатор сборки и окружение команды. Конкретная версия предпочтительнее <code>latest</code>: результат не меняется из-за гонки параллельных сборок. Для старых Broker документация описывает tag-based режим, но его нельзя смешивать с режимом environments без единой договорённости о том, что означает окружение.</p><p>В pipeline разделите статусы: schema failure останавливает проверку формы; consumer failure означает проблему клиента; provider failure — несовместимость provider с interaction; отсутствие результата — <em>not proven</em>, а не PASS. Выпуск разрешается только после успешной проверки нужных версий.</p><h2>Диагностика по короткой цепочке</h2><p>Когда экран сломался после успешной schema-проверки, не начинайте с обвинения backend или frontend. Идите от наблюдаемого эффекта к самому узкому доказательству.</p><ol><li><strong>Зафиксируйте ответ.</strong> Сохраните URL, метод, статус, <code>Content-Type</code>, тело, версии consumer и provider. Секреты и персональные данные удалите.</li><li><strong>Назовите потребность.</strong> Запишите действие пользователя, нужные поля и ветки для <code>null</code>, неизвестного enum, <code>4xx</code> и <code>5xx</code>.</li><li><strong>Разделите проверки.</strong> Запустите schema validator, затем тест API-клиента на mock provider. Schema PASS не равен пользовательскому PASS.</li><li><strong>Сверьте provider state.</strong> Убедитесь, что verification создаёт именно данные из interaction. Неверное состояние даёт зелёный результат для другого сценария.</li><li><strong>Сопоставьте версии.</strong> Проверьте публикацию pact и verification для конкретных версий и фактическое состояние целевого окружения в Broker.</li><li><strong>Выберите минимальное изменение.</strong> При нарушении формы исправляйте схему или provider. При допустимой форме и незафиксированном смысле уточняйте interaction, поле состояния или обработку consumer.</li></ol><h2>Ограничения и безопасный вывод</h2><p>Contract test покрывает только зафиксированные interactions. Он не перечисляет все ответы provider и не доказывает корректность бизнес-решения. Если consumer забыл проверить отсутствие даты, зелёный contract закрепит неполный сценарий. Это ошибка покрытия.</p><p>Тесты также не заменяют проверки авторизации, feature flags, миграций, таймаутов, повторов, лимитов, производительности и доступности downstream-систем. UI-тест остаётся полезен для композиции экрана, но не должен превращать каждую пиксельную деталь в API interaction.</p><p>Semantic failure не означает автоматически, что provider нужно откатить. Provider мог сохранить корректную широкую семантику, а consumer ошибочно трактовал <code>active</code> как гарантию даты. Сначала установите владельца правила и обратимость изменения. Если consumer не известен, provider state отсутствует или verification не опубликована, выпуск блокируется как недоказанный.</p><h2>Критерий готовности</h2><p>Изменение готово к выпуску, когда для каждого затронутого consumer названы положительные и отрицательные сценарии, schema соответствует фактической модели, API-клиент проверен на mock provider, verification прошла в требуемом состоянии, результат связан с конкретными версиями, а Broker подтверждает совместимость с целевым окружением. Любой неизвестный пункт получает статус «не доказано» и явное действие.</p><p>В нашем примере тело с <code>active</code> и <code>renewalAt: null</code> проходит широкую schema-проверку, но не удовлетворяет сценарию, которому нужна дата. Это сигнал уточнить контракт, но ещё не доказательство поломки provider. Доказательство появится после воспроизведения interaction на provider с корректно подготовленным состоянием.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://spec.openapis.org/oas/v3.1.0.html\" target=\"_blank\" rel=\"noopener\">OpenAPI Specification 3.1.0</a> — Schema Object, типы данных и связь с JSON Schema Draft 2020-12.</li><li><a href=\"https://docs.pact.io/getting_started/how_pact_works\" target=\"_blank\" rel=\"noopener\">Pact: How Pact works</a> — interactions, consumer testing, provider verification и provider states.</li><li><a href=\"https://docs.pact.io/implementation_guides/jvm/provider\" target=\"_blank\" rel=\"noopener\">Pact provider</a> — подготовка provider state перед verification.</li><li><a href=\"https://docs.pact.io/pact_broker/can_i_deploy\" target=\"_blank\" rel=\"noopener\">Pact Broker: Can I Deploy</a> — проверка совместимости версий перед deploy и запись deployment.</li></ul>"}
|