JSON Toolkit

Контрактне тестування JSON Schema: вбудуйте правила валідації в CI

Головне: Схема не завершується написанням — саме звідти починається деградація. Одна одруківка тихо послаблює валідацію, один рефакторинг ламає всі фікстури. Тут розібрано робочий набір контрактного тестування: позитивні, негативні та снапшот-фікстури, лінт самої схеми, одноразову компіляцію з повторним використанням і четверо воріт CI, що перетворюють поломку контракту на червону збірку.

Середній~8 хв читанняОпубліковано 2026-11-05Оновлено 2026-11-05
Схема контрактного тестування JSON Schema і ворота валідації CI

Висновок: схема — це код, а код потребує тестів. Позитивні фікстури доводять, що вона пропускає належне; негативні — що затримує неприпустиме; снапшоти наглядають за ненавмисними змінами. Натомість ajv compile і снапшоти з --ci несуть нічну варту у вашому пайплайні.

У момент поломки правила валідації нічого не кидає помилку: розширення допуску ніхто не помічає, посилення відбору переживає вся команда. Схема — не файл конфігурації, а код, який ніхто не тестує.

1. Схема зламалась — хто дізнається першим

Почнімо з ями, в яку ми потрапили по-справжньому. Оголосімо схему за чернетку 2020-12 і скомпілюємо її типовим пакетом Ajv — одразу летітиме виняток:

Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"

Вхідна точка Ajv 8 за замовчуванням знає лише draft-07; для 2020-12 потрібен імпорт із ajv/dist/2020. Ще яма: у строгому режимі format: "email" без установленого ajv-formats також валить компіляцію:

Error: unknown format "email" ignored in schema at path "#/properties/email"

Бачити ці помилки локально при першій компіляції — добре. Проблема в тому, що чимало проєктів відкладають компіляцію до приходу запиту, і зламана схема чекає першу жертву. Ще підступніша напівзламана схема: required, випадково записаний рядком замість масиву, дає на компіляції schema is invalid: data/required must be array, але якщо цей крок жодного разу не виконувався в CI, він лежить у продакшені.

Отже, перші ворота перевіряють не дані, а саму схему. Одна команда:

