JSON Toolkit

Tests de contrat JSON Schema : soudez vos règles de validation dans la CI

L’essentiel : Un schéma ne s'achève pas à l'écriture — c'est là que commence la dégradation. Une coquille assoupit silencieusement la validation, un refactoring casse toutes les fixtures. Ce guide présente un dispositif de test de contrat exploitable : fixtures positifs, négatifs et instantanés, lint du schéma lui-même, compilation unique réutilisée, et quatre portes de CI qui transforment la casse du contrat en build rouge.

Intermédiaire~8 min de lecturePublié le 2026-11-05Mis à jour le 2026-11-05
Schéma des tests de contrat JSON Schema et des portes de validation CI

Conclusion : un schéma est du code, et le code exige des tests. Les fixtures positifs prouvent qu'il laisse passer ce qu'il doit ; les négatifs prouvent qu'il bloque ce qu'il faut ; les instantanés surveillent les modifications involontaires. Enfin, ajv compile et les instantanés avec --ci montent la garde de nuit dans votre pipeline.

Au moment où une règle de validation casse, rien ne lève d'erreur : un élargissement du passage passe inaperçu, un durcissement du rejet est subi par toute l'équipe. Un schéma n'est pas un fichier de configuration — c'est du code que personne ne teste.

1. Le schéma est cassé — qui l'apprend en premier

Commençons par un piège réellement rencontré. Déclarez un schéma selon le draft 2020-12 et compilez-le avec le paquet Ajv par défaut : une exception part aussitôt :

Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"

Le point d'entrée par défaut d'Ajv 8 ne connaît que draft-07 ; pour 2020-12, il faut importer depuis ajv/dist/2020. Autre piège : en mode strict, écrire format: "email" sans installer ajv-formats fait aussi échouer la compilation :

Error: unknown format "email" ignored in schema at path "#/properties/email"

Voir ces erreurs localement à la première compilation, c'est bien. Le problème : beaucoup de projets repoussent la compilation à l'arrivée de la requête, et le schéma cassé attend sa première victime. Plus sournois encore, le schéma à moitié cassé : un required écrit par mégarde en chaîne au lieu d'un tableau déclenche schema is invalid: data/required must be array à la compilation — mais si cette étape n'a jamais tourné en CI, il dort en production.

Donc la première porte ne valide pas les données, elle valide le schéma lui-même. Une commande :

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}`);
}

Pourquoi pas npx ajv compile : le paquet ajv v8 ne fournit pas de CLI, npx ajv échoue avec could not determine executable to run ; la CLI vit dans le paquet séparé ajv-cli (5.0.0 dans notre mesure). Mais ajv-cli 5 ne gère que draft-07 et draft-2019-09 : appliqué au schéma 2020-12 de la section 1, il lève exactement le même no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Un projet 2020-12 doit donc linter avec le même point d'entrée Ajv2020 que l'article ; npx ajv-cli compile -s '**/*.schema.json' --strict ne convient qu'aux projets draft-07.

Faites-la tourner comme un linter : coquilles, mots-clés dépréciés et formats non enregistrés apparaissent dès la PR. C'est la première ressemblance entre le test de schéma et le test de code ordinaire : vérifier d'abord qu'il compile.

2. Le trio : positif, négatif, instantané

Le squelette du test de schéma tient en trois pièces.

Fixtures positifs : des échantillons prélevés sur de vraies réponses, censés passer. Prenez-les sur la staging, pas de mémoire — les positifs manuscrits ne couvrent que le happy path et oublient null, les chaînes vides, les frontières à champs obligatoires seuls.

Fixtures négatifs : pour chaque contrainte du schéma, au moins une entrée fautive. Pour minimum: 0, testez la paire -1 (rejet) et 0 (passage) ; pour enum, une valeur hors liste ; pour additionalProperties: false, un champ superflu. Les négatifs comptent plus que les positifs : un schéma qui laisse tranquillement passer de mauvaises données est un bug silencieux qui pollue jusqu'à la base de données.

La convention de nommage qui transforme le nom de fichier en assertion se copie chez l'open source :

schemas/order.schema.json
fixtures/order/valid-minimal.json        # doit passer
fixtures/order/valid-full.json           # doit passer
fixtures/order/invalid-type.json         # doit être rejeté
fixtures/order/invalid-extra-field.json  # doit être rejeté

Le node:test intégré à Node suffit :

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} doit passer`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
  } else {
    test(`${f} doit être rejeté`, () => { assert.ok(!validate(data)); });
  }
}

Instantanés : sérialisez validate.errors dans un fichier et comparez. Quelqu'un modifie une contrainte sans le vouloir — l'instantané rougit immédiatement. Dans Jest/Vitest, une ligne : expect(errors).toMatchSnapshot() ; en CI, passez toujours --ci : les nouveaux instantanés échouent alors au lieu d'être créés en silence, et la dérive n'est pas tolérée.

