JSON Toolkit

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.

Średni~8 min czytaniaOpublikowano 2026-11-05Zaktualizowano 2026-11-05
Diagram testowania kontraktowego JSON Schema i bram walidacji CI

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

  1. Pliki schematów w systemie wersjonowania, node scripts/lint-schemas.mjs do lintu;
  2. Dla każdego schematu co najmniej 1 pozytywny fixture i po 1 negatywnym na ograniczenie; nazwa pliku jest asercją;
  3. Obiekty błędów do snapshotów; w CI podawaj --ci, żeby snapshoty nie dryfowały same;
  4. Instancje walidatora ponownie używaj na poziomie modułu — żadnych new w handlerach;
  5. Diff zmian łamiących zawieś na PR; zmiany required/enum z obowiązkowym przeglądem;
  6. 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.

Źródła

Powiązane artykuły

Bez additionalProperties walidacja schematu nic nie sprawdza

Zdana walidacja nie oznacza czystych danych. Pydantic domyślnie ignoruje nieznane pola, Zod je usuwa, Ajv przepuszcza — zmyślone parametry i literówki przechodzą po cichu. Artykuł pokazuje trzy formy cichej akceptacji i daje pełne przepisy: zamykać obiekty, kontrolować koercję, zaciskać każdy poziom zagnieżdżenia.

Intermediate~8 min czytaniaZaktualizowano 2026-11-02

JSON Schema: od podstaw do produkcji

Słów kluczowych jest wiele, ale codziennie używa się tylko kilkunastu. Opanuj type/properties/required/$ref, dobrze obsłuż komunikaty błędów i pokryjesz osiemdziesiąt procent realnych przypadków.

Średniozaawansowany~14 min czytaniaZaktualizowano 2026-10-30