JSON-Schema-Vertragstests: Validierungsregeln in die CI schweißen
Fazit: Ein Schema hört beim Schreiben nicht auf — dort beginnt der Verfall. Ein Tippfehler lockert die Validierung still, ein Refactoring bricht alle Fixtures. Dieser Leitfaden zeigt eine praktikable Vertragstest-Architektur: positive, negative und Snapshot-Fixtures, Lint des Schemas selbst, einmalige kompilierte Wiederverwendung und vier CI-Tore, die einen Vertragsbruch in einen roten Build verwandeln.

Fazit: Ein Schema ist Code, und Code braucht Tests. Positive Fixtures beweisen, dass er das Richtige durchlässt; negative beweisen, dass er das Falsche zurückweist; Snapshots überwachen unbeabsichtigte Änderungen. Schließlich halten ajv compile und Snapshots mit --ci die Nachtwache in Ihrer Pipeline.
In dem Moment, in dem eine Validierungsregel bricht, wirft nichts einen Fehler: Eine Verbreiterung der Akzeptanz bleibt unbemerkt, eine Verschärfung der Ablehnung zahlt das ganze Team. Ein Schema ist keine Konfigurationsdatei — es ist Code, den niemand testet.
1. Das Schema ist kaputt — wer erfährt es zuerst
Beginnen wir mit einer Falle, in die wir wirklich getreten sind. Deklarieren Sie ein Schema nach dem 2020-12-Entwurf und kompilieren Sie es mit dem Standardpaket von Ajv, fliegt sofort eine Ausnahme:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
Der Standardeinstiegspunkt von Ajv 8 kennt nur draft-07; für 2020-12 braucht es den Import aus ajv/dist/2020. Eine weitere Falle: Im Strict-Modus wirft format: "email" ohne installiertes ajv-formats ebenfalls beim Kompilieren eine Ausnahme:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Diese Fehler lokal bei der ersten Kompilation zu sehen, ist gut. Das Problem: Viele Projekte schieben die Kompilation auf den Zeitpunkt des eingehenden Requests, und das kaputte Schema wartet auf sein erstes Opfer. Noch heimtückischer ist das halbkaputte Schema: ein required, versehentlich als String statt als Array geschrieben, wirft beim Kompilieren schema is invalid: data/required must be array — aber lief dieser Schritt nie in der CI, liegt es in der Produktion begraben.
Das erste Tor prüft also nicht die Daten, sondern das Schema selbst. Ein Befehl:
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}`);
}
Warum nicht npx ajv compile: Das Paket ajv v8 enthält keine CLI, npx ajv scheitert mit could not determine executable to run; die CLI steckt im separaten Paket ajv-cli (bei uns 5.0.0). ajv-cli 5 unterstützt aber nur draft-07 und draft-2019-09 — auf das 2020-12-Schema aus Abschnitt 1 angewandt wirft es denselben Fehler no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Ein 2020-12-Projekt lintet daher über denselben Ajv2020-Einstieg wie im Artikel; npx ajv-cli compile -s '**/*.schema.json' --strict passt nur zu draft-07-Projekten.
Fahren Sie ihn wie einen Linter: Tippfehler, veraltete Schlüsselwörter und nicht registrierte Formate erscheinen schon im PR. Das ist die erste Gemeinsamkeit von Schema-Tests und gewöhnlichem Code-Testen: zuerst prüfen, dass er kompiliert.
2. Das Trio: positiv, negativ, Snapshot
Das Skelett des Schema-Tests besteht aus drei Teilen.
Positive Fixtures: aus echten Antworten gezogene Samples, die durchgehen müssen. Nehmen Sie sie von der Staging-Umgebung, nicht aus dem Gedächtnis — handgeschriebene Positive decken nur den Happy Path ab und übersehen null, leere Strings und Grenzen mit nur Pflichtfeldern.
Negative Fixtures: für jede Einschränkung des Schemas mindestens eine verletzende Eingabe. Für minimum: 0 das Paar -1 (Ablehnung) und 0 (Durchlass); für enum ein Wert außerhalb der Liste; für additionalProperties: false ein überflüssiges Feld. Negative sind wichtiger als positive: Ein Schema, das still schlechte Daten durchlässt, ist ein stiller Bug, der bis in die Datenbank verschmutzt.
Die Namenskonvention, die den Dateinamen zur Assertion macht, lässt sich aus Open Source kopieren:
schemas/order.schema.json
fixtures/order/valid-minimal.json # muss durchgehen
fixtures/order/valid-full.json # muss durchgehen
fixtures/order/invalid-type.json # muss abgelehnt werden
fixtures/order/invalid-extra-field.json # muss abgelehnt werden
Das in Node eingebaute node:test genügt:
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} sollte durchgehen`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} sollte abgelehnt werden`, () => { assert.ok(!validate(data)); });
}
}
Snapshots: Serialisieren Sie validate.errors in eine Datei und vergleichen Sie. Jemand ändert unbeabsichtigt eine Einschränkung — der Snapshot rötet sich sofort. In Jest/Vitest ist es eine Zeile: expect(errors).toMatchSnapshot(); in der CI immer --ci mitgeben: Neue Snapshots schlagen dann fehl, statt still erzeugt zu werden, und Drift wird nicht stillschweigend akzeptiert.
3. Alle Fehler in einem Durchlauf sammeln
Eine oft übersehene Einstellung beim Testen von Negativen: Standardmäßig stoppt der Validator beim ersten Fehler. Die Daten sind an drei Stellen kaputt, Sie sehen eine, fixen, starten neu — und so weiter. Mit allErrors: true (Ajv) oder iter_errors (Python) sehen Sie alles auf einmal:
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 hat auch einen kontra-intuitiven Fakt: Welche Meldung validate() ausgibt, wählt nicht „die erste“, sondern best_match. In unserer Messung bei drei Verletzungen (falscher Typ von id, role außerhalb der Liste, überflüssiges Feld extra) wurde nur additionalProperties gemeldet — der Typfehler von id blieb verborgen. Verwenden Sie in der CI immer iter_errors für das volle Bild.
Und eine noch leichtere Falle: format ist standardmäßig keine Assertion. Der Entwurf 2020-12 stuft format als Annotation ein: Das jsonschema von Python prüft ihn nicht ohne übergebenen format_checker — in unserem Test lief "not-an-email" frei durch. Ajv verlangt die Installation und Registrierung von ajv-formats. Wenn ein Format wichtig ist (E-Mail, Datum, UUID), muss es eine negative Fixture geben, die beweist, dass Ihre Validator-Konfiguration ihn wirklich prüft.
4. Einmal kompilieren — den Compiler nicht als Wegwerfartikel benutzen
Lokale Messungen (Node v22.13.1, Ajv 8.17, dasselbe dreifach verschachtelte Schema, dieselben verletzenden Daten):
| Ansatz | Zeit für 10.000 Validierungen |
|---|---|
Jedes Mal new Ajv() und compile |
42233 ms |
Instanz auf Modulebene + compile einmal |
6.9 ms |
Ein Faktor 6000. Ein Validator im Request-Handler, der bei jedem Aufruf ein neues Ajv erzeugt, ist der häufigste Performance-Unfall. Halten wir auch einen weitverbreiteten Irrtum fest: „ajv.validate(schema, data) kompiliert jedes Mal neu“. Gemessen: 100.000 Aufrufe in 8.2 ms — vergleichbar mit 11.2 ms der einmal kompilierten Funktion, denn Ajv hält einen internen Schema-Cache. Die echte Not ist kein Cache-Miss, sondern die nicht wiederverwendete Instanz. In Python gilt dasselbe: Ein wiederverwendeter Draft202012Validator ist rund 5000-mal schneller als jedesmaliger validate()-Aufruf (2000 Iterationen: 0.3 ms gegen 1566 ms), denn letzterer prüft bei jedem Aufruf das Schema selbst.
5. Die Tore in die CI schweißen: vier Türen
Von billig nach teuer geordnet: Fallen die vorderen, müssen die hinteren nicht laufen:
| Tor | Befehlsform | Was es erwischt | Kosten |
|---|---|---|---|
| Schema-Lint | node scripts/lint-schemas.mjs |
Syntaxfehler, veraltete Schlüsselwörter | Millisekunden |
| Fixture-Prüfung | Die Testsuite aus Abschnitt 2 | Entkopplung von Fixtures und Schema, versehentlich geänderte Einschränkungen | Millisekunden |
| Diff brechender Änderungen | json-schema-diff-validator gegen main |
Entferntes required, verengtes enum, verschärftes pattern | Sekunden |
| Prüfung echter Antworten | Smoke-Tests gegen Staging mit Assertion .toMatchSchema() |
Drift des echten Endpunkts | Sekunden |
Minimale GitHub-Actions-Konfiguration:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # Fixture-Trio
- run: npx jest --ci --testPathPattern=schema # Snapshot-Driftwache
Das Tor für brechende Änderungen verdient eigene Betonung: Werkzeuge wie json-schema-diff-validator markieren „required-Feld entfernt“ und „enum-Wert verloren“ schon im Review — genau die beiden Änderungen, die gewöhnliche API-Diffs gern als „nicht brechend“ einstufen. Ein entferntes Antwortfeld ist für tolerante Konsumenten harmlos und für Konsumenten mit exhaustivem Switch ein Produktionsabsturz.
6. Das Fehlerobjekt asserten, nicht nur den Booleschen Wert
Ein negativer Test mit nur validate(data) === false genügt nicht. Beim selben Schema sollten Sie sich vergewissern, dass es aus dem richtigen Grund ablehnt: Eine fremde Einschränkung kann zufällig durchgelaufen sein. Das Fehlerobjekt von Ajv hat eine berühmte Asymmetrie: Schlägt required fehl, zeigt instancePath auf das Elternobjekt, und der Name des fehlenden Felds versteckt sich in params.missingProperty. Gemessen bei fehlendem id:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
Die Assertion lautet „es existiert ein Fehler mit keyword=required und missingProperty=id“, nicht ein vages errors.length > 0. Die vollständige Analyse der Fehlerfeld-Semantik steht im Artikel JSON-Schema-Fehler unverständlich: Ajv-Fehler lokalisieren und Meldungen gestalten.
7. Häufige Fallen — Schnelltabelle
| Falle | Folge | Maßnahme |
|---|---|---|
| Nur 200 Happy Paths getestet | Drift der Fehlerhüllen bleibt unbemerkt | Schema und Fixtures auch für Fehlerantworten |
| Generierte Schemas ohne Review | Aus einer Probe abgeleitete required-Felder werfen echte Daten raus; das Team schaltet die Validierung ab | Menschliche Prüfung von required/enum nach der Generierung |
| Ein globales Schema für alle Versionen | v1/v2-Unterschiede verwischen | Schemas zusammen mit Endpunkten versionieren |
| Volle Payloads bei Fehlern in Logs | PII und Secrets lecken in CI-Logs | Nur JSON-Pointer-Pfade und gekürzte Schnipsel schreiben |
| Einfrorene Enums | Produktumbenennung (canceled→cancelled) bricht Validierung | Enum-Änderungen über Kompatibilitätsreview |
| Nicht gepinnte Validatoreversionen | Unterschiedliches Verhalten je Maschine | Validatoreversionen in der CI pinnen |
| Ersatz für Autorisierungstests | Ein 403 passt zum Fehlerschema, die Rollenlogik ist trotzdem falsch | Schema prüft Struktur; Autorisierung separat testen |
| Semantik von format vergessen | Müll-Adressen laufen frei durch | ajv-formats installieren / format_checker übergeben, Negative ergänzen |
8. Einführungs-Checkliste
- Schemadateien in die Versionsverwaltung,
node scripts/lint-schemas.mjsin den Lint; - Pro Schema mindestens 1 positive Fixture und je Einschränkung 1 negative; der Dateiname ist die Assertion;
- Fehlerobjekte als Snapshots; in der CI
--cimitgeben, damit Snapshots nicht selbst driften; - Validatoreinstanzen auf Modulebene wiederverwenden — nie new in Handlern;
- Diff brechender Änderungen an die PR hängen; required/enum-Änderungen erzwingen Review;
- Der erste PR bewusst: das Tor muss scheitern können — required entfernen, das Rot sehen, zurücksetzen.
Die Frage der Strenge der Validierung (wann man additionalProperties schließt) ist ein anderes Thema — siehe additionalProperties offen gelassen: Schema-Validierung ist wertlos. Dieser Artikel handelt davon, geschriebene Regeln dauerhaft verlässlich zu halten. Für die Grundlagen: JSON Schema von den Grundlagen zur Praxis.
FAQ
Wie hängen Schema-Validierung und vertragsbasiertes Testen à la Pact zusammen?
Das ist Arbeitsteilung. Die Schema-Validierung beantwortet „ist dieses Dokument strukturell korrekt“ — eine einseitige Assertion. Konsumentgetriebenes Vertragstesten (Pact) beantwortet „erfüllt der Provider noch die konkreten Bedürfnisse des Konsumenten X“ und verlangt eine zweiseitige Prüfung. Kleine Teams starten mit Schema-Toren und führen Pact ein, wenn die Zahl der Dienste wächst.
Wie viele negative Tests reichen?
Ein Negativ pro Einschränkung ist die Untergrenze: so viele negative Fixtures wie Einschränkungen (required, type, minimum, enum, pattern, additionalProperties…), dazu Paare aus abgelehntem und akzeptiertem Grenzwert. Weniger Negative als Einschränkungen heißt: eine Einschränkung bleibt unbewacht.
Welche Schema-Änderung gilt als „brechend“?
Urteilen Sie aus Konsumentensicht: Ein Antwortfeld entfernen, ein enum entfernen oder verengen, ein pattern verschärfen, ein Pflichtanfragefeld hinzufügen — all das bricht; ein optionales Antwortfeld hinzuzufügen ist meist sicher — vorausgesetzt, der Dekoder des Konsumenten toleriert unbekannte Schlüssel. Automatisieren Sie den Vergleich mit json-schema-diff-validator zum PR-Zeitpunkt, Grauzonen entscheiden Sie von Hand.
Gegen die echte API oder gegen Mocks testen?
Beides, aber geschichtet: Fixture-Tests berühren kein Netz, laufen in Millisekunden und bei jedem PR; die echte Prüfung schlägt auf die Staging-Umgebung, dauert Sekunden und gehört zur Smoke-Pipeline, wird ohne Zugangsdaten per skipif übersprungen. Teams, die in der CI nur die echte API fahren, zerschleiern sich an Netzwerkflackern und Rate-Limits und schalten die Tests schließlich ab.