Files
progcode/editorial/agent-rewrites/161.json
T

8 lines
22 KiB
JSON
Raw 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": 161,
"slug": "editorial-2023-07-mechanism-contract-tests",
"title": "Почему schema match не равен совместимости API",
"excerpt": "Ответ может соответствовать OpenAPI и всё равно сломать действие на экране. Разбираем границу между формой JSON, ожиданием consumer и проверкой provider на конкретном состоянии.",
"contentHtml": "<p>Сбой интеграции часто выглядит обманчиво: HTTP-ответ имеет статус 200, обязательные поля присутствуют, а валидатор схемы сообщает PASS. При этом кнопка продления не появляется. Ответ содержит <code>state: \"active\"</code> и <code>renewalAt: null</code>. Общая схема допускает <code>null</code>, но экрану нужна дата, по которой он может предложить действие. Пользователь получает неполный сценарий, а команда видит зелёный тест и поздно замечает расхождение.</p>\n<p>Причина не в том, что schema validation бесполезна. Она отвечает на один вопрос: допустима ли форма сообщения по описанию? Совместимость интеграции требует ещё двух ответов: получил ли consumer данные, необходимые его сценарию, и проверил ли provider этот конкретный обмен на согласованном состоянии. Эти уровни нельзя сворачивать в один флаг <code>compatible</code>.</p>\n<h2>Три разных значения слова «контракт»</h2>\n<p>В этой статье <strong>schema</strong> — формальное описание структуры сообщения. В OpenAPI Schema Object задаёт типы, обязательность, перечисления, форматы и другие ограничения. OpenAPI 3.1 опирается на JSON Schema Draft 2020-12, но само описание не знает, какое поле нужно конкретной кнопке или какой порядок действий ожидает пользователь.</p>\n<p><strong>Consumer expectation</strong> — проверяемое ожидание клиента. Оно связывает ответ с действием: для активной подписки с датой в будущем экран показывает продление, а для отсутствующей или просроченной даты не показывает его и объясняет недоступность. Это правило принадлежит сценарию клиента, а не общей схеме ресурса.</p>\n<p><strong>Provider verification</strong> — запуск опубликованного набора взаимодействий против provider в заданном состоянии данных. Такой запуск показывает, что конкретная версия provider действительно отвечает ожидаемым запросам. Он не доказывает поведение клиентов, которых в наборе нет, и не заменяет проверки авторизации, нагрузки или миграции данных.</p>\n<table><caption>Что доказывает каждый уровень проверки</caption><thead><tr><th scope=\"col\">Уровень</th><th scope=\"col\">Вопрос</th><th scope=\"col\">Положительный результат</th><th scope=\"col\">Что остаётся неизвестным</th></tr></thead><tbody><tr><td>Schema</td><td>Сообщение имеет допустимую форму?</td><td>Ключи, типы, enum и ограничения соответствуют схеме.</td><td>Подходит ли значение действию конкретного consumer.</td></tr><tr><td>Consumer expectation</td><td>Клиент сможет принять решение?</td><td>Положительная и отрицательная ветки сценария определены.</td><td>Отвечает ли им реальный provider.</td></tr><tr><td>Provider verification</td><td>Provider выполняет interaction?</td><td>Запрос и ответ прошли на указанном provider state.</td><td>Поведение неохваченных клиентов, нагрузку и весь production-контур.</td></tr></tbody></table>\n<h2>Контрпример: допустимый JSON, бесполезный для действия</h2>\n<p>Предположим, endpoint возвращает сведения о подписке. Ниже — минимальная схема OpenAPI 3.1 в YAML. Запись <code>type: [string, 'null']</code> намеренно допускает отсутствие даты: это может быть корректно для отменённой подписки или другого потребителя.</p>\n<pre><code>type: object\nrequired: [id, state, renewalAt]\nproperties:\n id:\n type: string\n state:\n type: string\n enum: [active, canceled]\n renewalAt:\n type: [string, 'null']\n format: date-time</code></pre>\n<p>Ответ <code>active + null</code> соответствует этой форме. Но для сценария продления он недостаточен: экран не знает, когда можно выполнить операцию. Отсюда следует важное разделение: расширение схемы или ужесточение <code>renewalAt</code> для всех клиентов может быть неправильным исправлением. Нужно сначала выяснить, какой смысл требуется именно этому consumer.</p>\n<p>Не менее опасен обратный случай. Provider возвращает будущую дату, а клиент сравнивает её с локальной строкой без часового пояса. Формально поле имеет строковый тип и формат даты, но решение клиента зависит от неверного разбора времени. Здесь schema PASS не отменяет тест на границе времени.</p>\n<figure><img src=\"/assets/editorial/2023/contract-tests-2023-verification-gate.svg\" alt=\"Последовательность проверки API: consumer задаёт сценарий, contract фиксирует запрос и ответ, provider state задаёт данные, verification возвращает PASS или FAIL перед решением о выпуске\" loading=\"lazy\"><figcaption>Каждый блок добавляет отдельное доказательство: схема не заменяет ожидание consumer, состояние provider и сохранённый результат verification.</figcaption></figure>\n<h2>Как превратить ожидание в проверяемое правило</h2>\n<p>Начинайте не с полного API, а с одного пользовательского действия. Зафиксируйте метод, путь, статус, минимальный ответ, условие показа действия и отрицательную ветку. Например: «если <code>state</code> равен <code>active</code>, а <code>renewalAt</code> — дата в будущем относительно часов теста, экран показывает продление; иначе действие скрыто».</p>\n<p>В правило нужно передать часы явно. Иначе тест, выполняющийся около полуночи или границы даты, станет случайным. Нельзя использовать произвольное «сейчас» внутри функции и затем считать результат воспроизводимым. В боевом коде формат даты, часовой пояс и источник времени должны быть частью соглашения команды.</p>\n<pre><code>function canRenew(subscription, now) {\n if (subscription.state !== 'active') return false;\n if (typeof subscription.renewalAt !== 'string') return false;\n\n const renewalAt = Date.parse(subscription.renewalAt);\n return Number.isFinite(renewalAt) &amp;&amp; renewalAt &gt; now.getTime();\n}\n\nconst now = new Date('2023-07-15T10:00:00Z');\nif (!canRenew({\n state: 'active',\n renewalAt: '2023-07-16T10:00:00Z',\n}, now)) throw new Error('future renewal must be available');\n\nif (canRenew({ state: 'active', renewalAt: null }, now)) {\n throw new Error('null renewal must not enable the action');\n}</code></pre>\n<p>Это полностью локальная проверка decision rule: сохраните фрагмент в <code>contract-rule.mjs</code> и запустите <code>node contract-rule.mjs</code>. Скрипт не обращается к сети, не валидирует OpenAPI и не запускает provider. Он доказывает только две ветки выбранного правила. Прежде чем переносить его в проект, добавьте тесты на отменённое состояние, прошедшую дату, некорректную дату и другой часовой пояс.</p>\n<h2>Где заканчивается consumer contract</h2>\n<p>Consumer-driven contract описывает взаимодействие, которое действительно использует клиент. Для него важны сформированный запрос, заголовки, статус, нужные поля и обработка ответа. Такой тест полезен именно своей узкой областью: он быстро показывает, что provider перестал удовлетворять конкретному клиенту.</p>\n<p>Узкая область одновременно создаёт риск. Если в наборе есть только веб-экран, результат нельзя автоматически распространить на мобильное приложение, партнёрский API или старую версию клиента. Список consumers и их interactions должен быть явным. Если неизвестно, кто ещё читает поле, вывод ограничивается проверенным набором.</p>\n<p>Не помещайте в contract test всю бизнес-логику экрана. Проверка «кнопка имеет зелёный цвет» относится к UI или функциональному тесту. Контракт должен зафиксировать, какие данные и ответ нужны для связи между consumer и provider. Решение интерфейса можно проверять отдельным тестом, используя тот же набор граничных ответов.</p>\n<h2>Что именно проверяет provider verification</h2>\n<p>Provider state — это не комментарий «в базе есть активная подписка», а воспроизводимая подготовка данных, при которой interaction имеет смысл. Для примера назовите его так: <code>subscription sub-42 is active and renews after 2023-07-15T10:00:00Z</code>. В описании состояния должны быть известны идентификатор фикстуры, версия provider и момент времени, относительно которого проверяется дата.</p>\n<p>Проверка provider должна выполняться против локально запускаемого экземпляра или экземпляра в CI с контролируемыми зависимостями. Проверка уже развёрнутого общего окружения хуже отвечает задаче быстрого feedback: там труднее подготовить состояние, изолировать внешние сервисы и понять, какая версия обработала запрос. Это не запрет на smoke-тесты в окружении, а граница между ними и provider verification.</p>\n<p>Результат записывайте не только как PASS/FAIL. Нужны версия provider, идентификатор contract, provider state, список interactions, commit или сборка и время запуска. Если результат не найден, корректная формулировка — «consumer contract опубликован, verification не подтверждена», а не «API совместим».</p>\n<table><caption>Минимальная запись для расследования расхождения</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Пример</th><th scope=\"col\">Зачем нужно</th></tr></thead><tbody><tr><td>Consumer</td><td><code>web-renewal</code></td><td>Понимать, чьё ожидание проверялось.</td></tr><tr><td>Interaction</td><td><code>GET /v1/subscriptions/sub-42</code></td><td>Связать ошибку с конкретным обменом.</td></tr><tr><td>Provider state</td><td><code>active, renewal after fixed time</code></td><td>Воспроизвести входные данные.</td></tr><tr><td>Provider version</td><td><code>build-2023-07-15.2</code></td><td>Отличить код, который реально проверяли.</td></tr><tr><td>Verification result</td><td><code>PASS: 1 interaction</code></td><td>Не принять существование contract за его выполнение.</td></tr></tbody></table>\n<h2>Диагностика: симптом → проверка → решение</h2>\n<p>При отказе не начинайте с изменения nullable-поля. Сначала определите уровень, на котором возникло расхождение. Один и тот же экранный симптом может быть следствием формы ответа, семантики данных, часов, provider state или отсутствия самого запуска.</p>\n<table><caption>Карта разбора проблемного ответа</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Первая проверка</th><th scope=\"col\">Ограниченное действие</th></tr></thead><tbody><tr><td>Нет обязательного ключа или изменился тип</td><td>Сверить версию схемы, <code>required</code>, type и media type.</td><td>Исправить provider или согласовать версионное изменение.</td></tr><tr><td>Форма верна, но действие скрыто</td><td>Проверить decision rule, значение поля и фиксированные часы.</td><td>Уточнить смысл consumer или добавить явное поле.</td></tr><tr><td>Consumer зелёный, provider неизвестен</td><td>Найти запись provider verification для той же версии.</td><td>Не расширять область вывода за проверенный consumer.</td></tr><tr><td>Один consumer зелёный, другой сломан</td><td>Сверить список interactions и версий клиентов.</td><td>Добавить отдельный сценарий или ограничить изменение.</td></tr><tr><td>Тест нестабилен у границы даты</td><td>Проверить источник времени, формат и часовой пояс.</td><td>Передавать часы в правило и фиксировать момент в state.</td></tr></tbody></table>\n<h2>Порядок проверки перед изменением API</h2>\n<ol><li><strong>Зафиксируйте наблюдение.</strong> Сохраните безопасные request, response, статус, consumer и действие, которое не завершилось. Не называйте причину до проверки.</li><li><strong>Проверьте форму.</strong> Укажите точную версию OpenAPI или другой schema contract. Сверьте required, type, enum, format, null и media type.</li><li><strong>Запишите решение consumer.</strong> Назовите положительную и отрицательную ветки. Для дат зафиксируйте формат, часовой пояс и источник времени.</li><li><strong>Определите scope.</strong> Составьте список consumers, версий и interactions, которых касается изменение. Не переносите результат одного клиента на весь API.</li><li><strong>Опишите provider state.</strong> Укажите фикстуру, состояние данных, внешние зависимости и версию provider, на которой должен выполняться обмен.</li><li><strong>Запустите два независимых слоя.</strong> Выполните consumer contract и provider verification. Сохраните не только contract, но и результат его проверки.</li><li><strong>Проверьте отрицательный путь.</strong> Прогоните <code>null</code>, просроченную дату, неизвестный enum, ошибку авторизации и отсутствие записи там, где это входит в сценарий.</li><li><strong>Примите решение в границах доказательств.</strong> Для неподтверждённых клиентов оставьте риск, добавьте проверку или остановите несовместимое изменение.</li></ol>\n<h2>Ограничения и безопасный вывод</h2>\n<p>Контрактные тесты не заменяют функциональные, end-to-end, нагрузочные и security-тесты. Они не доказывают корректность бизнес-расчёта для всех данных, доступность базы, задержку сети или работоспособность каждого UI-перехода. Они также не обнаружат consumer, о котором команда не знает и который не попал в набор interactions.</p>\n<p>Учебный endpoint и фикстура в этой статье вымышлены. Команда <code>node contract-rule.mjs</code> проверяет локальный инвариант на двух значениях и не вызывает реальный API. Если проект использует OpenAPI 3.0, правило для nullable оформляется иначе, чем в OpenAPI 3.1; сверяйте версию спецификации и поведение конкретного валидатора. Формат <code>date-time</code> сам по себе не говорит, какую бизнес-зону времени выбрать.</p>\n<p>Безопасный вывод должен быть узким: «ответ соответствует schema», «consumer rule проходит для этих fixtures» или «provider verification прошла для такого-то state». Формулировку «изменение совместимо» оставляйте только тогда, когда перечислены все затронутые consumers и для них есть соответствующие результаты. Если не хватает версии, state или verification result, это пробел в доказательстве, а не зелёный статус.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://spec.openapis.org/oas/v3.1.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.0</a> — назначение OpenAPI, структура Schema Object и граница между описанием типов и семантикой приложения.</li><li><a href=\"https://docs.pact.io/\" target=\"_blank\" rel=\"noopener noreferrer\">Pact Docs: Introduction</a> — различие между статической схемой и проверяемыми consumer/provider interactions.</li><li><a href=\"https://docs.pact.io/consumer\" target=\"_blank\" rel=\"noopener noreferrer\">Pact Docs: Writing Consumer tests</a> — область consumer contract и отличие contract testing от функциональной проверки provider.</li><li><a href=\"https://docs.pact.io/getting_started/provider_verification\" target=\"_blank\" rel=\"noopener noreferrer\">Pact Docs: Provider verification</a> — назначение provider verification и ссылки на реализации.</li><li><a href=\"https://docs.pact.io/provider\" target=\"_blank\" rel=\"noopener noreferrer\">Pact Docs: Verifying Pacts</a> — рекомендация проверять contract на контролируемом локальном provider и ограничение проверки общего deployed instance.</li></ul>"
}