Files

2 lines
22 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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: &quot;active&quot;</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 === &quot;active&quot;</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 - &lt;&lt;'NODE'\nconst response = {\n status: 200,\n body: { state: 'active', renewalAt: null },\n};\n\nconst schemaAccepts =\n response.status === 200 &amp;&amp;\n response.body.state === 'active' &amp;&amp;\n 'renewalAt' in response.body &amp;&amp;\n (response.body.renewalAt === null ||\n typeof response.body.renewalAt === 'string');\n\nconst renewalScenarioAccepts =\n schemaAccepts &amp;&amp; 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>"}