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

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

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

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

Поле

Тип

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

Правило

pricing.subtotal

целое

да

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

pricing.discount

целое

да

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

pricing.total

целое

да

subtotal − discount

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. Всё, что построено от факта, описывает факт, а не требование. Документация и ответ — два разных источника истины, и проверка обязана расти из первого.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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