Testowanie kontraktowe JSON Schema: przyspawaj reguły walidacji do CI
Podsumowanie: Schemat nie kończy się napisaniem — tam zaczyna się degradacja. Jedna literówka po cichu rozluźnia walidację, jeden refaktoryzing łamie wszystkie fixture. Ten przewodnik rozbiera użyteczny układ testowania kontraktowego: fixture pozytywne, negatywne i snapshoty, lint samego schematu, jednokrotną kompilację z ponownym użyciem oraz cztery bramy CI, które zamieniają zerwanie kontraktu w czerwony build.

Wniosek: schemat to kod, a kod wymaga testów. Fixture pozytywne dowodzą, że przepuszcza to, co powinien; negatywne dowodzą, że zatrzymuje to, co trzeba; snapshoty pilnują niezamierzonych zmian. Wreszcie ajv compile i snapshoty z --ci pełnią nocną wartę w twoim pipeline.
W chwili, gdy reguła walidacji się łamie, nic nie rzuca błędu: rozszerzenie akceptacji umyka uwadze, zaostrzenie odrzucania ponosi cały zespół. Schemat nie jest plikiem konfiguracji — to kod, którego nikt nie testuje.
1. Schemat się zepsuł — kto dowiaduje się pierwszy
Zacznijmy od pułapki, w którą naprawdę wdepnęliśmy. Zadeklaruj schemat według szkicu 2020-12 i skompiluj go domyślnym pakietem Ajv: natychmiast wylatuje wyjątek:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
Domyślny punkt wejścia Ajv 8 zna tylko draft-07; dla 2020-12 trzeba importować z ajv/dist/2020. Kolejna pułapka: w trybie strict format: "email" bez zainstalowanego ajv-formats też powala kompilację:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Widzieć te błędy lokalnie przy pierwszej kompilacji jest dobrze. Problem: wiele projektów odsuwa kompilację do nadejścia requestu, a zepsuty schemat czeka na pierwszą ofiarę. Jeszcze podstępniejsze jest półzepsuty schemat: required omyłkowo zapisany jako string zamiast tablicy rzuca przy kompilacji schema is invalid: data/required must be array — ale jeśli ten krok nigdy nie biegł w CI, leży w produkcji.
Pierwsza brama więc nie waliduje danych: waliduje sam schemat. Jedno polecenie:
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}`);
}
Dlaczego nie npx ajv compile: pakiet ajv v8 nie zawiera CLI, więc npx ajv kończy się błędem could not determine executable to run; CLI jest w osobnym pakiecie ajv-cli (w naszym pomiarze 5.0.0). Ale ajv-cli 5 obsługuje tylko draft-07 i draft-2019-09 — dla schematu 2020-12 z sekcji 1 wyrzuca dokładnie ten sam no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Projekt na 2020-12 lintuje więc tym samym Ajv2020 co artykuł; npx ajv-cli compile -s '**/*.schema.json' --strict pasuje tylko do projektów draft-07.
Odpalaj je jak linter: literówki, przestarzałe słowa kluczowe i niezarejestrowane formaty wyskoczą już w PR. To pierwsze podobieństwo testowania schematu do testowania zwykłego kodu: najpierw upewnij się, że się kompiluje.
2. Triada: pozytyw, negatyw, snapshot
Szkielet testu schematu ma trzy części.
Fixture pozytywne: próbki zebrane z prawdziwych odpowiedzi, które mają przejść. Bierz je ze stagingu, nie z pamięci — pisane ręcznie pozytywy pokrywają tylko happy path i zapominają o null, pustych stringach i granicach z samymi polami obowiązkowymi.
Fixture negatywne: dla każdego ograniczenia schematu co najmniej jeden naruszający input. Dla minimum: 0 para -1 (odrzucenie) i 0 (przepuszczenie); dla enum wartość poza listą; dla additionalProperties: false nadmiarowe pole. Negatywy ważą więcej niż pozytywy: schemat, który po cichu puszcza złe dane, to cichy bug, który skaża aż do bazy danych.
Umowa o nazewnictwie, która czyni nazwę pliku asercją, kopiuje się z open source:
schemas/order.schema.json
fixtures/order/valid-minimal.json # musi przejść
fixtures/order/valid-full.json # musi przejść
fixtures/order/invalid-type.json # musi być odrzucony
fixtures/order/invalid-extra-field.json # musi być odrzucony
Wbudowany w Node node:test wystarcza:
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} powinien przejść`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} powinien zostać odrzucony`, () => { assert.ok(!validate(data)); });
}
}
Snapshoty: serializuj validate.errors do pliku i porównuj. Ktoś niechcący zmieni ograniczenie — snapshot natychmiast czerwienieje. W Jest/Vitest to jedna linia: expect(errors).toMatchSnapshot(); w CI zawsze podawaj --ci: nowe snapshoty wtedy padają zamiast być tworzonymi po cichu, a dryf nie jest tolerowany.
3. Zbierz wszystkie błędy w jednym przebiegu
Często pomijane ustawienie przy testowaniu negatywów: domyślnie walidator zatrzymuje się na pierwszym błędzie. Dane są zepsute w trzech miejscach, widzisz jedno, poprawiasz, odpalasz znów — i tak dalej. Z allErrors: true (Ajv) albo iter_errors (Python) wszystko widać naraz:
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 kryje też fakt sprzeczny z intuicją: który błąd zgłosi validate(), wybiera nie „pierwszy", lecz best_match. W naszym pomiarze przy trzech naruszeniach (zły typ id, role poza listą, nadmiarowe pole extra) zgłoszono tylko additionalProperties — błąd typu id pozostał ukryty. W CI zawsze używaj iter_errors dla pełnego obrazu.
I jeszcze łatwiejsza pułapka: format domyślnie nie jest asercją. Szkic 2020-12 klasyfikuje format jako adnotację: pythonowe jsonschema nie waliduje go bez przekazanego format_checker — w naszym teście "not-an-email" przeszedł swobodnie. Ajv wymaga instalacji i rejestracji ajv-formats. Jeśli format jest istotny (e-mail, data, uuid), musi istnieć negatywny fixture, który dowiezie, że twoja konfiguracja walidatora naprawdę go sprawdza.
4. Kompiluj raz — nie używaj kompilatora jak materiału zużywalnego
Pomiary lokalne (Node v22.13.1, Ajv 8.17, ten sam trzykrotnie zagnieżdżony schemat, te same naruszające dane):
| Podejście | Czas na 10 000 walidacji |
|---|---|
Za każdym razem new Ajv() i compile |
42233 ms |
Instancja na poziomie modułu + compile raz |
6.9 ms |
Współczynnik 6000. Walidator wewnątrz request handlera tworzący nowe Ajv przy każdym wywołaniu to najczęstszy wypadek wydajnościowy. Zanotujmy i rozpowszechnione mniemanie: „ajv.validate(schema, data) kompiluje na nowo przy każdym wywołaniu". Zmierzone: 100 000 wywołań w 8.2 ms — porównywalne z 11.2 ms funkcji skompilowanej raz, bo Ajv trzyma wewnętrzną cache schematów. Prawdziwą chorobą nie jest cache miss, tylko nieponowne użycie instancji. W Pythonie podobnie: ponownie używany Draft202012Validator jest ok. 5000 razy szybszy niż każdorazowe wywołanie validate() (2000 iteracji: 0.3 ms wobec 1566 ms), bo ten drugi przy każdym wywołaniu kontroluje sam schemat.
5. Przyspawaj bramy do CI: cztery drzwi
Uporządkowane od tanich do drogich: jeżeli wcześniejsze padną, późniejsze nie muszą biec:
| Brama | Kształt polecenia | Co łapie | Koszt |
|---|---|---|---|
| Lint schematu | node scripts/lint-schemas.mjs |
Błędy składni, przestarzałe słowa kluczowe | Milisekundy |
| Walidacja fixture | Zestaw testów z sekcji 2 | Rozjazd fixture i schematu, omylnie zmienione ograniczenia | Milisekundy |
| Diff zmian łamiących | json-schema-diff-validator przeciw main |
Usunięte required, zawężone enum, zaostrzone pattern | Sekundy |
| Walidacja żywych odpowiedzi | Smoke testy na staging z asercją .toMatchSchema() |
Dryf prawdziwego endpointu | Sekundy |
Minimalna konfiguracja GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # triada fixture
- run: npx jest --ci --testPathPattern=schema # strażnik dryfu snapshotów
Brama zmian łamiących zasługuje na osobny akcent: narzędzia jak json-schema-diff-validator znaczą na czerwono „usunięte pole required" i „utracona wartość enum" już w rewizji — właśnie te dwie zmiany, które zwyczajne API-diff chętnie uznają za „nielamiące". Usunięcie pola odpowiedzi jest nieszkodliwe dla tolerancyjnego konsumenta i fatalne dla konsumenta z exhaustive switch.
6. Asertuj obiekt błędu, nie tylko boolean
Negatywny test, który asertuje tylko validate(data) === false, nie wystarcza. Dla tego samego schematu upewnij się, że odrzuca z właściwego powodu: obce ograniczenie mogło przejść przypadkiem. Obiekt błędu Ajv ma słynną asymetrię: gdy zawodzi required, instancePath wskazuje obiekt rodzica, a nazwa brakującego pola chowa się w params.missingProperty. Zmierzone przy brakującym id:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
Asercja pisze się jako „istnieje błąd z keyword=required i missingProperty=id", a nie mglisty errors.length > 0. Pełne rozszyfrowanie semantyki pól błędu jest w Błędy JSON Schema nieczytelne: lokalizacja błędów Ajv i pielęgnacja komunikatów.
7. Częste pułapki — szybka tabela
| Pułapka | Następstwo | Lekarstwo |
|---|---|---|
| Testować tylko 200 happy path | Dryf kopert błędów umyka uwadze | Schema i fixture także dla odpowiedzi o błędach |
| Generowane schematy bez przeglądu | required wydedukowane z jednej próbki odrzucają realne dane; zespół wyłącza walidację | Ludzki przegląd required/enum po generowaniu |
| Jedno globalne schema dla wszystkich wersji | Różnice v1/v2 się zacierają | Wersjonować schematy razem z endpointami |
| Całe payloady do logów przy awarii | PII i sekrety ciekną do logów CI | Zapisywać tylko ścieżki JSON Pointer i skrócone urywki |
| Zamrożone enumy | Zmiana nazwy w produkcie (canceled→cancelled) łamie walidację | Zmiany enum przez przegląd kompatybilności |
| Nieprzypięte wersje walidatora | Rozbieżne zachowania między maszynami | Przypiąć wersje walidatora w CI |
| Zastąpienie testów autoryzacji | 403 może pasować do schematu błędu, a logika ról i tak być zła | Schema pilnuje struktury; autoryzację testuje się osobno |
| Zapomniana semantyka format | Śmieciowe adresy przechodzą swobodnie | Zainstalować ajv-formats / przekazać format_checker, dodać negatywy |
8. Checklista wdrożenia
- Pliki schematów w systemie wersjonowania,
node scripts/lint-schemas.mjsdo lintu; - Dla każdego schematu co najmniej 1 pozytywny fixture i po 1 negatywnym na ograniczenie; nazwa pliku jest asercją;
- Obiekty błędów do snapshotów; w CI podawaj
--ci, żeby snapshoty nie dryfowały same; - Instancje walidatora ponownie używaj na poziomie modułu — żadnych new w handlerach;
- Diff zmian łamiących zawieś na PR; zmiany required/enum z obowiązkowym przeglądem;
- Pierwszy PR celowo dowodzi, że brama umie paść: usuń required, zobacz czerwień, przywróć.
Kwestia surowości walidacji (kiedy zamykać additionalProperties) to inny temat — zobacz additionalProperties pozostawione otwarte: walidacja schematu jest bezużyteczna. Ten artykuł dotyczy utrzymania zapisanych reguł nieustannie wiarygodnymi. Podstawy zacznij od JSON Schema od podstaw do inżynierii.
FAQ
Jaki związek ma walidacja schematu z testowaniem kontraktowym à la Pact?
To podział pracy. Walidacja schematu odpowiada na pytanie „czy ten dokument jest strukturalnie poprawny" — jednostronna asercja. Testowanie kontraktowe sterowane konsumentem (Pact) odpowiada na pytanie „czy dostawca wciąż spełnia konkretne potrzeby konsumenta X" i wymaga dwustronnej weryfikacji. Małe zespoły zaczynają od bram schematu, a Pact wprowadzają wraz z rosnącą liczbą usług.
Ile negatywnych testów wystarczy?
Jeden negatyw na ograniczenie to dolna granica: tyle negatywnych fixture, ile schemat ma ograniczeń (required, type, minimum, enum, pattern, additionalProperties…), plus pary wartości granicznych odrzuconych/zaakceptowanych. Mniej negatywów niż ograniczeń znaczy, że jakieś ograniczenie zostało bez nadzoru.
Która zmiana schematu liczy się jako „łamiąca"?
Sądź z punktu widzenia konsumenta: usunięcie pola odpowiedzi, usunięcie czy zawężenie enum, zaostrzenie pattern, dodanie obowiązkowego pola requestu — to wszystko łamie; dodanie opcjonalnego pola odpowiedzi zwykle jest bezpieczne — pod warunkiem, że dekoder konsumenta toleruje nieznane klucze. Zautomatyzuj porównanie json-schema-diff-validatorem w momencie PR, a szare strefy rozstrzygaj ręcznie.
Testować przeciwko prawdziwemu API czy mockom?
Oba, lecz warstwowo: testy fixture nie dotykają sieci, biegną w milisekundach i ruszają przy każdym PR; żywa walidacja bije w staging, trwa sekundy, należy do smoke pipeline, a bez poświadczeń jest pomijana przez skipif. Zespoły, które w CI puszczają tylko prawdziwe API, wyżerają się na sieciowym dygotaniu i limitach zapytań i ostatecznie wyłączają testy.