JSON Toolkit

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.

Intermedio~8 min de lecturaPublicado el 2026-11-05Actualizado el 2026-11-05
Diagrama de tests de contrato con JSON Schema y gates de validación en CI

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

  1. Ficheros de esquema en control de versiones, node scripts/lint-schemas.mjs en el lint;
  2. Cada esquema con al menos 1 fixture positivo y 1 negativo por restricción; el nombre del fichero es la aserción;
  3. El objeto de error a snapshot; en CI con --ci para que el snapshot no derive solo;
  4. La instancia del validador se reutiliza a nivel de módulo — no hagas new en el handler;
  5. El diff de cambios que rompen cuelga del PR; cambios de required/enum con revisión obligatoria;
  6. 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.

Referencias

Artículos relacionados

Sin additionalProperties, la validación de esquema no valida nada

Una validación exitosa no significa datos limpios. Pydantic ignora los campos desconocidos por defecto, Zod los elimina y Ajv los deja pasar — los parámetros alucinados y las claves mal escritas atraviesan en silencio. Este artículo muestra las tres formas de aceptación silenciosa y da recetas completas: cerrar objetos, controlar la coerción y apretar cada nivel de anidación.

Intermediate~8 min de lecturaActualizado el 2026-11-02

JSON Schema: de lo básico a producción

Hay muchas palabras clave, pero solo una docena se usan a diario. Domina type/properties/required/$ref, gestiona bien los mensajes de error y cubrirás el ochenta por ciento de los casos reales.

Intermedio~14 min de lecturaActualizado el 2026-10-30