3. Rassemblez toutes les erreurs en un seul passage

Réglage souvent oublié pour tester les négatifs : par défaut, le validateur s'arrête à la première erreur. Les données sont cassées à trois endroits, vous n'en voyez qu'un, vous corrigez, relancez — et ainsi de suite. Avec allErrors: true (Ajv) ou iter_errors (Python), tout se voit d'un coup :

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 réserve aussi un fait contre-intuitif : l'erreur que rapporte validate() n'est pas « la première » mais celle que choisit best_match. Dans notre mesure, avec trois violations (type de id faux, role hors liste, champ extra superflu), seule additionalProperties a été rapportée — l'erreur de type sur id est restée cachée. En CI, utilisez toujours iter_errors pour la vue complète.

Et un piège encore plus facile : format n'est pas une assertion par défaut. Le draft 2020-12 classe format comme annotation : le jsonschema de Python ne le valide pas sans format_checker transmis — dans notre test, "not-an-email" est passé librement. Ajv exige l'installation et l'enregistrement d'ajv-formats. Si un format importe (email, date, uuid), il doit exister un fixture négatif prouvant que votre configuration de validateur le vérifie réellement.

4. Compilez une fois — n'utilisez pas le compilateur comme un consommable

Mesures locales (Node v22.13.1, Ajv 8.17, même schéma à triple imbrication, mêmes données fautives) :

Approche Temps pour 10 000 validations
new Ajv() puis compile à chaque fois 42233 ms
Instance au niveau module + compile une seule fois 6.9 ms

Un rapport de 6000. Un validateur dans le gestionnaire de requête qui crée un nouvel Ajv à chaque appel est l'accident de performance le plus fréquent. Notez aussi une idée reçue tenace : « ajv.validate(schema, data) recompile à chaque fois ». Mesuré : 100 000 appels en 8.2 ms, comparable aux 11.2 ms de la fonction compilée une fois, car Ajv garde un cache interne de schémas. Le vrai mal n'est pas l'échec de cache, c'est la non-réutilisation de l'instance. En Python c'est pareil : un Draft202012Validator réutilisé est environ 5000 fois plus rapide que d'appeler validate() à chaque fois (2000 itérations : 0.3 ms contre 1566 ms), ce dernier revérifiant le schéma lui-même à chaque appel.

5. Soudez les portes dans la CI : quatre vantaux

Du moins cher au plus cher : si les précédents tombent, inutile de lancer les suivants :

Porte Forme de commande Ce que ça attrape Coût
Lint du schéma node scripts/lint-schemas.mjs Erreurs de syntaxe, mots-clés dépréciés Millisecondes
Validation des fixtures La suite de la section 2 Désynchronisation fixtures/schéma, contraintes modifiées par erreur Millisecondes
Diff des changements cassants json-schema-diff-validator contre main Suppression de required, rétrécissement d'enum, durcissement de pattern Secondes
Validation des réponses réelles Smoke tests sur staging, assertion .toMatchSchema() Dérive du vrai endpoint Secondes

Configuration minimale GitHub Actions :

- run: npm ci
- run: npm run lint:schemas        # Ajv2020 lint
- run: npm run test:schema         # trio de fixtures
- run: npx jest --ci --testPathPattern=schema   # garde-fou d'instantanés

La porte des changements cassants mérite un accent : des outils comme json-schema-diff-validator signalent en rouge « champ required supprimé » et « valeur perdue d'un enum » dès la revue — précisément les deux changements que les API-diff ordinaires jugent volontiers « non cassants ». Retirer un champ de réponse est inoffensif pour un consommateur tolérant, et fatal pour un consommateur à switch exhaustif.

6. Assertez l'objet d'erreur, pas seulement le booléen

Un test négatif qui se contente de validate(data) === false ne suffit pas. Pour le même schéma, assurez-vous qu'il rejette pour la bonne raison : une contrainte étrangère peut être passée par coïncidence. L'objet d'erreur d'Ajv a une asymétrie célèbre : quand required échoue, instancePath pointe vers l'objet parent, et le nom du champ manquant se cache dans params.missingProperty. Mesuré pour un id absent :

{ "instancePath": "", "schemaPath": "#/required",
  "keyword": "required", "params": { "missingProperty": "id" } }

L'assertion s'écrit « il existe une erreur avec keyword=required et missingProperty=id », et non un vague errors.length > 0. Le décryptage complet de la sémantique des champs d'erreur se trouve dans Erreurs JSON Schema illisibles : localiser les erreurs Ajv et soigner les messages.

