Валідний JSON, порушений контракт: багатошарові перевірки для запобігання регресіям у вантажі
Навчіться виявляти регресії у JSON-даних, які парсуються коректно: семантичні відмінності, фокусований JSON Schema, перевірки бізнес-правил у Node та обмеження генерованих типів.
Джерела: 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. Зміна прапорця оплати на true є очікуваною зміною стану. Перетворення кількості з числа на рядок є регресією.
Значення відмінностей за шляхом, а не за рядками тексту
Існує два дуже різних типи запитань, які можна поставити під час порівняння навантажень даних. Семантичне порівняння аналізує обидва документи та порівнює значення в кожному шляху. Воно вважає порядок ключів об’єкта незначним, тому заміна місцями 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: expected type integer. Заміна цього значення на число 2 дозволяє перевірку пройти. Саме такий точний підхід і потрібен під час регрес-тестування.
Знайте, що насправді перевіряє ваш валідатор
Валідатори відрізняються тим, наскільки повністю вони реалізують специфікацію. Деякі навмисно ігнорують обробку віддалених цілей $ref, оцінку регулярних виразів pattern чи перевірку ключових слів format, і за замовчуванням специфікація розглядає format як анотацію, а не як умову перевірки. Переконайтеся, що валідатор, який ви використовуєте у тестах, покриває ті самі ключові слова, від яких залежить ваш продакшн-контракт; інакше схема може здаватися більш суворою, ніж реальні перевірки, які її застосовують.
Крайні випадки, які варто протестувати окремо
Хороший контракт відхиляє некоректні дані з різних причин. Кожна з цих ситуацій має призводити до невдачі окремо:
- Повна відсутність поля
items(порушення вимогиrequired) - Поле
itemsяк порожній масив (minItems) - Значення
quantity, дорівнє нулю (minimum)
paid надсилається у вигляді рядка "true" (type)Не менш важливим є те, що має бути передано: непогашений замовлення з paid: false. Ключове слово required перевіряє лише наявність властивості, а не те, чи її значення є істинним. Офіційні посібники JSON Schema пояснюють, як required пов’язаний із властивостями об’єкта та як кожен елемент масиву перевіряється за правилами підсхеми items.
Що схема навмисно не включає
Це цільова схема, а не повна специфікація замовлення. Деталі клієнта, доставка, семантика часових позначок та додаткові поля не мають обмежень. Арифметичне правило також навмисно не враховується: те, чи дорівнює сума позицій totalCents, є логікою додатку, яку стандартна JSON Schema не може виразити, тому скрипт Node перевіряє це окремо. Розділення на два елементи дозволяє кожному інструменту виконувати те, у чому він найкраще справляється.
Створені типи описують один зразок
Є спокуса вставити дані у генератор JSON-to-TypeScript та вважати результат контрактом. При запуску на скоригованому зразку такий генератор створює елементи на кшталт quantity: number, createdAt: string та instructions: null усередині вкладених типів. Це точні описи цього конкретного зразку, і нічого більше:
- У JSON є лише один тип чисел, тому
numberне може позначати „додатне ціле число“. - Один приклад не може показати, які поля є необов’язковими.
- Поле, яке сьогодні має значення
null, завтра може законно містити рядок. - Типи TypeScript зникають під час виконання та ніколи не перевіряють надходжені байти; у посібнику TypeScript щодо тверджень типу чітко зазначено, що твердження не виконують жодних перевірок під час виконання.
Якщо ви хочете єдину визначення, яка б забезпечувала одночасно перевірку під час виконання та статичні типи, ознайомтесь з нашою статтею про спільне використання однієї схеми Zod між фронтендом та бекендом.
Точність також має бути на межі
Числа створюють ще одну пастку. JavaScript розбирає {"id":9007199254740993} як 9007199254740992, оскільки це значення перевищує Number.MAX_SAFE_INTEGER та округлюється до найближчого представленим дуплексним числа. Якщо ідентифікатор має зберігати кожну цифру, контракт розробника має визначити його як рядок. Намагатися виправити ситуацію після JSON.parse вже занадто пізно — цифри вже зникли.
Ключові висновки
- Зберігайте очищену версію відомого коректного навантаження та ніколи не використовуйте справжні облікові дані чи особисту інформацію у тестових даних.
- Розглядайте перевірки як різні рівні: сувора синтаксична перевірка, порівняння семантичних значень, верифікація схеми, а потім бізнес-правила, які схема не може виразити.
- Створюйте типи TypeScript лише після узгодження контракту та ніколи не плутайте їх із перевіркою під час виконання програми.
$.items[0].quantity змінився з цілого числа на рядок, тому перевірка провалилася та відбулося об’єднання значень", а не "порядок виглядає неправильно">.