Test contrattuali JSON Schema: salda le regole di validazione nella CI
In sintesi: Uno schema non finisce quando è scritto — lì inizia il degrado. Un refuso rilassa la validazione in silenzio, un refactoring rompe tutte le fixture. Questa guida illustra un'impostazione di test contrattuali attuabile: fixture positivi, negativi e snapshot, lint dello schema stesso, compilazione una tantum riutilizzata e quattro porte CI che trasformano la rottura del contratto in una build rossa.

Conclusione: uno schema è codice, e il codice richiede test. Le fixture positive dimostrano che lascia passare ciò che deve; le negative dimostrano che blocca ciò che occorre; gli snapshot sorvegliano le modifiche involontarie. Infine ajv compile e gli snapshot con --ci fanno la ronda notturna nel tuo pipeline.
Nel momento in cui una regola di validazione si rompe, nulla solleva un errore: un allargamento dell'accettazione passa inosservato, un irrigidimento del rifiuto è pagato da tutto il team. Uno schema non è un file di configurazione — è codice che nessuno testa.
1. Lo schema si è rotto — chi lo scopre per primo
Iniziamo con una trappola realmente inciampata. Dichiara uno schema secondo la bozza 2020-12 e compila con il pacchetto predefinito di Ajv: subito parte un'eccezione:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
Il punto d'ingresso predefinito di Ajv 8 conosce solo draft-07; per il 2020-12 serve importare da ajv/dist/2020. Altra trappola: in modalità strict, scrivere format: "email" senza installare ajv-formats fa fallire anche la compilazione:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Vedere questi errori in locale alla prima compilazione è buono. Il problema: molti progetti rimandano la compilazione all'arrivo della request, e lo schema rotto attende la prima vittima. Più insidioso ancora è lo schema mezzo rotto: un required scritto per distrazione come stringa invece che array fa scattare schema is invalid: data/required must be array in compilazione — ma se quel passo non è mai girato in CI, dorme in produzione.
Il primo portale quindi non valida i dati: valida lo schema stesso. Un comando:
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}`);
}
Perché non npx ajv compile: il pacchetto ajv v8 non include una CLI, quindi npx ajv fallisce con could not determine executable to run; la CLI sta nel pacchetto separato ajv-cli (5.0.0 nella nostra misurazione). Ma ajv-cli 5 supporta solo draft-07 e draft-2019-09: applicato allo schema 2020-12 della sezione 1 solleva esattamente lo stesso no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Quindi un progetto 2020-12 esegue il lint con lo stesso Ajv2020 dell'articolo; npx ajv-cli compile -s '**/*.schema.json' --strict è adatto solo a progetti draft-07.
Fallo girare come un linter: refusi, parole chiave deprecate e formati non registrati emergono già nella PR. È la prima somiglianza tra testare uno schema e testare codice comune: prima assicurati che compili.
2. Il trio: positivo, negativo, snapshot
Lo scheletro del test di schema ha tre pezzi.
Fixture positivi: campioni raccolti da risposte reali, che devono passare. Prendili dallo staging, non dalla memoria — i positivi scritti a mano coprono solo l'happy path e dimenticano null, stringhe vuote e bordi di soli campi obbligatori.
Fixture negativi: per ogni vincolo dello schema, almeno un input violante. Per minimum: 0 la coppia -1 (rifiuto) e 0 (passaggio); per enum un valore fuori lista; per additionalProperties: false un campo superfluo. I negativi contano più dei positivi: uno schema che lascia passare silenziosamente dati cattivi è un bug silenzioso che inquina fino al database.
La convenzione di nomi che trasforma il nome del file in asserzione si copia dall'open source:
schemas/order.schema.json
fixtures/order/valid-minimal.json # deve passare
fixtures/order/valid-full.json # deve passare
fixtures/order/invalid-type.json # deve essere rifiutato
fixtures/order/invalid-extra-field.json # deve essere rifiutato
Il node:test integrato in Node basta:
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} dovrebbe passare`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} dovrebbe essere rifiutato`, () => { assert.ok(!validate(data)); });
}
}
Snapshot: serializza validate.errors in un file e confronta. Qualcuno modifica involontariamente un vincolo — lo snapshot arrossisce subito. In Jest/Vitest è una riga: expect(errors).toMatchSnapshot(); in CI passa sempre --ci: i nuovi snapshot falliscono invece di essere creati in silenzio, e la deriva non viene tollerata.
3. Raccogli tutti gli errori in un solo passaggio
Configurazione spesso dimenticata nel testare i negativi: per impostazione predefinita il validatore si ferma al primo errore. I dati sono rotti in tre punti, ne vedi uno, correggi, rilanci — e così via. Con allErrors: true (Ajv) o iter_errors (Python) vedi tutto in una volta:
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 riserva anche un fatto controintuitivo: l'errore che validate() riporta non è "il primo" ma quello scelto da best_match. Nella nostra misurazione, con tre violazioni (tipo errato di id, role fuori lista, campo extra superfluo), è stato riportato solo additionalProperties — l'errore di tipo di id è rimasto nascosto. In CI usa sempre iter_errors per la vista completa.
E una trappola ancora più facile: format non è un'asserzione per impostazione predefinita. La bozza 2020-12 classifica format come annotazione: il jsonschema di Python non lo valida senza un format_checker passato — nel nostro test "not-an-email" è passato liberamente. Ajv richiede di installare e registrare ajv-formats. Se un formato conta (email, data, uuid), deve esistere un fixture negativo che provi che la tua configurazione del validatore lo verifica davvero.
4. Compila una volta — non usare il compilatore come monouso
Misure locali (Node v22.13.1, Ajv 8.17, stesso schema a tripla annidamento, stessi dati violanti):
| Approccio | Tempo per 10.000 validazioni |
|---|---|
new Ajv() e compile ogni volta |
42233 ms |
Istanza a livello di modulo + compile una volta |
6.9 ms |
Un fattore 6000. Un validatore dentro l'handler della request che crea un nuovo Ajv a ogni chiamata è l'incidente di prestazioni più comune. Registrerei anche una convinzione errata diffusa: "ajv.validate(schema, data) ricompila ogni volta". Misurato: 100.000 chiamate in 8.2 ms — comparabile agli 11.2 ms della funzione compilata una volta, perché Ajv tiene una cache interna di schemi. Il vero male non è il cache miss, è la non riutilizzo dell'istanza. In Python è uguale: un Draft202012Validator riutilizzato è circa 5000 volte più veloce che chiamare validate() ogni volta (2000 iterazioni: 0.3 ms contro 1566 ms), perché quest'ultimo verifica lo schema stesso a ogni chiamata.
5. Salda i portali nella CI: quattro porte
Ordinati dal più economico al più caro: se i precedenti cadono, non serve lanciare i successivi:
| Portale | Forma del comando | Cosa aggancia | Costo |
|---|---|---|---|
| Lint dello schema | node scripts/lint-schemas.mjs |
Errori di sintassi, parole chiave deprecate | Millisecondi |
| Validazione delle fixture | La suite della sezione 2 | Disallineamento fixture/schema, vincoli cambiati per errore | Millisecondi |
| Diff delle modifiche lesive | json-schema-diff-validator contro main |
required rimosso, enum ristretto, pattern irrigidito | Secondi |
| Validazione delle risposte reali | Smoke test contro staging con asserzione .toMatchSchema() |
Deriva dell'endpoint reale | Secondi |
Configurazione minima GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # trio di fixture
- run: npx jest --ci --testPathPattern=schema # guardia anti-deriva degli snapshot
Il portale delle modifiche lesive merita un accento: strumenti come json-schema-diff-validator segnalano in rosso "campo required rimosso" e "valore perso da un enum" già in revisione — proprio le due modifiche che i normali API-diff giudicano volentieri "non lesive". Togliere un campo di risposta è innocuo per un consumatore tollerante e fatale per un consumatore con switch esaustivo.
6. Asserta l'oggetto d'errore, non solo il booleano
Un test negativo che asserisce solo validate(data) === false non basta. Per lo stesso schema, assicurati che rifiuti per la ragione giusta: un vincolo estraneo può essere passato per coincidenza. L'oggetto d'errore di Ajv ha un'asimmetria famosa: quando required fallisce, instancePath punta all'oggetto padre, e il nome del campo mancante si nasconde in params.missingProperty. Misurato con id assente:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
L'asserzione si scrive "esiste un errore con keyword=required e missingProperty=id", non un vago errors.length > 0. La decifrazione completa della semantica dei campi d'errore è in Errori JSON Schema incomprensibili: localizzare gli errori Ajv e curare i messaggi.
7. Trappole frequenti — tabella rapida
| Trappola | Conseguenza | Rimedio |
|---|---|---|
| Testare solo i 200 happy path | La deriva delle buste d'errore passa inosservata | Schema e fixture anche per le risposte d'errore |
| Schemi generati senza revisione | required dedotti da un campione rifiutano dati reali; il team spegne la validazione | Revisione umana di required/enum dopo la generazione |
| Uno schema globale per tutte le versioni | Le differenze v1/v2 si cancellano | Versionare gli schemi insieme agli endpoint |
| Payload interi nei log in caso di fallimento | PII e segreti colano nei log CI | Scrivere solo percorsi JSON Pointer e frammenti troncati |
| Enum congelati | Una rinomina nel prodotto (canceled→cancelled) rompe la validazione | Cambi di enum via revisione di compatibilità |
| Versioni del validatore non bloccate | Comportamenti divergenti tra macchine | Bloccare le versioni del validatore in CI |
| Sostituire i test di autorizzazione | Un 403 può stare nello schema d'errore e la logica di ruolo essere comunque sbagliata | Lo schema cura la struttura; l'autorizzazione si testa a parte |
| Dimenticare la semantica di format | Indirizzi spazzatura passano liberamente | Installare ajv-formats / passare format_checker, aggiungere negativi |
8. Checklist di implementazione
- File di schema nel controllo di versione,
node scripts/lint-schemas.mjsnel lint; - Per ogni schema almeno 1 fixture positivo e 1 negativo per ogni vincolo; il nome del file è l'asserzione;
- Oggetti d'errore in snapshot; in CI passare
--ciper impedire la deriva automatica; - Istanze del validatore riutilizzate a livello di modulo — mai new dentro gli handler;
- Diff delle modifiche lesive appeso alle PR; modifiche di required/enum con revisione obbligatoria;
- La prima PR prova deliberatamente che il portale sa cadere: rimuovi un required, vedi il rosso, ripristina.
La questione della severità della validazione (quando chiudere additionalProperties) è un altro tema — vedi additionalProperties lasciato aperto: la validazione dello schema non serve a niente. Questo articolo parla di mantenere le regole scritte costantemente affidabili. Per le basi, parti da JSON Schema dalle basi all'ingegneria.
FAQ
Che rapporto c'è tra validazione dello schema e test contrattuali alla Pact?
È divisione del lavoro. La validazione dello schema risponde a "questo documento è strutturalmente corretto" — un'asserzione unidirezionale. Il test contrattuale guidato dal consumatore (Pact) risponde a "il provider soddisfa ancora i bisogni specifici del consumatore X" e richiede una verifica bidirezionale. I piccoli team partono con i portali dello schema e adottano Pact quando il numero di servizi cresce.
Quanti test negativi bastano?
Un negativo per vincolo è il pavimento: tante fixture negative quanti sono i vincoli (required, type, minimum, enum, pattern, additionalProperties…), più le coppie di valori limite rifiutati/accettati. Meno negativi che vincoli significa che qualche vincolo è rimasto senza sorveglianza.
Quale modifica dello schema conta come "lesiva"?
Giudica dal punto di vista del consumatore: rimuovere un campo di risposta, rimuovere o restringere un enum, irrigidire un pattern, aggiungere un campo obbligatorio alla request — tutto questo lesive; aggiungere un campo opzionale di risposta è di solito sicuro — a patto che il decoder del consumatore tolleri chiavi sconosciute. Automatizza il confronto con json-schema-diff-validator al momento della PR e decidi le zone grigie a mano.
Testare contro la vera API o i mock?
Entrambi, a strati: i test delle fixture non toccano la rete, girano in millisecondi e partono a ogni PR; la validazione reale colpisce lo staging, richiede secondi, appartiene al pipeline di smoke e salta via skipif senza credenziali. I team che in CI girano solo la vera API si logorano con rete instabile e limiti di tariffa e alla fine spengono i test.