Есть класс ошибок, которые проходят все ручные проверки и спокойно доезжают до продакшена. Не пятисотки, не пустые поля, не сломанная структура — с этим как раз всё в порядке. Речь про случаи, когда сервис отвечает статусом 200, все поля на месте, типы правильные, и при этом одно число посчитано неверно.

Расскажу, как я проверяю такие ответы, когда в команде нет автотестов и в ближайшее время не появится: писать и поддерживать код некому, а сверка ответа с документацией делается глазами. Подход применим в любом инструменте, я просто покажу его на своём рабочем сценарии — создании заказа с расчётом скидки.

Что написано в документации

В нашей постановке блок с ценами описан так, как это обычно и выглядит в Confluence: таблица параметров, тип, обязательность и правило вычисления.

Поле

Тип

Обязательное

Правило

pricing.subtotal

целое

да

Сумма позиций без скидки

pricing.discount

целое

да

10% от subtotal, округление до целого

pricing.total

целое

да

subtotaldiscount

pricing.currency

строка

да

Всегда RUB

pricing.vatAmount

целое

да

Для этого тарифа всегда 0

Ключевое здесь — третья строка. total не приходит откуда‑то извне, он обязан быть равен разнице двух других полей того же ответа. Именно такие правила чаще всего и ломаются: сумма считается в одном сервисе, скидка применяется в другом, а собирается ответ в третьем.

Что вернул сервис

Вот сокращённый ответ на создание заказа:

{
  "traceId": "7c2a8410-56f2-4f59-b921-202607230002",
  "orderId": "ORD-2026-0723-001",
  "status": "CREATED",
  "customerId": "C-1042",
  "pricing": {
    "subtotal": 51960,
    "discount": 5196,
    "total": 47764,
    "currency": "RUB",
    "vatAmount": 0
  }
}

Теперь немного интерактивности, посмотрите на него секунд десять, как вы обычно смотрите на ответ в клиенте. Статус 200, структура совпадает с документацией, ни одного пустого или неожиданного значения. Скидка посчитана правильно: 10% от 51 960 — это 5196.

А total неверный. 51 960 минус 5196 — это 46764, а пришло 47764. Ровно тысяча рублей сверху.

Проблема не в том, что ошибку невозможно заметить. Проблема в том, что заметить её можно только одним способом — взять калькулятор и посчитать самому. На одном ответе это полминуты. На двадцати ответах в спринте, где ещё десяток полей и несколько сценариев расчёта, этого не делает никто. Люди смотрят, что поля на месте и значения «похожи на правду», и идут дальше.

Почему проверка «поле равно значению» тут не помогает

Первое, что приходит в голову, — зафиксировать ожидаемые значения. Взять ответ, сохранить его как эталон и сравнивать с ним следующие.

Так делать нельзя, и вот почему. Если вы возьмёте текущий ответ за эталон, вы зафиксируете total равным 47 764 — то есть закрепите ошибку как норму. Дальше проверка будет добросовестно подтверждать, что сервис стабильно считает неправильно. Когда разработчик починит расчёт, тест покраснеет — и вы пойдёте «чинить тест», приводя его обратно к сломанному поведению.

Это, кстати, общая ловушка любой генерации проверок по образцу ответа: и автогенератора в инструменте, и языковой модели, которой вы скормите JSON. Всё, что построено от факта, описывает факт, а не требование. Документация и ответ — два разных источника истины, и проверка обязана расти из первого.

инструмент разобрал ответ и предложил проверки по каждому полю. Обратите внимание на седьмую строку: total он предлагает сравнивать с 47764 — то есть с текущим, неверным значением.
инструмент разобрал ответ и предложил проверки по каждому полю. Обратите внимание на седьмую строку: total он предлагает сравнивать с 47 764 — то есть с текущим, неверным значением.

Поэтому сгенерированный набор — это заготовка, а не результат. Дальше её нужно править по документации, и это как раз та работа, ради которой человек в процессе и нужен.

Как выглядит проверка, построенная от документации

Из пяти полей блока pricing три проверяются тривиально, а два требуют вычисления.

Простые — это currency и vatAmount. Документация говорит «всегда RUB» и «всегда 0», значит, сравнение с константой здесь корректно: мы сверяем не с фактом, а с требованием, которое случайно совпало с фактом.

Со скидкой чуть сложнее: она не константа, а функция от суммы. Значит, subtotal нужно сначала запомнить, а discount сравнить с округлённым результатом вычисления по этому запомненному значению. И то же самое с итогом: запоминаем discount, считаем ожидаемый total как разницу и сравниваем с фактическим.

пять шагов одного тест-кейса. Первые два сохраняют фактические значения в переменные, третий вычисляет ожидаемый итог, четвёртый сравнивает его с тем, что пришло в ответе.
пять шагов одного тест‑кейса. Первые два сохраняют фактические значения в переменные, третий вычисляет ожидаемый итог, четвёртый сравнивает его с тем, что пришло в ответе.

