Главная / Статьи

Валидный JSON, нарушенный контракт: многоуровневая проверка на регрессии в полезной нагрузке

Научитесь выявлять регрессии в JSON-полезной нагрузке, которая парсится корректно: семантические различия, сфокусированный JSON Schema, проверки бизнес-правил в Node и ограничения генерируемых типов.

Издатель: REACTAPP.TOP7 мин чтения1572 слов

Источники: Catch JSON Contract Regressions Before They Reach Your App

Ответ может прийти с кодом HTTP 200, быть успешно обработанным без единой ошибки, но при этом сломать ваше приложение. Проверка синтаксиса лишь подтверждает, что текст представляет собой JSON; она ничего не говорит о том, соответствует ли содержимое тому, чего ожидает ваш код. В этом руководстве создается небольшая многоуровневая проверка на регрессию (семантическое сравнение, специфичная схема и явные бизнес-правила), чтобы нарушение контракта проявлялось в виде точного сообщения, а не странной ошибки дальше в работе приложения.

Содержимое, которое успешно парсится, но всё равно вызывает сбой

Рассмотрим сервис обработки заказов. Конечная точка отвечает успешно, JSON парсится, но код, суммирующий количество товаров, возвращает значение "021" вместо 3. Этот сценарий вымышлен, но способ возникновения ошибки распространён: в перегруженном пакете данных легко пропустить строку "2" и число 2; оба являются допустимыми в JSON, но только одно из них соответствует условиям контракта.

Приведённый ниже пример представляет собой поступающий заказ. Внимательно посмотрите на первый товар: его quantity — это строка в кавычках, тогда как у второго товара используется простое число. Все остальные данные являются правдоподобными для заказа, включая указание доставки null и заранее вычисленное значение totalCents.

{
  "orderId": "ORD-1042",
  "createdAt": "2026-09-21T09:30:00Z",
  "customer": {"id": "C-017", "name": "Avery Example", "vip": false},
  "items": [
    {"sku": "MUG-BLUE", "quantity": "2", "unitPriceCents": 1250},
    {"sku": "NOTE-A5", "quantity": 1, "unitPriceCents": 650}
  ],
  "shipping": {"city": "Exampleton", "instructions": null},
  "paid": true,
  "totalCents": 3150
}

Строгая проверка синтаксиса принимает этот документ без замечаний, и это закономерный результат: текст соответствует грамматике JSON, описанной в RFC 8259, согласно которой строки, числа, логические значения и null являются допустимыми значениями. Однако грамматика не может учитывать правила, налагаемые вашим приложением:

  • quantity должен быть целым числом, большим за ноль
  • paid должен быть логическим значением
  • сумма количества каждого товара, умноженная на его цену за единицу, должна совпадать с totalCents

Предположим, что предыдущая версия этого заказа отличалась ровно в двух местах: значение quantity было равно числу 2, а значение paid — false. Изменение флага оплаты с false на true является ожидаемым изменением состояния. Преобразование значения quantity из числа в строку представляет собой регрессию.

Значения различий по путям, а не по строкам текста

При сравнении полезных нагрузок можно задать два совершенно разных вопроса. Семантическое сравнение анализирует оба документа и сопоставляет значения по каждому пути. Оно считает порядок ключей объекта незначимым, поэтому смена местами customer.id и customer.name не считается изменением, в то время как порядок элементов массива считается значимым, так что перемещение элемента в другой индекс может считаться изменением. Для этого фикстура семантическое сравнение указывает ровно два пути: $.items[0].quantity и $.paid. Это отвечает на вопрос «Какие данные изменились?»

Разница текстов отвечает на другой вопрос: «Какие символы или строки изменились?» Это по-прежнему полезное доказательство, и сначала нормализация отступов и форматирования в обоих документах устраняет большую часть лишней информации. Однако совпадение или различия в тексте не являются доказательством того, что два документа представляют собой одинаковое значение JSON или нет, поскольку пробелы, порядок ключей и формат чисел могут различаться без изменения смысла.

Повторение проверок в чистом Node

Для проверки этих фактов не требуется никаких внешних сервисов. Приведённый ниже скрипт считывает файл order.json из своей же директории (с помощью import.meta.url), воссоздаёт предыдущую версию с помощью structuredClone и проверяет наличие различий с использованием node:assert/strict. Затем он демонстрирует ошибку, исправляет проблему в копии файла и проверяет бизнес-правило о том, что рассчитанная сумма соответствует totalCents. Сохраните его как check-order.mjs рядом с исходным файлом и запустите команду node check-order.mjs.

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const incoming = JSON.parse(
  readFileSync(new URL('./order.json', import.meta.url), 'utf8')
);
const before = structuredClone(incoming);
before.items[0].quantity = 2;
before.paid = false;
assert.equal(before.items[0].quantity, 2);
assert.equal(incoming.items[0].quantity, '2');
assert.equal(before.paid, false);
assert.equal(incoming.paid, true);
const naiveCount = incoming.items.reduce(
  (sum, item) => sum + item.quantity,
  0
);
assert.equal(naiveCount, '021');
const corrected = structuredClone(incoming);
corrected.items[0].quantity = 2;
const count = corrected.items.reduce((sum, item) => sum + item.quantity, 0);
const computedTotal = corrected.items.reduce(
  (sum, item) => sum + item.quantity * item.unitPriceCents,
  0
);
assert.equal(count, 3);
assert.equal(computedTotal, corrected.totalCents);
console.log('Contract checks passed');

