DarkRiDDeR13 мин

Полевой разбор: контрактный тест REST API без подмены его production-проверкой

HTTPТестированиеДиагностика

Симптом на интеграции прост: frontend получает ответ, JSON успешно распарсился, но следующий экран уже не знает, что делать. В одном релизе последняя страница приходит без nextCursor, в другом backend отдаёт HTML от proxy вместо problem document, в третьем optional customer становится null. Цена — не только падение компонента. Клиент может считать данные окончательными, показать неверный текст ошибки или повторять запрос, который никогда не станет успешным.

Ниже — полевой сценарий для маленького контракта GET /api/v1/orders. Он строится вокруг двух вещей: воспроизводимого HTTP-запроса, который следует выполнить на разрешённом тестовом URL, и локальной fixture, которую можно прогнать без сети. Важно не перепутать их. Фикстура доказывает, что наши правила отличают допустимое тело от недопустимого. Она не доказывает, что сервер, gateway, авторизация и production уже ведут себя так же.

Собираем симптомы в проверяемые случаи

Сначала вырезаем из инцидента общие слова. «Пагинация сломалась» превращается в утверждение: у 200 application/json обязан быть объект page, а у него — ключ nextCursor, равный строке или null. «Ошибка непонятна» превращается в другое утверждение: у 400 ожидается application/problem+json, поле status совпадает с HTTP-кодом, а локальный errors[0].code даёт UI стабильный повод для действия.

Такая декомпозиция позволяет тестировать не весь сервис, а границу, которая уже известна из сбоя. Для выборки заказов хватит трёх cases: корректная последняя страница с nextCursor: null; корректный problem document для плохого cursor; намеренно испорченная страница, где cursor пропал. Третий case особенно полезен: если проверка его принимает, тест на деле проверяет лишь наличие JSON и не защищает договор.

Минимальная матрица контрактной fixture
CaseСтатус и Content-TypeКлючевое ожиданиеОжидаемый итог
Последняя страница200 / application/jsonpage.nextCursor присутствует и равен nullПринять ответ
Недопустимый cursor400 / application/problem+jsonstatus совпадает; errors[0].code равен invalid_cursorПринять управляемую ошибку
Регрессия пагинации200 / application/jsonКлюч nextCursor отсутствуетОтклонить ответ с понятной причиной
Случай customer200 / application/jsoncustomer отсутствует либо является объектом, но не nullПринять или отклонить строго по схеме

Фикстура должна проверять статус до тела

Порядок проверок важен. Нельзя сначала читать body.items, а затем мимоходом заметить, что статус был 400. Так код начинает парсить ошибку как список и рождает вторичную ошибку вроде «map is not a function». В fixture сначала сравниваются статус и content-type, потом только форма соответствующего тела. Для success нужен 200 и JSON. Для known validation error нужен 400 и problem+json. Неизвестный статус оставляем нераспознанным, чтобы интерфейс показал общий сбой и команда увидела новый случай.

function assertOrdersPage(response) {
  assert(response.status === 200, "ожидался HTTP 200");
  assert(mediaType(response.headers["content-type"]) === "application/json", "ожидался JSON");
  assert(Array.isArray(response.body.items), "items должен быть массивом");
  assert(response.body.page, "page обязателен");
  assert(Object.prototype.hasOwnProperty.call(response.body.page, "nextCursor"),
    "page.nextCursor должен присутствовать");
  assert(response.body.page.nextCursor === null ||
    typeof response.body.page.nextCursor === "string",
    "nextCursor должен быть строкой или null");
}

Этот фрагмент намеренно не делает сетевой запрос. response — обычный объект с status, headers и body. В полном пакете запускается та же идея: проверка принимает локальную финальную страницу, принимает 400 problem detail и убеждается, что сама отвергает 200 без nextCursor. Если такой negative case вдруг проходит, мы знаем, что защита ослабла до подключения API. Это корректный результат unit-level fixture, а не отчёт об endpoint.

