Контрактне тестування JSON Schema: вбудуйте правила валідації в CI
Головне: Схема не завершується написанням — саме звідти починається деградація. Одна одруківка тихо послаблює валідацію, один рефакторинг ламає всі фікстури. Тут розібрано робочий набір контрактного тестування: позитивні, негативні та снапшот-фікстури, лінт самої схеми, одноразову компіляцію з повторним використанням і четверо воріт 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. Чек-лист упровадження
- Файли схем у системі контролю версій,
node scripts/lint-schemas.mjsу лінт; - У кожної схеми щонайменше 1 позитивна фікстура і по 1 негативній на кожне обмеження; ім'я файла — ассерт;
- Об'єкти помилок знімаються в снапшоти; у CI передавати
--ci, щоб снапшоти не дрейфували самі; - Екземпляри валідатора перевживаються на рівні модуля — жодних new всередині обробників;
- Diff ламальних змін вішается на PR; правки required/enum — примусове рев'ю;
- Перший 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, знесилюються мережевим тремтінням і лімітами і врешті вимикають тести.