8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"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) && renewalAt > 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>"
|
||
}
|