Tests de contrato con JSON Schema: soldar las reglas de validación en CI
En resumen: Un esquema no termina cuando se escribe — ahí es donde empieza a degradarse. Una errata relaja la validación en silencio, una refactorización rompe todos los fixtures. Esta guía disecciona un sistema de tests de contrato que se puede ejecutar: fixtures positivos, negativos y de snapshot, lint del propio esquema, compilación única con reutilización y cuatro gates de CI que convierten los cambios que rompen contratos en builds rojos.

Conclusión: un esquema es código, y el código necesita tests. Los fixtures positivos prueban que deja pasar lo que debe; los negativos, que rechaza lo que tiene que rechazar; los snapshots vigilan los cambios no intencionados. Y ajv compile junto con snapshots en modo --ci montan guardia nocturna en tu pipeline.
Cuando se rompen las reglas de validación, nadie lanza una excepción: el ensanchamiento de la aceptación pasa desapercibido para todos, y el endurecimiento del rechazo lo paga todo el equipo. Un esquema no es un fichero de configuración — es código que nadie testea.
1. El esquema se rompe — ¿quién se entera primero?
Empecemos por la trampa que pisamos de verdad. Declara un esquema según el draft 2020-12 y compílalo con el paquete por defecto de Ajv: la excepción salta inmediatamente:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
El punto de entrada por defecto de Ajv 8 solo conoce draft-07; para 2020-12 hay que importar desde ajv/dist/2020. Otra trampa: en modo strict, escribir format: "email" sin tener instalado ajv-formats tumba la compilación:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Ver estos errores localmente en la primera compilación está bien. El problema: muchos proyectos difieren la compilación hasta que llega una petición, y un esquema roto espera a su primera víctima. Más artero todavía es el esquema medio roto: un required mal escrito como cadena en vez de array lanza schema is invalid: data/required must be array en compilación — pero si ese paso nunca corre en CI, se queda tumbado en producción.
Así que el primer gate no valida datos: valida el propio esquema. 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}`);
}
Por qué no npx ajv compile: el paquete ajv v8 no incluye CLI, así que npx ajv falla con could not determine executable to run; la CLI vive en el paquete aparte ajv-cli (5.0.0 en nuestra medición). Pero ajv-cli 5 solo admite draft-07 y draft-2019-09: apuntado al esquema 2020-12 de la sección 1 lanza exactamente el mismo no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Así que un proyecto 2020-12 linta con el mismo Ajv2020 del artículo; npx ajv-cli compile -s '**/*.schema.json' --strict solo conviene a proyectos draft-07.
Ejecútalo como un linter: erratas, keywords obsoletas y formatos no registrados aparecen ya en la fase de PR. Esa es la primera similitud entre testear esquemas y testear código normal: primero comprueba que compila.
2. El trío: positivos, negativos, snapshot
El esqueleto de los tests de esquema tiene tres piezas.
Fixtures positivos: muestras cogidas de respuestas reales que deben pasar. Cógelas de staging, no de memoria — los positivos escritos a mano solo cubren el happy path y olvidan null, cadenas vacías y los límites de un objeto con solo los campos obligatorios.
Fixtures negativos: para cada restricción del esquema, al menos una entrada que la viole. Para minimum: 0, el par -1 (rechazado) y 0 (aprobado); para enum, un valor fuera de la lista; para additionalProperties: false, un campo de más. Los negativos pesan más que los positivos: un esquema que deja pasar silenciosamente datos malos es un bug silencioso que contamina hasta la base de datos.
Una convención de nombres que convierte el nombre del fichero en una aserción, copiable del open source:
schemas/order.schema.json
fixtures/order/valid-minimal.json # debe pasar
fixtures/order/valid-full.json # debe pasar
fixtures/order/invalid-type.json # debe rechazarse
fixtures/order/invalid-extra-field.json # debe rechazarse
El node:test incluido en Node es suficiente:
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} debería pasar`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} debería rechazarse`, () => { assert.ok(!validate(data)); });
}
}
Snapshot: serializa validate.errors a un fichero y compara. Alguien cambia una restricción sin querer — el snapshot se pone rojo al instante. En Jest/Vitest es una línea: expect(errors).toMatchSnapshot(); en CI pasa siempre --ci: un snapshot nuevo es un fallo en ese momento y no se crea silenciosamente, y la deriva no recibe indulgencia.
3. Recoge todos los errores en una pasada
Un ajuste que se olvida a menudo al testear los negativos: por defecto el validador se detiene en el primer error. Los datos están rotos en tres sitios, ves uno, lo corriges, vuelves a ejecutar — y así sucesivamente. Con allErrors: true (Ajv) o iter_errors (Python), todo se ve de golpe:
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 también guarda un hecho contra la intuición: el error que reporta validate() no es "el primero" sino la elección de best_match. En nuestra medición, con tres violaciones (tipo de id incorrecto, role fuera de la lista y campo extra sobrante), solo se reportaba additionalProperties — el error de tipo de id quedaba oculto. En CI usa siempre iter_errors para tener la foto completa.
Y la trampa aún más fácil de olvidar: format no es una aserción por defecto. El draft 2020-12 clasifica format como anotación: el jsonschema de Python no lo valida sin pasar un format_checker — en nuestro test, "not-an-email" pasaba libremente. Ajv exige instalar y registrar ajv-formats. Si el formato importa (email, fecha, uuid), debe existir un fixture negativo que pruebe que tu configuración del validador realmente lo comprueba.
4. Compila una vez — no trates el compilador como desechable
Medición local (Node v22.13.1, Ajv 8.17, el mismo esquema anidado de tres niveles, los mismos datos violadores):
| Enfoque | Tiempo para 10.000 validaciones |
|---|---|
new Ajv() + compile cada vez |
42233 ms |
Instancia a nivel de módulo + compile una vez |
6.9 ms |
Factor 6000. Un validador dentro de un request handler que crea un Ajv nuevo en cada llamada es el accidente de rendimiento más común. Anota también la respuesta errónea extendida: "ajv.validate(schema, data) recompila cada vez". Medido: 100.000 llamadas tardan 8.2 ms — comparable a los 11.2 ms de la función compilada una vez, porque Ajv guarda los esquemas en una caché interna. La enfermedad real no es el cache miss, sino la instancia que no se reutiliza. En Python pasa igual: un Draft202012Validator reutilizado es unas 5000 veces más rápido que llamar a validate() cada vez (2000 iteraciones: 0.3 ms frente a 1566 ms), porque este último comprueba el propio esquema en cada llamada.
5. Suelda los gates en CI: cuatro puertas
Ordenados de barato a caro: si el primero cae, los siguientes no hacen falta ejecutar:
| Gate | Forma del comando | Qué atrapa | Coste |
|---|---|---|---|
| Lint del esquema | node scripts/lint-schemas.mjs |
Errores de sintaxis, keywords obsoletas | Milisegundos |
| Validación de fixtures | Suite de tests de la sección 2 | Desincronización fixture/esquema, restricciones mal marcadas | Milisegundos |
| Diff de cambios que rompen | json-schema-diff-validator contra main |
required eliminado, enum estrechado, pattern endurecido | Segundos |
| Validación de respuestas reales | Tests de humo contra staging con aserción .toMatchSchema() |
Deriva de endpoints reales | Segundos |
Configuración mínima de GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # trío de fixtures
- run: npx jest --ci --testPathPattern=schema # guardián de deriva de snapshots
El gate de cambios que rompen merece subrayado aparte: herramientas como json-schema-diff-validator marcan en rojo "se eliminó un required" y "faltan valores del enum" ya en la revisión — justo los dos cambios que un diff de API normal tiende a valorar como "no rompe". Eliminar un campo de respuesta es inofensivo para un consumidor tolerante y letal para uno con un switch exhaustivo.
6. Aserciona el objeto de error, no solo un booleano
Un test negativo que solo aserta validate(data) === false se queda corto. Para el mismo esquema, comprueba que rechaza por la razón correcta: una restricción ajena puede pasar por casualidad. El objeto de error de Ajv tiene una asimetría famosa: cuando falla required, instancePath apunta al objeto padre, y el nombre del campo que falta se esconde en params.missingProperty. Medido cuando falta id:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
La aserción se escribe como "existe un error con keyword=required y missingProperty=id", no un vago errors.length > 0. El despiece completo de la semántica de los campos de error está en Errores de JSON Schema ilegibles: ubicación del error en Ajv y tratamiento de mensajes.
7. Trampas frecuentes — tabla rápida
| Trampa | Consecuencia | Remedio |
|---|---|---|
| Testear solo los 200 del happy path | La deriva de las respuestas de error pasa desapercibida | Esquemas y fixtures también para errores |
| Esquemas generados sin revisión | un required inferido de un solo ejemplo rechaza datos reales; el equipo apaga la validación | Revisión humana de required/enum tras generar |
| Un esquema global para todas las versiones | Las diferencias v1/v2 se borran | Versiona los esquemas junto a los endpoints |
| Payload completo al log al fallar | PII y secretos se filtran al log de CI | Escribe solo rutas JSON Pointer y fragmentos recortados |
| Enum congelado | Un renombrado en el producto (canceled→cancelled) tumba la validación | Cambios de enum pasan revisión de conformidad |
| Versión del validador sin fijar | Comportamiento distinto entre máquinas | Fija la versión del validador en CI |
| Sustituir los tests de autorización | Un 403 puede encajar en el esquema de error y la lógica de roles sigue mal | El esquema custodia la estructura; la autorización se testea aparte |
| Olvidar la semántica de format | Basura de email pasa libremente | Instala ajv-formats / pasa format_checker, añade negativos |
8. Checklist de implantación
- Ficheros de esquema en control de versiones,
node scripts/lint-schemas.mjsen el lint; - Cada esquema con al menos 1 fixture positivo y 1 negativo por restricción; el nombre del fichero es la aserción;
- El objeto de error a snapshot; en CI con
--cipara que el snapshot no derive solo; - La instancia del validador se reutiliza a nivel de módulo — no hagas new en el handler;
- El diff de cambios que rompen cuelga del PR; cambios de required/enum con revisión obligatoria;
- El primer PR demuestra a propósito que el gate puede caer: quita un required, mira el rojo, restáuralo.
En cuanto a la severidad de la validación (cuándo cerrar additionalProperties) es otro tema — ver additionalProperties abierto: la validación del esquema no sirve. Este artículo trata de que las reglas escritas siempre se puedan creer. Para los fundamentos, empieza por JSON Schema de cero a ingeniería.
FAQ
¿Qué relación tiene la validación de esquemas con los tests de contrato tipo Pact?
Es una división del trabajo. La validación de esquema responde "¿este documento es estructuralmente correcto?" — una aserción unidireccional. Los tests de contrato dirigidos por el consumidor (Pact) responden "¿el proveedor sigue cumpliendo las necesidades específicas del consumidor X?" y exigen verificación bidireccional. Un equipo pequeño empieza con los gates de esquema y adopta Pact cuando crece el número de servicios.
¿Cuántos tests negativos son suficientes?
Un negativo por restricción es el suelo: tantos fixtures negativos como restricciones tenga el esquema (required, type, minimum, enum, pattern, additionalProperties…), más pares de valores límite rechazado/aceptado. Menos negativos que restricciones significa que alguna restricción está sin vigilar.
¿Qué cambios de esquema cuentan como "rompen"?
El valor desde el punto de vista del consumidor: eliminar un campo de respuesta, eliminar o estrechar un enum, endurecer un pattern, añadir un campo obligatorio al request — todo eso rompe; añadir un campo opcional de respuesta suele ser seguro — con la condición de que el decodificador del consumidor tolere claves desconocidas. Automatiza la comparación con json-schema-diff-validator en el PR, y las zonas grises decídelas tú.
¿Testear contra la API real o contra mocks?
Ambos, por capas: los tests de fixtures no tocan la red, corren en milisegundos y se ejecutan en cada PR; la validación real golpea staging, cuesta segundos, pertenece al pipeline de smoke y se salta con skipif sin credenciales. El equipo que en CI solo lanza tests contra la API real acaba estrangulado por redes inestables y límites de tasa, y acaba apagando los tests.