Интересной является асертация naiveCount. Сокращение начинается с числа 0, к нему добавляется строка "2", в результате чего получается строка "02", поскольку оператор + преобразуется в операцию конкатенации, когда хотя бы один из операндов — строка. На следующем шаге добавляется 1, в результате чего получается "021". Числовое начальное значение не принудительно преобразует последующие операнды; как только в аккумулятор попадает строка, все последующие элементы также конкатенируются.

Обратите внимание на то, чего не рекомендует скрипт. Изменение значения фикстуры на число — это способ соблюдения правильного формата данных, а не указание на необходимость оборачивать каждое входящее поле в функцию Number(). Молчаливое принудительное преобразование скрывает ошибки в исходном коде. Необходимо либо исправить сервис, генерирующий строки, либо определить четкое, задокументированное правило нормализации на этапе входа данных в систему.

Преобразование правила типа в схему

Правила прозы легко забываются; исполняемые же — нет. Приведённая ниже схема Draft 2020-12 кодирует требования, важные для этой регрессии. Она требует наличия полей orderId, items, paid и totalCents, предусматривает наличие как минимум одного элемента, ограничивает значение quantity целым числом не меньше 1, а значение unitPriceCents — ненегативным целым числом.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["orderId", "items", "paid", "totalCents"],
  "properties": {
    "orderId": {"type": "string"},
    "items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["sku", "quantity", "unitPriceCents"],
        "properties": {
          "sku": {"type": "string"},
          "quantity": {"type": "integer", "minimum": 1},
          "unitPriceCents": {"type": "integer", "minimum": 0}
        }
      }
    },
    "paid": {"type": "boolean"},
    "totalCents": {"type": "integer", "minimum": 0}
  }
}

Проверка входных данных по этой схеме проваливается с сообщением вроде $.items[0].quantity: ожидается тип целое число. Замена этого значения на число 2 позволяет проверке пройти успешно. Именно такой точечный анализ и нужен при проверке на регрессию.

Знайте, что на самом деле покрывает ваш валидатор

Валидаторы отличаются тем, насколько полно они реализуют спецификацию. Некоторые умышленно пропускают обработку удаленных целей $ref, оценку регулярных выражений pattern или проверку ключевых слов format; по умолчанию спецификация рассматривает format как аннотацию, а не как условие проверки. Убедитесь, что валидатор, который вы используете в тестах, покрывает те же ключевые слова, от которых зависит ваша производственная схема, иначе схема может казаться более строгой, чем реальные проверки, её обеспечивающие.

Крайние случаи, каждый из которых требует тестирования

Хорошая схема отклоняет некорректные данные по разным причинам. Каждая из этих причин должна приводить к сбою отдельно:

  • Полное отсутствие поля items (нарушение требования к обязательным полям)
  • Поле items представляет собой пустой массив (minItems)
  • Значение quantity равно нулю (minimum)
  • paid передаётся в виде строки "true" (type)
  • Не менее важно определить, что должно передаваться: непогашенный заказ с paid: false. Ключевое слово required проверяет лишь наличие свойства, а не то, что его значение является истинным. В официальных руководствах JSON Schema объясняется, как required связан с свойствами объекта и как каждый элемент массива проверяется согласно подсхеме items.

    Что намеренно не включено в схему

    Это целевая схема, а не полное описание заказа. Параметры клиента, информация об отправке, семантика временных меток и дополнительные поля не ограничены никакими правилами. Арифметическое правило также намеренно не входит в область применения: то, суммируются ли позиции до значения totalCents, является частью логики приложения, которую стандартная JSON Schema не может отразить, поэтому скрипт Node проверяет это отдельно. Разделение на две части позволяет каждому инструменту выполнять свои функции максимально эффективно.

    Генерируемые типы описывают один пример

    Хочется просто вставить данные в генератор JSON-в-TypeScript и считать полученный результат контрактом. Однако при работе с исправленным примером такой генератор выдаст внутри вложенных типов значения вроде quantity: number, createdAt: string и instructions: null. Это точные описания конкретного примера и ничего более:

    • В JSON существует только один тип чисел, поэтому number не может обозначать «положительное целое число».
    • Один пример недостаточен, чтобы понять, какие поля являются необязательными.
    • Поле, которое сегодня имеет значение null, завтра может законно содержать строку.
    • Типы TypeScript исчезают во время выполнения и никогда не анализируют поступающие данные; в руководстве TypeScript по утверждениям типов четко указано, что утверждения не выполняют проверок во время выполнения.

    Если вы хотите единую концепцию, которая бы обеспечивала как проверку во время выполнения, так и статические типы, ознакомьтесь с нашей статьей об использовании одной схемы Zod как на фронтенде, так и на бэкенде.

    Точность также важна на границах

    Числа создают ещё одну ловушку. JavaScript интерпретирует {"id":9007199254740993} как 9007199254740992, поскольку значение превышает Number.MAX_SAFE_INTEGER и округляется до ближайшего представимого числа типа double. Если идентификатор должен сохранять все цифры, то в спецификации производителя он должен быть определён как строка. Попытки исправить ситуацию после вызова JSON.parse слишком поздни — цифры уже исчезли.

    Основные выводы

    • Храните очищенную версию проверенного пакета данных и никогда не используйте реальные учётные данные или личную информацию в тестовых данных.
    • Рассматривайте проверки как последовательные уровни: строгая синтаксическая проверка, сравнение семантических значений, верификация по шаблону, а затем бизнес-правила, которые невозможно выразить с помощью шаблона.
    • Генерируйте типы TypeScript только после утверждения спецификации и никогда не путайте их с проверками во время выполнения программы.
  • Стремитесь к описанию неудач с указанием пути и причины, например: «$.items[0].quantity изменился с целого числа на строку, поэтому проверка проваливается и происходит конкатенация», вместо фразы «порядок выглядит неправильно».