Различаем optional, null и неизвестное поле

В реальной выдаче часто спорят о customer: пользователю без привязанного профиля объект не нужен, но UI может захотеть написать «клиент не указан». Это не повод разрешить все представления сразу. В выбранном договоре customer необязателен. Если ключ отсутствует, экран выбирает запасной текст. Если ключ присутствует, он обязан быть объектом с id и name. Значение null считается нарушением, потому что добавляет третью ветку без продукта и без причины.

Это правило не универсально. Другая команда может сделать customer обязательным и nullable, если null имеет отдельный бизнес-смысл. Тогда schema и fixture должны принять null, а интерфейс — назвать его. Плохой вариант один: backend меняет отсутствие на null «потому что так сериализатор отдал», а frontend должен догадаться. Contract test ценен тем, что такое изменение становится красным до того, как попадёт в карточку.

function assertCustomer(order) {
  var hasCustomer = Object.prototype.hasOwnProperty.call(order, "customer");
  if (!hasCustomer) return;

  assert(order.customer && typeof order.customer === "object",
    "customer при наличии должен быть объектом, не null");
  assert(typeof order.customer.id === "string", "customer.id обязателен");
  assert(typeof order.customer.name === "string", "customer.name обязателен");
}

Проверка не обязана быть сложной библиотекой schema validation. В 2019 маленькая функция на assert часто полезнее, когда она живёт рядом с тремя fixtures и легко читается разработчиком обоих слоёв. Позже её можно заменить валидатором на основе OpenAPI, но только после сравнения поддержки версии Schema Object. Цель текущего теста скромнее: удержать реально важные условия — статус, media type, required-поля и смысл optional-поля.

Вертикальная схема контрактной проверки: локальные response fixtures проходят сначала через проверку HTTP-статуса и Content-Type, затем через схему success или problem; отдельная красная ветка показывает 200 без nextCursor, который должен быть отвергнут. Справа отмечен отдельный последующий запрос к тестовому стенду.
Фикстура проверяет договор на заранее заданных данных. Реальный HTTP-запрос — следующий независимый этап, поэтому диаграмма не выдаёт локальный тест за production-проверку.

Добавляем воспроизводимый запрос, но не выдумываем его результат

После fixture берём разрешённый тестовый host и выполняем один запрос к первой странице. Команда ниже сохраняет заголовки и тело отдельно. Это важно: status и Content-Type видны в headers, а body можно показать в ревью без шума curl. В примере нет токена, cookies и адреса production. Подставлять их в статью, коммит или CI-лог нельзя; для закрытого API команда должна использовать безопасный тестовый способ аутентификации и скрытие секретов.

# Выполнять только на разрешённом тестовом URL и с безопасной авторизацией.
curl -sS -D /tmp/orders.headers -o /tmp/orders.json \
  -H "Accept: application/json" \
  "https://api.example.test/api/v1/orders?limit=2"

grep -Ei "^(HTTP/|content-type:)" /tmp/orders.headers
node -e "const fs=require(\"fs\"); const body=JSON.parse(fs.readFileSync(\"/tmp/orders.json\")); console.log(body.page)"

Этот запрос надо читать по шагам. Сначала сверяем фактический HTTP-код. Затем Content-Type; заголовок application/json; charset=utf-8 может содержать параметры, поэтому production-парсер должен сравнивать media type корректно, а не полную строку, если сервер его допускает. Потом смотрим наличие items, page и nextCursor. Если ответ — 400, мы не запускаем проверку success, а сравниваем problem document с отдельной веткой. Результат фиксируем как запись факта, не как «API работает».

Связываем fixture с OpenAPI-описанием

У теста не должно быть второго тайного контракта. Перед запуском сверяем его условия с OpenAPI: 200 указывает на OrdersPage, 400 — на Problem, required содержит items и page, а nullable у nextCursor разрешает только null помимо string. Если fixture и YAML расходятся, сначала решаем, какой из них описывает продукт, и исправляем один источник. Нельзя чинить тест под случайный текущий ответ сервера и оставить спецификацию прежней: это вернёт спор в следующем релизе.