node scripts/lint-schemas.mjs
// scripts/lint-schemas.mjs
import { readFileSync, readdirSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';

const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
for (const file of readdirSync('schemas')) {
  const schema = JSON.parse(readFileSync(`schemas/${file}`, 'utf8'));
  ajv.compile(schema);
  console.log(`ok ${file}`);
}

Чому не npx ajv compile: пакет ajv v8 не містить CLI, тож npx ajv падає з could not determine executable to run; CLI лежить в окремому пакеті ajv-cli (у нашому замірі 5.0.0). Але ajv-cli 5 підтримує лише draft-07 і draft-2019-09 — для схеми 2020-12 з першого розділу він кидає те саме no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Тому для проєкту на 2020-12 lint працює через той самий Ajv2020, а npx ajv-cli compile -s '**/*.schema.json' --strict доречний лише для draft-07.

Ганяйте її як лінтер — одруківки, застарілі ключові слова, незареєстровані формати виявляться ще на PR. Це перша спільність тестування схеми зі звичайним кодом: спершу переконайтеся, що вона компілюється.

2. Трійця: позитив, негатив, снапшот

Скелет тестування схеми складається з трьох частин.

Позитивні фікстури: зразки, зняті з реальних відповідей, які мусять проходити. Беріть їх зі staging, а не з пам'яті — рукописні позитиви покривають лише happy path і пропускають null, порожні рядки, межі з одних обов'язкових полів.

Негативні фікстури: на кожне обмеження схеми — щонайменше один порушувальний ввід. Для minimum: 0 перевіряйте пару -1 (відмова) і 0 (проходження); для enum — значення поза списком; для additionalProperties: false — зайве поле. Негатив важливіший за позитив: схема, що тихо пропускає погані дані, — це тихий баг, який доходить до бази даних.

Угоду про імена, що перетворює назву файла на ассерт, можна взяти з опенсорсу:

schemas/order.schema.json
fixtures/order/valid-minimal.json        # має пройти
fixtures/order/valid-full.json           # має пройти
fixtures/order/invalid-type.json         # має бути відхилено
fixtures/order/invalid-extra-field.json  # має бути відхилено

Вбудованого в Node node:test достатньо:

import { test } from 'node:test';
import { readFileSync, readdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(
  JSON.parse(readFileSync('schemas/order.schema.json', 'utf8'))
);

for (const f of readdirSync('fixtures/order')) {
  const data = JSON.parse(readFileSync(`fixtures/order/${f}`, 'utf8'));
  if (f.startsWith('valid-')) {
    test(`${f} має пройти`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
  } else {
    test(`${f} має бути відхилено`, () => { assert.ok(!validate(data)); });
  }
}

Снапшоти: серіалізуйте validate.errors у файл і порівнюйте. Хтось невипадково змінив обмеження — снапшот миттєво червоніє. У Jest/Vitest це один рядок expect(errors).toMatchSnapshot(), а в CI завжди додавайте --ci: нові снапшоти тоді падають замість тихого створення, і дрейф не проходить непомітно.

3. Збирайте всі помилки за один прохід

Часто пропущуване налаштування при тестуванні негативів: за замовчуванням валідатор зупиняється на першій помилці. Дані зламані в трьох місцях, ви бачите одне, правите, перезапускаєте — і так по колу. З allErrors: true (Ajv) чи iter_errors (Python) усе видно одразу:

from jsonschema import Draft202012Validator

validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(bad), key=lambda e: list(e.absolute_path))
for e in errors:
    print(list(e.absolute_path), e.validator, e.message)

У Python є і контінтуїтивний факт: яку помилку повідомить validate(), обирає не «перша», а best_match. У нашому замірі при трьох порушеннях (хибний тип id, поза списком role, зайве поле extra) було показано лише additionalProperties, а помилка типу id лишилась прихованою. У CI завжди використовуйте iter_errors для повної картини.

І ще легша пастка: format за замовчуванням — не ассерт. У 2020-12 format оголошено анотацією: Python-jsonschema не перевіряє його без переданого format_checker — у нашому тесті "not-an-email" пройшов вільно. Ajv вимагає встановлення та реєстрації ajv-formats. Якщо формат важливий (email, дата, uuid), має бути негативна фікстура, що доводить: ваша конфігурація валідатора справді його перевіряє.

4. Компілюйте один раз — не використовуйте компілятор як витратний матеріал

Локальні заміри (Node v22.13.1, Ajv 8.17, та сама схема з потрійною вкладеністю, ті самі порушувальні дані):

Підхід Час на 10 000 валідацій
Щоразу new Ajv() і compile 42233 мс
Екземпляр на рівні модуля + compile один раз 6.9 мс

Різниця в 6000 разів. Валідатор у межах обробника запиту зі створенням нового Ajv на кожен виклик — найчастіша аварія продуктивності. Зазначимо й поширене хибне переконання: «ajv.validate(schema, data) компілює заново щоразу». За заміром 100 тисяч викликів зайняли 8.2 мс — зіставно з 11.2 мс у функції повторного використання, бо Ajv тримає внутрішній кеш схем. Справжня біда не в промасі кешу, а в невикористанні екземпляра повторно. У Python те саме: перевживаний Draft202012Validator приблизно у 5000 разів швидший, ніж виклик validate() щоразу (2000 ітерацій: 0.3 мс проти 1566 мс), адже останній щоразу перевіряє саму схему.

5. Вбудуйте ворота в CI: четверо дверей

Порядок від дешевих до дорогих: якщо попередні впали, наступні можна не запускати:

Ворота Форма команди Що ловить Вартість
Лінт схеми node scripts/lint-schemas.mjs Синтаксичні помилки, застарілі ключові слова Мілісекунди
Перевірка фікстур Набір тестів із розділу 2 Розсинхрон фікстур і схеми, випадкові правки обмежень Мілісекунди
Diff ламальних змін json-schema-diff-validator проти main Видалення required, звуження enum, посилення pattern Секунди
Перевірка живих відповідей Smoke-тести на staging з ассертом .toMatchSchema() Дрейф реального ендпоінта Секунди

Мінімальне налаштування GitHub Actions:

- run: npm ci
- run: npm run lint:schemas        # Ajv2020 lint
- run: npm run test:schema         # трійця фікстур
- run: npx jest --ci --testPathPattern=schema   # захист снапшотів від дрейфу

Ворота ламальних змін заслуговують окремого акценту: інструменти на кшталт json-schema-diff-validator підсвічують «видалене required» і «втрачене значення enum» ще на рев'ю — саме ті дві зміни, які звичайні API-diff суцільно і далі визнають «неламальними». Для терпимого споживача видалення поля нешкідливе, а для споживача з exhaustive switch це креш на проді.

6. Ассертуйте об'єкт помилки, а не лише булеве значення

Негативного тесту з одним validate(data) === false мало. Для тієї самої схеми варто переконатися, що вона відхиляє з правильної причини: не виключено, що випадково спрацювало стороннє обмеження. В об'єкта помилки Ajv є знаменита асиметрія: при провалі required поле instancePath вказує на батьківський об'єкт, а ім'я бракуючого поля сховане в params.missingProperty. У нашому замірі за відсутності id отримано:

{ "instancePath": "", "schemaPath": "#/required",
  "keyword": "required", "params": { "missingProperty": "id" } }

Ассерт пишеться як «існує помилка з keyword=required і missingProperty=id», а не розпливчасте errors.length > 0. Повний розбір семантики полів помилки — у статті Помилки JSON Schema незрозумілі: локалізація помилок Ajv і робота з повідомленнями.

7. Часті пастки — коротка таблиця

Пастка Наслідок Захід
Тестуються лише 200 happy path Дрейф конвертів помилок ніхто не помічає Схема і фікстури для відповідей про помилки також
Згенеровані схеми без рев'ю required, виведені з одного зразка, відкидають реальні дані; команда вимикає валідацію Ручна перевірка required/enum після генерації
Одна схема на всі версії Відмінності v1/v2 стираються Версіонувати схеми разом з ендпоінтами
Повні payload у логи при збої PII і секрети витікають у логи CI Писати лише JSON Pointer шляхи й обривки
Застиглі enum Перейменування в продукті (canceled→cancelled) ламає валідацію Зміни enum через рев'ю сумісності
Неприпковані версії валідатора Різна поведінка на різних машинах Фіксувати версії валідатора в CI
Заміна тестів авторизації 403 пасує під схему помилки, але логіка ролей хибна Схема — про структуру, авторизація тестується окремо
Забута семантика format Мусорні адреси проходять вільно Ставити ajv-formats / передавати format_checker, додавати негативи

8. Чек-лист упровадження

  1. Файли схем у системі контролю версій, node scripts/lint-schemas.mjs у лінт;
  2. У кожної схеми щонайменше 1 позитивна фікстура і по 1 негативній на кожне обмеження; ім'я файла — ассерт;
  3. Об'єкти помилок знімаються в снапшоти; у CI передавати --ci, щоб снапшоти не дрейфували самі;
  4. Екземпляри валідатора перевживаються на рівні модуля — жодних new всередині обробників;
  5. Diff ламальних змін вішается на PR; правки required/enum — примусове рев'ю;
  6. Перший PR спеціально доводить, що ворота вміють падати: видаліть required, побачте червоний колір, поверніть назад.

Питання суворості валідації (коли зачиняти additionalProperties) — окрема тема, див. additionalProperties не закрито — валідація схеми марна. Ця стаття про те, як зберігати написані правила постійно достовірними. За основами — до статті JSON Schema від основ до інженерії.

FAQ

Як пов'язані валідація схеми і контрактне тестування в дусі Pact?

Це поділ праці. Валідація схеми відповідає на запитання «чи структурно вірний цей документ» — однобічний ассерт. Споживчо-орієнтоване контрактне тестування (Pact) відповідає на запитання «чи досі провайдер задовольняє конкретні потреби споживача X» і вимагає двобічної перевірки. Малим командам варто стартувати з воріт схеми, а Pact упроваджувати з ростом числа сервісів.

Скільки негативних тестів досить?

Один негатив на обмеження — нижня планка: скільки в схемі обмежень (required, type, minimum, enum, pattern, additionalProperties…), стільки й негативних фікстур, плюс пари «граничне значення відхиляється/приймається». Якщо негативів менше за число обмежень, якесь обмеження лишилось без нагляду.

Яку зміну схеми вважати «ламальною»?

Судіть із позиції споживача: видалення поля відповіді, видалення чи звуження enum, посилення pattern, додавання обов'язкового поля запиту — усе це ламальне; додавання необов'язкового поля відповіді зазвичай безпечне — за умови, що декодер споживача терпить незнайомі ключі. Автоматизуйте порівняння json-schema-diff-validator на етапі PR, сірі зони вирішуйте вручну.

Тестувати на реальному API чи на моках?

І те й те, але пошарово: тести фікстур не торкаються мережі, ідуть за мілісекунди і запускаються в кожному PR; жива перевірка б'є по staging, триває секунди і належить до smoke-конвеєра, а за відсутності креденшелів пропускається через skipif. Команди, що гоняють у CI лише реальний API, знесилюються мережевим тремтінням і лімітами і врешті вимикають тести.

Джерела

Схожі статті

Коли помилки JSON Schema не мають сенсу: локалізація помилок Ajv і керування повідомленнями

Валідатор видає машинні помилки; користувачі хочуть знати, яке поле введення неправильне. Охоплює масив errors Ajv, instancePath проти schemaPath, групування й формулювання.

Просунутий~10 хв читанняОновлено 2026-10-31

Без additionalProperties перевірка схеми нічого не перевіряє

Успішна перевірка не означає чисті дані. Pydantic типово ігнорує невідомі поля, Zod їх вилучає, Ajv пропускає — вигадані параметри й одруківки проходять тихо. Стаття показує три форми мовчазного прийняття та дає повні рецепти: закривати об'єкти, контролювати приведення типів, підтягувати кожен рівень вкладеності.

Intermediate~8 хв читанняОновлено 2026-11-02

JSON Schema: від основ до виробництва

Ключових слів багато, але щодня використовують лише десяток. Правильно розберіться з type/properties/required/$ref, добре обробіть повідомлення про помилки — і покриєте вісімдесят відсотків реальних випадків.

Середній~14 хв читанняОновлено 2026-10-30