Files
progcode/editorial/agent-rewrites/162.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
20 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": 162,
"slug": "editorial-2023-07-practice-contract-tests",
"title": "Контрактные тесты API: как поймать совместимое на вид изменение",
"excerpt": "HTTP 200 и валидная схема ещё не означают, что consumer сможет продолжить сценарий. Разбираем смысловой контракт, provider verification и критерий готовности.",
"contentHtml": "<p>API возвращает <code>200 OK</code>. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле <code>state</code> осталось строкой <code>active</code>, но поле <code>renewalAt</code> стало <code>null</code>. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.</p>\n<p>Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.</p>\n<p>Тезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.</p>\n<h2>Где ломается обычная проверка схемы</h2>\n<p>OpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. <code>null</code> может быть допустимым для одного consumer и неприемлемым для другого.</p>\n<p>Представим endpoint <code>GET /v1/subscriptions/sub-42</code>. Общий ответ может выглядеть так:</p>\n<pre><code>{\n \"id\": \"sub-42\",\n \"state\": \"active\",\n \"renewalAt\": null\n}</code></pre>\n<p>Схема проверяет, что <code>id</code> — строка, <code>state</code> входит в перечисление, а <code>renewalAt</code> имеет тип даты или допускает <code>null</code>. Consumer для экрана продления проверяет другое правило: если состояние <code>active</code>, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.</p>\n<p>Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.</p>\n<h2>Три слоя доказательства</h2>\n<table><caption>Что доказывает каждая проверка</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Что проверяем</th><th scope=\"col\">Что означает PASS</th><th scope=\"col\">Чего PASS не означает</th></tr></thead><tbody><tr><td>Schema match</td><td>Поля, типы, enum, обязательность и nullable-границы.</td><td>Ответ соответствует описанной форме.</td><td>Consumer может завершить свой пользовательский сценарий.</td></tr><tr><td>Semantic expectation</td><td>Минимальное значение, нужное конкретному consumer.</td><td>Ответ содержит предусловие выбранного действия.</td><td>Provider действительно обработал запрос.</td></tr><tr><td>Provider verification</td><td>Interaction исполняется на provider в названном состоянии.</td><td>Запущенный provider вернул ожидаемый ответ для этого contract.</td><td>Проверены все клиенты, методы и варианты данных.</td></tr></tbody></table>\n<p>Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать <code>null</code> во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.</p>\n<h2>Как записать смысловой контракт</h2>\n<p>Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — <code>GET /v1/subscriptions/sub-42</code>; обязательное предусловие — <code>renewalAt</code> содержит будущую дату в ISO-формате.</p>\n<p>Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.</p>\n<pre><code>const response = {\n id: 'sub-42',\n state: 'active',\n renewalAt: '2026-09-30T00:00:00Z',\n};\n\nexpect(response.state).toBe('active');\nexpect(response.renewalAt).toMatch(\n /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/\n);\nexpect(Date.parse(response.renewalAt)).toBeGreaterThan(Date.now());</code></pre>\n<p>Код выше — учебная проверка значения. Она не является готовым Pact-тестом: здесь нет consumer client, mock server, contract broker и provider verification. В рабочем тесте assertion должен проходить через реальный код доступа consumer, чтобы contract отражал его запрос и его решение, а не отдельно созданный объект.</p>\n<h2>Почему нужен provider verification</h2>\n<p>Consumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.</p>\n<p>У provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.</p>\n<p>Проверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.</p>\n<figure><img src=\"/assets/editorial/2023/contract-tests-2023-consumer-provider.svg\" alt=\"Схема связи consumer-сценария, контракта и provider verification\" loading=\"lazy\" /><figcaption>Consumer формулирует потребность, contract фиксирует request и expectation, provider verification исполняет interaction в названном состоянии. Схема учебная: она не является сетевой трассой и не доказывает запуск конкретного сервиса.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Карта разбора расхождения consumer и provider</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Схема зелёная, экран не показывает действие.</td><td>Смысловое предусловие не записано: допустимый <code>null</code> стал непригодным для consumer.</td><td>Назвать решение, которое принимает экран, и минимальное значение для него.</td><td>Добавить semantic expectation для конкретного сценария или изменить общий контракт после согласования владельцев.</td></tr><tr><td>Provider verification падает на пустом поле.</td><td>Provider state не создаёт данные, обещанные interaction.</td><td>Проверить подготовку состояния, идентификатор записи и фактический response.</td><td>Исправить state setup или уточнить contract; не добавлять случайный default в assertion.</td></tr><tr><td>Consumer-тест проходит, provider verification не запускался.</td><td>Проверили mock или сохранённый JSON, но не реальный provider.</td><td>Найти результат verifier, версию contract, версию provider и номер interaction.</td><td>Запустить проверку на управляемом provider и опубликовать результат рядом с contract.</td></tr><tr><td>Один contract прошёл, другой consumer сломался.</td><td>Общее поле использовалось с разными ожиданиями.</td><td>Составить список consumer и сравнить их semantic expectations.</td><td>Разделить endpoint или поле, версионировать изменение либо добавить совместимое новое поле.</td></tr><tr><td>Тест падает только на старых данных.</td><td>Новый смысл поля не поддерживает исторические записи.</td><td>Проверить варианты данных до миграции и после неё.</td><td>Добавить миграцию, fallback с явным сроком или запрет выпуска до готовности данных.</td></tr></tbody></table>\n<h2>Порядок действий</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите endpoint, статус, поля ответа, consumer-сценарий и цену отказа. Формулировка «API несовместим» слишком широка.</li><li><strong>Отделите форму от смысла.</strong> Проверьте schema match отдельно. Укажите, какие значения схема разрешает и какое из них не подходит выбранному consumer.</li><li><strong>Назовите предусловие.</strong> Запишите действие пользователя и минимальный response, без которого действие должно исчезнуть или перейти в понятный отрицательный путь.</li><li><strong>Определите provider state.</strong> Укажите идентификатор данных, состояние зависимостей и способ подготовки. Не ссылайтесь на «обычную тестовую базу» без воспроизводимого описания.</li><li><strong>Сформируйте interaction.</strong> Включите только нужный request и response, но сохраните все поля, по которым consumer принимает решение. Привяжите contract к версии consumer.</li><li><strong>Запустите provider verification.</strong> Исполните interaction на конкретной версии provider. Сохраните результат, версию, состояние и номер проверки.</li><li><strong>Проверьте отрицательный путь.</strong> Ответ с <code>active</code> и <code>null</code> должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.</li><li><strong>Примите решение о выпуске.</strong> Разрешайте изменение только для перечисленных consumer и проверенных состояний. Для остальных клиентов оставьте совместимое поле, новую версию или план миграции.</li></ol>\n<h2>Ограничения</h2>\n<p>Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.</p>\n<p>Provider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.</p>\n<p>Примеры в статье учебные. Они не запускались против production, не измеряют частоту отказов и не сообщают о совместимости конкретных сервисов. Для реального изменения укажите версии, подготовьте изолированное состояние и сохраните фактический результат verifier. Если запуск не выполнялся, напишите «не проверено», а не «совместимо».</p>\n<h2>Критерий готовности</h2>\n<p>Изменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с <code>null</code> не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://spec.openapis.org/oas/latest.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification</a> — официальная спецификация описания HTTP API и схем, включая актуальную опубликованную версию.</li><li><a href=\"https://docs.pact.io/getting_started/how_pact_works\" target=\"_blank\" rel=\"noopener noreferrer\">Pact Docs: How Pact works</a> — официальное описание consumer-теста, provider verification и provider states.</li><li><a href=\"https://docs.pact.io/getting_started/verifying_pacts\" target=\"_blank\" rel=\"noopener noreferrer\">Pact Docs: Verifying Pacts</a> — официальные условия запуска verification и публикации результата.</li></ul>"
}