Kontraktní testování JSON Schema: svařte validační pravidla do CI
Shrnutí: Schéma nekončí napsáním — tam začíná úpadek. Jedno překlepnutí potichu uvolní validaci, jeden refaktoring rozbije všechny fixture. Tato příručka rozebírá použitelné uspořádání kontraktního testování: pozitivní, negativní a snapshot fixture, lint samotného schématu, jednorázovou kompilaci s opakovaným použitím a čtyři brány CI, které promění porušení kontraktu v červený build.

Závěr: schéma je kód a kód vyžaduje testy. Pozitivní fixture dokazují, že pouští naproti to správné; negativní, že zadržuje to zakázané; snapshoty hlídají neúmyslné změny. A ajv compile se snapshoty s --ci drží noční hlídku ve vašem pipeline.
V okamžiku, kdy se validační pravidlo rozbije, nic nevyhodí chybu: rozšíření propouštění si nikdo nevšimne, zpřísnění odmítání nese celý tým. Schéma není konfigurační soubor — je to kód, který nikdo netestuje.
1. Schéma se rozbilo — kdo se to dozví první
Začněme pastí, do které jsme opravdu šlápli. Deklarujte schéma podle návrhu 2020-12 a zkompilujte ho výchozím balíčkem Ajv: okamžitě vyletí výjimka:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
Výchozí vstupní bod Ajv 8 zná jen draft-07; pro 2020-12 je třeba import z ajv/dist/2020. Další past: ve strict režimu format: "email" bez nainstalovaného ajv-formats srazí také kompilaci:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Vidět tyto chyby lokálně při první kompilaci je dobře. Problém je, že mnoho projektů odkládá kompilaci na příjezd requestu a rozbité schéma čeká na první oběť. Ještě rafinovanější je napůl rozbité schéma: required omylem zapsaný jako řetězec místo pole hodí při kompilaci schema is invalid: data/required must be array — ale pokud tento krok nikdy neběžel v CI, leží v produkci.
První brána tedy nevaliduje data: validuje samo schéma. Jeden příkaz:
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}`);
}
Proč ne npx ajv compile: balíček ajv v8 neobsahuje CLI, takže npx ajv selže s could not determine executable to run; CLI je v samostatném balíčku ajv-cli (v našem měření 5.0.0). Ale ajv-cli 5 podporuje pouze draft-07 a draft-2019-09 — na schématu 2020-12 z první sekce vyhodí stejné no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Projekt na 2020-12 proto lintuje přes stejný Ajv2020 jako článek; npx ajv-cli compile -s '**/*.schema.json' --strict patří jen projektům draft-07.
Pouštějte ho jako linter: překlepy, zastaralá klíčová slova a neregistrované formáty se objeví už v PR. To je první podoba mezi testováním schématu a testováním obyčejného kódu: nejprve se přesvědčte, že kompiluje.
2. Trojice: pozitiv, negativ, snapshot
Kostra testu schématu má tři díly.
Pozitivní fixture: vzorky odebrané z reálných odpovědí, které mají projít. Berte je ze stagingu, ne z paměti — ručně psané pozitivy pokrývají jen happy path a zapomínají null, prázdné řetězce a hranice jen s povinnými poli.
Negativní fixture: pro každé omezení schématu aspoň jeden porušující vstup. Pro minimum: 0 pár -1 (odmítnutí) a 0 (průchod); pro enum hodnotu mimo seznam; pro additionalProperties: false nadbytečné pole. Negativy jsou důležitější než pozitivy: schéma, které potichu pouští špatná data, je tichý bug, který znečistí až databázi.
Úmluva o pojmenování, která mění název souboru v aserci, se dá zkopírovat z open source:
schemas/order.schema.json
fixtures/order/valid-minimal.json # musí projít
fixtures/order/valid-full.json # musí projít
fixtures/order/invalid-type.json # musí být odmítnuto
fixtures/order/invalid-extra-field.json # musí být odmítnuto
Vestavěný node:test ve Node stačí:
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} by měl projít`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} by měl být odmítnut`, () => { assert.ok(!validate(data)); });
}
}
Snapshoty: serializujte validate.errors do souboru a porovnávejte. Někdo neúmyslně změní omezení — snapshot se okamžitě zarudne. V Jest/Vitest je to jeden řádek: expect(errors).toMatchSnapshot(); v CI vždy dávejte --ci: nové snapshoty pak padají místo tichého vytvoření a drift není tiše akceptován.
3. Sesbírejte všechny chyby v jednom průchodu
Často opomíjené nastavení při testování negativů: ve výchozím stavu validátor zastaví na první chybě. Data jsou rozbitá na třech místech, vidíte jedno, opravíte, spustíte znovu — a tak dál. S allErrors: true (Ajv) nebo iter_errors (Python) vidíte vše najednou:
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 si uchovává i protiintuitivní fakt: kterou chybu validate() nahlásí, nevybírá „první“, ale best_match. V našem měření při třech porušeních (špatný typ id, role mimo seznam, nadbytečné pole extra) bylo nahlášeno jen additionalProperties — chyba typu id zůstala skrytá. V CI vždy používejte iter_errors pro celkový obraz.
A ještě snadnější past: format není ve výchozím stavu aserce. Návrh 2020-12 řadí format jako anotaci: pythonovské jsonschema ho nevaliduje bez předaného format_checker — v našem testu "not-an-email" prošel volně. Ajv vyžaduje instalaci a registraci ajv-formats. Pokud na formátu záleží (e-mail, datum, uuid), musí existovat negativní fixture, který dokaže, že vaše konfigurace validátoru ho opravdu kontroluje.
4. Kompilujte jednou — nepoužívejte kompilátor jako spotřební materiál
Lokální měření (Node v22.13.1, Ajv 8.17, stejné trojnásobně vnořené schéma, stejná porušující data):
| Přístup | Čas na 10 000 validací |
|---|---|
Pokaždé new Ajv() a compile |
42233 ms |
Instance na úrovni modulu + compile jednou |
6.9 ms |
Faktor 6000. Validátor uvnitř request handleru, který při každém volání tvoří nové Ajv, je nejčastější výkonnostní havárie. Připišme i rozšířený omyl: „ajv.validate(schema, data) kompiluje znovu při každém volání“. Měřeno: 100 000 volání za 8.2 ms — srovnatelné s 11.2 ms u funkce zkompilované jednou, protože Ajv drží interní cache schémat. Skutečnou nemocí není cache miss, ale neopakované použití instance. V Pythonu totéž: znovu používaný Draft202012Validator je zhruba 5000× rychlejší než každé volání validate() (2000 iterací: 0.3 ms proti 1566 ms), protože ten druhý při každém volání kontroluje samo schéma.
5. Svařte brány do CI: čtyři dveře
Seřazeno od laciných k drahým: pokud předchozí padnou, další běžet nemusejí:
| Brána | Tvar příkazu | Co chytá | Náklady |
|---|---|---|---|
| Lint schématu | node scripts/lint-schemas.mjs |
Syntaktické chyby, zastaralá klíčová slova | Milisekundy |
| Kontrola fixture | Testovací sada z oddílu 2 | Rozhození fixture a schématu, omylem změněná omezení | Milisekundy |
| Diff ničivých změn | json-schema-diff-validator proti main |
Odstraněné required, zúžené enum, zpřísněné pattern | Sekundy |
| Kontrola živých odpovědí | Smoke testy na staging s asercí .toMatchSchema() |
Drift skutečného endpointu | Sekundy |
Minimální konfigurace GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # trojice fixture
- run: npx jest --ci --testPathPattern=schema # strážce snapshot driftu
Brána ničivých změn si zaslouží zvláštní důraz: nástroje jako json-schema-diff-validator označí červeně „odstraněné pole required“ a „ztracená hodnota enum“ už v revizi — právě ty dvě změny, které obyčejné API-diff rády soudí jako „neničivé“. Odebrání pole odpovědi je pro shovívavého konzumenta neškodné a pro konzumenta s exhaustive switch produkční pád.
6. Asertujte chybový objekt, ne jen boolean
Negativní test, který asertuje jen validate(data) === false, nestačí. U téhož schématu se ujistěte, že odmítá ze správného důvodu: cizí omezení mohlo projít náhodou. Chybový objekt Ajv má slavnou asymetrii: když selže required, instancePath ukazuje na rodičovský objekt a jméno chybějícího pole se skrývá v params.missingProperty. Změřeno při chybějícím id:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
Aserce se píše jako „existuje chyba s keyword=required a missingProperty=id“, nikoli vágní errors.length > 0. Úplné rozluštění sémantiky chybových polí najdete v Chyby JSON Schema nečitelné: lokalizace chyb Ajv a péče o zprávy.
7. Časté pasti — rychlá tabulka
| Past | Následek | Lék |
|---|---|---|
| Testovat jen 200 happy path | Drift obálek chyb si nikdo nevšimne | Schema a fixture i pro chybové odpovědi |
| Generovaná schémata bez revize | required odvozené z jediného vzorku odmítají reálná data; tým validaci vypne | Lidská revize required/enum po generování |
| Jedno globální schéma pro všechny verze | Rozdíly v1/v2 se smažou | Verzovat schémata spolu s endpointy |
| Celé payloady do logů při selhání | PII a tajemství vytečou do logů CI | Zapisovat jen JSON Pointer cesty a zkrácené útržky |
| Zmrazené enumy | Přejmenování v produktu (canceled→cancelled) rozbije validaci | Změny enum přes revizi kompatibility |
| Nepřipíchnuté verze validátoru | Rozdílné chování na různých strojích | Připíchnout verze validátoru v CI |
| Náhrada testů autorizace | 403 může ladit s chybovým schématem a logika rolí je přesto špatně | Schema hlídá strukturu; autorizace se testuje zvlášť |
| Zapomenutá sémantika format | Odpadové adresy procházejí volně | Nainstalovat ajv-formats / předat format_checker, přidat negativy |
8. Kontrolní seznam zavedení
- Soubory schémat ve verzovacím systému,
node scripts/lint-schemas.mjsdo lintu; - Pro každé schéma aspoň 1 pozitivní fixture a na každé omezení 1 negativní; název souboru je aserce;
- Chybové objekty do snapshotů; v CI dávejte
--ci, aby snapshoty nedriftovaly samy; - Instance validátoru znovu používejte na úrovni modulu — žádné new uvnitř handlerů;
- Diff ničivých změn pověste na PR; úpravy required/enum s povinnou revizí;
- První PR záměrně dokazuje, že brána umí padat: odeberte required, uvidíte červenou, vraťte zpět.
Otázka přísnosti validace (kdy zavřít additionalProperties) je jiné téma — viz additionalProperties ponechané otevřené: validace schématu je k ničemu. Tento článek je o udržení napsaných pravidel trvale důvěryhodnými. Základy najdete v JSON Schema od základů k inženýrství.
FAQ
Jak souvisí validace schématu a kontraktní testování ve stylu Pact?
Je to dělení práce. Validace schématu odpovídá na otázku „je tento dokument strukturně správný“ — jednostranná aserce. Konzumentem vedené kontraktní testování (Pact) odpovídá na otázku „poskytovatel stále splňuje konkrétní potřeby konzumenta X“ a vyžaduje oboustrannou kontrolu. Malé týmy začněte branami schématu a Pact zaveďte s rostoucím počtem služeb.
Kolik negativních testů stačí?
Jeden negativ na omezení je spodní hranice: tolik negativních fixture, kolik schéma má omezení (required, type, minimum, enum, pattern, additionalProperties…), plus páry hraničních hodnot odmítnutých/přijatých. Méně negativů než omezení znamená, že nějaké omezení zůstalo bez dohledu.
Která změna schématu se počítá jako „ničivá“?
Saďte z pohledu konzumenta: odstranění pole odpovědi, odstranění či zúžení enum, zpřísnění pattern, přidání povinného pole requestu — to všechno ničí; přidání nepovinného pole odpovědi bývá bezpečné — za předpokladu, že dekodér konzumenta snáší neznámé klíče. Porovnání automatizujte json-schema-diff-validatorem v momentě PR a šedé zóny rozhodujte ručně.
Testovat proti reálnému API nebo mockům?
Obě, ale po vrstvách: testy fixture se sítě nedotýkají, běží za milisekundy a startují u každého PR; živá kontrola bije na staging, trvá sekundy, patří do smoke pipeline a bez přihlašovacích údajů se přeskočí skipifem. Týmy, které v CI jezdí jen reálné API, se otřou o síťové zakymčování a rate limity a nakonec testy vypnou.