Получается цепочка, которая проверяет не значения, а правило. Она останется валидной, когда изменится сумма заказа, когда поменяется состав позиций и когда придёт другой клиент. Ломаться она будет ровно в одном случае — если сервис посчитает неправильно. Это и есть то, чего мы добиваемся.

Лишние поля: почему перечислять нужные бесполезно

Второй тип ошибок, который ручная сверка пропускает почти всегда, — не отсутствие ожидаемого, а появление неожиданного. Человек проверяет по списку из документации: subtotal есть, discount есть, total есть, currency есть, vatAmount есть. Все пять на месте, проверка пройдена. А в ответе их шесть, и шестое поле никто не заметил, потому что его не было в списке, по которому сверяли.

Между тем лишнее поле — это довольно серьёзно. Оно может быть внутренним флагом, случайно вылезшим наружу, отладочным значением или, что хуже, данными, которых в этом ответе быть не должно.

Лечится это одной проверкой: перечисляем разрешённый состав ключей и требуем, чтобы других не было. Одна строка закрывает весь блок целиком и продолжает работать, когда в документацию добавят новое поле — тогда проверка честно покраснеет, и вы обновите список осознанно, а не задним числом.

разрешённый состав описывается по уровням вложенности. Всё, что не перечислено, считается лишним.
разрешённый состав описывается по уровням вложенности. Всё, что не перечислено, считается лишним.

Куда смотреть, когда проверка покраснела

Итак, прогон дал ожидаемый результат: четыре проверки зелёные, одна красная — фактический итог не сошёлся с вычисленным.

правило видно прямо в теле ответа, рядом с проверенным полем. Красным отмечено расхождение: ожидалось
правило видно прямо в теле ответа, рядом с проверенным полем. Красным отмечено расхождение: ожидалось

Здесь мне важна одна деталь оформления, которая экономит больше времени, чем всё остальное. Отчёт показывает правило рядом с тем полем, к которому оно относится, а не отдельным списком внизу. Когда правил три десятка, а полей полсотни, сопоставление «проверка номер девятнадцать не прошла» с конкретным местом в ответе превращается в отдельную задачу — и делается она, разумеется, глазами, по двум окнам.

Но знать, что число неверное, — это полдела. Дальше нужно понять, на каком шаге операции оно испортилось: сумма пришла кривой из тарификации, скидка применилась дважды или итог пересчитали при сборке ответа.

Для этого пригодится traceId, который сервис вернул в том же ответе. По нему в хранилище логов находятся все записи операции, и по ним видно, что происходило между запросом и ответом: с какими данными вызывалась тарификация, что она вернула, каким был итог до и после применения скидки.

записи операции, найденные по идентификатору из ответа. По ним проверяются те же правила, что и по HTTP: статусы шагов, поля запроса и ответа на каждом этапе.
записи операции, найденные по идентификатору из ответа. По ним проверяются те же правила, что и по HTTP: статусы шагов, поля запроса и ответа на каждом этапе.

Принципиально здесь то, что это не «посмотреть логи глазами», а те же самые проверки, только по другому источнику данных. Условие «на шаге расчёта итог должен быть равен разнице» формулируется один раз и применяется и к ответу сервиса, и к записи в логе.

Что происходит на следующем релизе

Вся описанная работа заняла у меня минут пятнадцать: вставить ответ, получить заготовку, поправить четыре шага по документации, добавить проверку состава и связать с логами.

Но ценность не в первом запуске — пятнадцать минут можно и калькулятором отработать. Ценность в том, что на следующем релизе это уже не работа: открыл, нажал «запустить», получил тот же отчёт. И через месяц, когда в расчёт добавят НДС, вы поменяете одно правило вместо того, чтобы заново вспоминать, как вообще считается итог в этом сервисе.

Второй эффект — передаваемость. Проверка перестаёт жить в голове того, кто её придумал. Коллега открывает сохранённый сценарий и получает тот же результат, не задавая вопросов. Для команд, где тестирование размазано между аналитиком, разработчиком и второй линией поддержки, это часто важнее скорости.

Где этот подход не нужен

Скажу прямо, чтобы не тратить ваше время.

Если в команде уже есть автотесты, настроенный пайплайн и человек, который их поддерживает, — вам ничего из этого не нужно, задача давно решена лучше. Если проверки должны запускаться на каждый коммит, тоже мимо: описанное я делаю в настольном приложении под Windows, оно запускается руками, а не из CI. И если проверка нужна ровно один раз и повторять её никто не собирается, калькулятор быстрее.

Подход имеет смысл в другой ситуации, довольно частой: проверять API нужно регулярно, ошибки в расчётах болезненны, а кода писать некому — ни своих автотестов, ни инженера под них, ни, зачастую, разрешения отправлять реальные данные во внешние сервисы.

Если у вас есть свой способ ловить такие расхождения без написания кода — расскажите в комментариях, мне правда интересно, как это решают другие.