7. Pièges fréquents — tableau express

Piège Conséquence Remède
Tester seulement les 200 happy path Dérive des enveloppes d'erreur inaperçue Schéma et fixtures pour les réponses d'erreur aussi
Schémas générés sans revue Des required déduits d'un échantillon rejettent les vraies données ; l'équipe coupe la validation Revue humaine de required/enum après génération
Un schéma global pour toutes les versions Les différences v1/v2 s'effacent Versionner les schémas avec les endpoints
Payloads entiers dans les logs en cas d'échec PII et secrets fuient dans les logs CI N'écrire que les chemins JSON Pointer et des fragments tronqués
Enum figés Un renommage produit (canceled→cancelled) casse la validation Changements d'enum via une revue de compatibilité
Versions du validateur non épinglées Comportements divergents entre machines Épingler les versions dans la CI
Remplacer les tests d'autorisation Un 403 peut coller au schéma d'erreur alors que la logique de rôle est fausse Le schéma gère la structure ; l'autorisation se teste à part
Oublier la sémantique de format Les adresses poubelles passent librement Installer ajv-formats / passer format_checker, ajouter des négatifs

8. Liste de déploiement

  1. Fichiers de schéma dans le gestionnaire de versions, node scripts/lint-schemas.mjs dans le lint ;
  2. Au moins 1 fixture positif et 1 négatif par contrainte pour chaque schéma ; le nom de fichier est l'assertion ;
  3. Objets d'erreur instantanés ; en CI, passer --ci pour empêcher la dérive automatique ;
  4. Instances du validateur réutilisées au niveau module — jamais de new dans les gestionnaires ;
  5. Diff des changements cassants accroché aux PR ; modifications de required/enum soumises à revue obligatoire ;
  6. La première PR prouve délibérément que la porte sait tomber : retirez un required, voyez le rouge, remettez.

La question de la sévérité de la validation (quand fermer additionalProperties) est un autre sujet — voir additionalProperties laissé ouvert : la validation de schéma ne sert à rien. Cet article parle de garder des règles écrites constamment fiables. Pour les bases, commencez par JSON Schema des bases à l'ingénierie.

FAQ

Quel lien entre la validation de schéma et le test de contrat à la Pact ?

C'est un partage du travail. La validation de schéma répond à « ce document est-il structurellement correct » — une assertion unidirectionnelle. Le test de contrat piloté par le consommateur (Pact) répond à « le fournisseur satisfait-il encore les besoins précis du consommateur X » et exige une vérification bidirectionnelle. Les petites équipes démarrent avec les portes de schéma et adoptent Pact quand le nombre de services grandit.

Combien de tests négatifs suffisent ?

Un négatif par contrainte est le plancher : autant de fixtures négatifs que de contraintes (required, type, minimum, enum, pattern, additionalProperties…), plus les paires de valeurs limites rejetées/acceptées. Moins de négatifs que de contraintes, c'est une contrainte sans surveillance.

Qu'est-ce qu'un changement de schéma « cassant » ?

Jugez du point de vue du consommateur : supprimer un champ de réponse, supprimer ou restreindre un enum, durcir un pattern, ajouter un champ de requête obligatoire — tout cela casse ; ajouter un champ de réponse optionnel est généralement sûr — à condition que le décodeur du consommateur tolère les clés inconnues. Automatisez la comparaison avec json-schema-diff-validator au stade de la PR et arbitrez les zones grises à la main.

Tester contre la vraie API ou des mocks ?

Les deux, par couches : les tests de fixtures ne touchent pas le réseau, durent des millisecondes et tournent à chaque PR ; la validation réelle frappe la staging, dure des secondes, appartient au pipeline de smoke et saute via skipif sans identifiants. Les équipes qui ne font tourner que la vraie API en CI s'épuisent sur le réseau capricieux et les limites de débit, et finissent par couper les tests.

Références

Articles liés

Sans additionalProperties, la validation de schéma ne valide rien

Une validation réussie ne garantit pas des données propres. Pydantic ignore les champs inconnus par défaut, Zod les supprime, Ajv les laisse passer — les paramètres hallucinés et les clés mal orthographiées traversent en silence. Cet article montre les trois formes d'acceptation silencieuse et donne les recettes complètes : fermer les objets, contrôler la coercition, serrer chaque niveau d'imbrication.

Intermediate~8 min de lectureMis à jour le 2026-11-02

JSON Schema : des bases à la production

Les mots-clés sont nombreux, mais une douzaine seulement servent au quotidien. Bien maîtriser type/properties/required/$ref, bien gérer les messages d'erreur, et vous couvrez quatre-vingts pour cent des cas réels.

Intermédiaire~14 min de lectureMis à jour le 2026-10-30