Симптом на интеграции прост: 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 и не защищает договор.
| Case | Статус и Content-Type | Ключевое ожидание | Ожидаемый итог |
|---|---|---|---|
| Последняя страница | 200 / application/json | page.nextCursor присутствует и равен null | Принять ответ |
| Недопустимый cursor | 400 / application/problem+json | status совпадает; errors[0].code равен invalid_cursor | Принять управляемую ошибку |
| Регрессия пагинации | 200 / application/json | Ключ nextCursor отсутствует | Отклонить ответ с понятной причиной |
| Случай customer | 200 / application/json | customer отсутствует либо является объектом, но не 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-поля.
Добавляем воспроизводимый запрос, но не выдумываем его результат
После 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 и повторить запрос |
Маршрут от локального случая к интеграционной проверке
- Возьмите один пользовательский сбой и выразите его в одном проверяемом условии ответа.
- Добавьте успешную fixture, управляемую ошибку и отрицательный case, который обязан завершиться с ошибкой проверки.
- Сначала проверяйте HTTP-статус и media type, затем schema соответствующего тела.
- Зафиксируйте одно правило optional-поля и добавьте его в fixture и OpenAPI одновременно.
- Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.
- Если результат расходится, не подгоняйте 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