Порядок диагностики при расхождении теста и сервера
НаблюдениеНа что указываетПроверкаДействие
Fixture падает на локальном положительном caseОшибка в тесте или собственном примереСверить fixture с зафиксированным schemaИсправить тест/пример до сетевого запуска
Fixture принимает отрицательный caseКонтракт не защищён от известной регрессииДобавить конкретное assertionНе продолжать с зелёным, но пустым тестом
Тестовый сервер отдаёт другой statusНарушен Responses contract или выбран иной сценарийСохранить headers и запрос без секретовСогласовать изменение или исправить endpoint
Status верный, тело другоеСериализация/schema не совпалиСравнить required, nullable и Content-TypeИсправить schema или mapper и повторить запрос

Маршрут от локального случая к интеграционной проверке

  1. Возьмите один пользовательский сбой и выразите его в одном проверяемом условии ответа.
  2. Добавьте успешную fixture, управляемую ошибку и отрицательный case, который обязан завершиться с ошибкой проверки.
  3. Сначала проверяйте HTTP-статус и media type, затем schema соответствующего тела.
  4. Зафиксируйте одно правило optional-поля и добавьте его в fixture и OpenAPI одновременно.
  5. Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.
  6. Если результат расходится, не подгоняйте UI: сначала укажите, какая строчка контракта изменилась, и согласуйте переход.

Что этот сценарий не обещает

Фикстура не измеряет latency, не проверяет права, не запускает gateway и не подтверждает, что cursor защищён от перебора. Она также не заменяет end-to-end сценарий, где UI действительно нажимает «ещё». Это сознательная граница: один быстрый локальный тест должен ловить разрыв контракта раньше, а серверный и браузерный уровни подтверждают другие свойства. Объявить fixture production-тестом означало бы скрыть эти пробелы, а не уменьшить риск.

Зато сценарий даёт команде чёткий предметный артефакт. Когда следующий change удалит page.nextCursor, поставит другой Content-Type или заменит optional object на null, можно показать конкретный case и конкретный пункт спецификации. Для автора 2019 года это уже не «проверим руками после релиза», а аккуратный мост от фронтенд-обработки ответа к договору backend и HTTP.

Короткий вывод

  • Contract fixture должна принимать ожидаемые cases и обязательно отвергать известный плохой ответ.
  • Проверка начинается со status и Content-Type; одинаковый JSON не делает 200 и 400 взаимозаменяемыми.
  • Отсутствующее optional-поле и null — разные данные, если команда не зафиксировала обратное.
  • Локальные response objects полезны до сети, но тестовый запрос к серверу остаётся отдельным и честно названным этапом.

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

  • IETF RFC 7231, HTTP/1.1 Semantics and Content — семантика методов, представлений, Content-Type и кодов ответа, действовавшая в 2019 году
  • IETF RFC 7231, раздел 6: Response Status Codes — коды 2xx, 4xx и 5xx сообщают результат конкретного HTTP-запроса; их смысл нельзя заменять произвольным полем JSON
  • IETF RFC 7807, Problem Details for HTTP APIs — стандартизированная форма problem detail с type, title, status, detail и instance; расширения остаются контрактом API
  • IETF RFC 8288, Web Linking — модель ссылочных отношений HTTP; конкретная форма пагинации должна быть явно выбрана командой
  • OpenAPI Specification 3.0.2 — версия спецификации, доступная в 2019 году; описывает пути, операции, ответы, content и Schema Object
  • OpenAPI 3.0.2, Operation Object — каждая операция объявляет параметры и ожидаемые ответы, а не только URL и метод
  • OpenAPI 3.0.2, Responses Object — ответы задаются по HTTP-коду или default; у каждого можно описать content и схему тела
  • OpenAPI 3.0.2, Schema Object — required относится к свойствам объекта, а nullable разрешает null только при явно заданном type