{ "index": 161, "slug": "editorial-2023-07-mechanism-contract-tests", "title": "Почему schema match не равен совместимости API", "excerpt": "Ответ может соответствовать OpenAPI и всё равно сломать действие на экране. Разбираем границу между формой JSON, ожиданием consumer и проверкой provider на конкретном состоянии.", "contentHtml": "
Сбой интеграции часто выглядит обманчиво: HTTP-ответ имеет статус 200, обязательные поля присутствуют, а валидатор схемы сообщает PASS. При этом кнопка продления не появляется. Ответ содержит state: \"active\" и renewalAt: null. Общая схема допускает null, но экрану нужна дата, по которой он может предложить действие. Пользователь получает неполный сценарий, а команда видит зелёный тест и поздно замечает расхождение.
Причина не в том, что schema validation бесполезна. Она отвечает на один вопрос: допустима ли форма сообщения по описанию? Совместимость интеграции требует ещё двух ответов: получил ли consumer данные, необходимые его сценарию, и проверил ли provider этот конкретный обмен на согласованном состоянии. Эти уровни нельзя сворачивать в один флаг compatible.
В этой статье schema — формальное описание структуры сообщения. В OpenAPI Schema Object задаёт типы, обязательность, перечисления, форматы и другие ограничения. OpenAPI 3.1 опирается на JSON Schema Draft 2020-12, но само описание не знает, какое поле нужно конкретной кнопке или какой порядок действий ожидает пользователь.
\nConsumer expectation — проверяемое ожидание клиента. Оно связывает ответ с действием: для активной подписки с датой в будущем экран показывает продление, а для отсутствующей или просроченной даты не показывает его и объясняет недоступность. Это правило принадлежит сценарию клиента, а не общей схеме ресурса.
\nProvider verification — запуск опубликованного набора взаимодействий против provider в заданном состоянии данных. Такой запуск показывает, что конкретная версия provider действительно отвечает ожидаемым запросам. Он не доказывает поведение клиентов, которых в наборе нет, и не заменяет проверки авторизации, нагрузки или миграции данных.
\n| Уровень | Вопрос | Положительный результат | Что остаётся неизвестным |
|---|---|---|---|
| Schema | Сообщение имеет допустимую форму? | Ключи, типы, enum и ограничения соответствуют схеме. | Подходит ли значение действию конкретного consumer. |
| Consumer expectation | Клиент сможет принять решение? | Положительная и отрицательная ветки сценария определены. | Отвечает ли им реальный provider. |
| Provider verification | Provider выполняет interaction? | Запрос и ответ прошли на указанном provider state. | Поведение неохваченных клиентов, нагрузку и весь production-контур. |
Предположим, endpoint возвращает сведения о подписке. Ниже — минимальная схема OpenAPI 3.1 в YAML. Запись type: [string, 'null'] намеренно допускает отсутствие даты: это может быть корректно для отменённой подписки или другого потребителя.
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\nОтвет active + null соответствует этой форме. Но для сценария продления он недостаточен: экран не знает, когда можно выполнить операцию. Отсюда следует важное разделение: расширение схемы или ужесточение renewalAt для всех клиентов может быть неправильным исправлением. Нужно сначала выяснить, какой смысл требуется именно этому consumer.
Не менее опасен обратный случай. Provider возвращает будущую дату, а клиент сравнивает её с локальной строкой без часового пояса. Формально поле имеет строковый тип и формат даты, но решение клиента зависит от неверного разбора времени. Здесь schema PASS не отменяет тест на границе времени.
\nНачинайте не с полного API, а с одного пользовательского действия. Зафиксируйте метод, путь, статус, минимальный ответ, условие показа действия и отрицательную ветку. Например: «если state равен active, а renewalAt — дата в будущем относительно часов теста, экран показывает продление; иначе действие скрыто».
В правило нужно передать часы явно. Иначе тест, выполняющийся около полуночи или границы даты, станет случайным. Нельзя использовать произвольное «сейчас» внутри функции и затем считать результат воспроизводимым. В боевом коде формат даты, часовой пояс и источник времени должны быть частью соглашения команды.
\nfunction 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}\nЭто полностью локальная проверка decision rule: сохраните фрагмент в contract-rule.mjs и запустите node contract-rule.mjs. Скрипт не обращается к сети, не валидирует OpenAPI и не запускает provider. Он доказывает только две ветки выбранного правила. Прежде чем переносить его в проект, добавьте тесты на отменённое состояние, прошедшую дату, некорректную дату и другой часовой пояс.
Consumer-driven contract описывает взаимодействие, которое действительно использует клиент. Для него важны сформированный запрос, заголовки, статус, нужные поля и обработка ответа. Такой тест полезен именно своей узкой областью: он быстро показывает, что provider перестал удовлетворять конкретному клиенту.
\nУзкая область одновременно создаёт риск. Если в наборе есть только веб-экран, результат нельзя автоматически распространить на мобильное приложение, партнёрский API или старую версию клиента. Список consumers и их interactions должен быть явным. Если неизвестно, кто ещё читает поле, вывод ограничивается проверенным набором.
\nНе помещайте в contract test всю бизнес-логику экрана. Проверка «кнопка имеет зелёный цвет» относится к UI или функциональному тесту. Контракт должен зафиксировать, какие данные и ответ нужны для связи между consumer и provider. Решение интерфейса можно проверять отдельным тестом, используя тот же набор граничных ответов.
\nProvider state — это не комментарий «в базе есть активная подписка», а воспроизводимая подготовка данных, при которой interaction имеет смысл. Для примера назовите его так: subscription sub-42 is active and renews after 2023-07-15T10:00:00Z. В описании состояния должны быть известны идентификатор фикстуры, версия provider и момент времени, относительно которого проверяется дата.
Проверка provider должна выполняться против локально запускаемого экземпляра или экземпляра в CI с контролируемыми зависимостями. Проверка уже развёрнутого общего окружения хуже отвечает задаче быстрого feedback: там труднее подготовить состояние, изолировать внешние сервисы и понять, какая версия обработала запрос. Это не запрет на smoke-тесты в окружении, а граница между ними и provider verification.
\nРезультат записывайте не только как PASS/FAIL. Нужны версия provider, идентификатор contract, provider state, список interactions, commit или сборка и время запуска. Если результат не найден, корректная формулировка — «consumer contract опубликован, verification не подтверждена», а не «API совместим».
\n| Поле | Пример | Зачем нужно |
|---|---|---|
| Consumer | web-renewal | Понимать, чьё ожидание проверялось. |
| Interaction | GET /v1/subscriptions/sub-42 | Связать ошибку с конкретным обменом. |
| Provider state | active, renewal after fixed time | Воспроизвести входные данные. |
| Provider version | build-2023-07-15.2 | Отличить код, который реально проверяли. |
| Verification result | PASS: 1 interaction | Не принять существование contract за его выполнение. |
При отказе не начинайте с изменения nullable-поля. Сначала определите уровень, на котором возникло расхождение. Один и тот же экранный симптом может быть следствием формы ответа, семантики данных, часов, provider state или отсутствия самого запуска.
\n| Симптом | Первая проверка | Ограниченное действие |
|---|---|---|
| Нет обязательного ключа или изменился тип | Сверить версию схемы, required, type и media type. | Исправить provider или согласовать версионное изменение. |
| Форма верна, но действие скрыто | Проверить decision rule, значение поля и фиксированные часы. | Уточнить смысл consumer или добавить явное поле. |
| Consumer зелёный, provider неизвестен | Найти запись provider verification для той же версии. | Не расширять область вывода за проверенный consumer. |
| Один consumer зелёный, другой сломан | Сверить список interactions и версий клиентов. | Добавить отдельный сценарий или ограничить изменение. |
| Тест нестабилен у границы даты | Проверить источник времени, формат и часовой пояс. | Передавать часы в правило и фиксировать момент в state. |
null, просроченную дату, неизвестный enum, ошибку авторизации и отсутствие записи там, где это входит в сценарий.Контрактные тесты не заменяют функциональные, end-to-end, нагрузочные и security-тесты. Они не доказывают корректность бизнес-расчёта для всех данных, доступность базы, задержку сети или работоспособность каждого UI-перехода. Они также не обнаружат consumer, о котором команда не знает и который не попал в набор interactions.
\nУчебный endpoint и фикстура в этой статье вымышлены. Команда node contract-rule.mjs проверяет локальный инвариант на двух значениях и не вызывает реальный API. Если проект использует OpenAPI 3.0, правило для nullable оформляется иначе, чем в OpenAPI 3.1; сверяйте версию спецификации и поведение конкретного валидатора. Формат date-time сам по себе не говорит, какую бизнес-зону времени выбрать.
Безопасный вывод должен быть узким: «ответ соответствует schema», «consumer rule проходит для этих fixtures» или «provider verification прошла для такого-то state». Формулировку «изменение совместимо» оставляйте только тогда, когда перечислены все затронутые consumers и для них есть соответствующие результаты. Если не хватает версии, state или verification result, это пробел в доказательстве, а не зелёный статус.
\n