Тестирование 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 линтуется через тот же 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, изматываются сетевым дрожанием и лимитами и в конце концов выключают тесты.