{ "index": 162, "slug": "editorial-2023-07-practice-contract-tests", "title": "Контрактные тесты API: как поймать совместимое на вид изменение", "excerpt": "HTTP 200 и валидная схема ещё не означают, что consumer сможет продолжить сценарий. Разбираем смысловой контракт, provider verification и критерий готовности.", "contentHtml": "

API возвращает 200 OK. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле state осталось строкой active, но поле renewalAt стало null. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.

\n

Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.

\n

Тезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.

\n

Где ломается обычная проверка схемы

\n

OpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. null может быть допустимым для одного consumer и неприемлемым для другого.

\n

Представим endpoint GET /v1/subscriptions/sub-42. Общий ответ может выглядеть так:

\n
{\n  \"id\": \"sub-42\",\n  \"state\": \"active\",\n  \"renewalAt\": null\n}
\n

Схема проверяет, что id — строка, state входит в перечисление, а renewalAt имеет тип даты или допускает null. Consumer для экрана продления проверяет другое правило: если состояние active, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.

\n

Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.

\n

Три слоя доказательства

\n
Что доказывает каждая проверка
СлойЧто проверяемЧто означает PASSЧего PASS не означает
Schema matchПоля, типы, enum, обязательность и nullable-границы.Ответ соответствует описанной форме.Consumer может завершить свой пользовательский сценарий.
Semantic expectationМинимальное значение, нужное конкретному consumer.Ответ содержит предусловие выбранного действия.Provider действительно обработал запрос.
Provider verificationInteraction исполняется на provider в названном состоянии.Запущенный provider вернул ожидаемый ответ для этого contract.Проверены все клиенты, методы и варианты данных.
\n

Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать null во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.

\n

Как записать смысловой контракт

\n

Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — GET /v1/subscriptions/sub-42; обязательное предусловие — renewalAt содержит будущую дату в ISO-формате.

\n

Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.

\n
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());
\n

Код выше — учебная проверка значения. Она не является готовым Pact-тестом: здесь нет consumer client, mock server, contract broker и provider verification. В рабочем тесте assertion должен проходить через реальный код доступа consumer, чтобы contract отражал его запрос и его решение, а не отдельно созданный объект.

\n

Почему нужен provider verification

\n

Consumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.

\n

У provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.

\n

Проверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.

\n
\"Схема
Consumer формулирует потребность, contract фиксирует request и expectation, provider verification исполняет interaction в названном состоянии. Схема учебная: она не является сетевой трассой и не доказывает запуск конкретного сервиса.
\n

Симптом → причина → проверка → действие

\n
Карта разбора расхождения consumer и provider
СимптомПричинаПроверкаДействие
Схема зелёная, экран не показывает действие.Смысловое предусловие не записано: допустимый null стал непригодным для consumer.Назвать решение, которое принимает экран, и минимальное значение для него.Добавить semantic expectation для конкретного сценария или изменить общий контракт после согласования владельцев.
Provider verification падает на пустом поле.Provider state не создаёт данные, обещанные interaction.Проверить подготовку состояния, идентификатор записи и фактический response.Исправить state setup или уточнить contract; не добавлять случайный default в assertion.
Consumer-тест проходит, provider verification не запускался.Проверили mock или сохранённый JSON, но не реальный provider.Найти результат verifier, версию contract, версию provider и номер interaction.Запустить проверку на управляемом provider и опубликовать результат рядом с contract.
Один contract прошёл, другой consumer сломался.Общее поле использовалось с разными ожиданиями.Составить список consumer и сравнить их semantic expectations.Разделить endpoint или поле, версионировать изменение либо добавить совместимое новое поле.
Тест падает только на старых данных.Новый смысл поля не поддерживает исторические записи.Проверить варианты данных до миграции и после неё.Добавить миграцию, fallback с явным сроком или запрет выпуска до готовности данных.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите endpoint, статус, поля ответа, consumer-сценарий и цену отказа. Формулировка «API несовместим» слишком широка.
  2. Отделите форму от смысла. Проверьте schema match отдельно. Укажите, какие значения схема разрешает и какое из них не подходит выбранному consumer.
  3. Назовите предусловие. Запишите действие пользователя и минимальный response, без которого действие должно исчезнуть или перейти в понятный отрицательный путь.
  4. Определите provider state. Укажите идентификатор данных, состояние зависимостей и способ подготовки. Не ссылайтесь на «обычную тестовую базу» без воспроизводимого описания.
  5. Сформируйте interaction. Включите только нужный request и response, но сохраните все поля, по которым consumer принимает решение. Привяжите contract к версии consumer.
  6. Запустите provider verification. Исполните interaction на конкретной версии provider. Сохраните результат, версию, состояние и номер проверки.
  7. Проверьте отрицательный путь. Ответ с active и null должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.
  8. Примите решение о выпуске. Разрешайте изменение только для перечисленных consumer и проверенных состояний. Для остальных клиентов оставьте совместимое поле, новую версию или план миграции.
\n

Ограничения

\n

Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.

\n

Provider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.

\n

Примеры в статье учебные. Они не запускались против production, не измеряют частоту отказов и не сообщают о совместимости конкретных сервисов. Для реального изменения укажите версии, подготовьте изолированное состояние и сохраните фактический результат verifier. Если запуск не выполнялся, напишите «не проверено», а не «совместимо».

\n

Критерий готовности

\n

Изменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с null не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.

\n

Проверяемые источники

\n" }