Inicio / Artículos

JSON válido, contrato roto: verificaciones en capas para las regresiones del payload

Aprenda a detectar regresiones en los payloads JSON que se parsean correctamente: diferencias semánticas, un JSON Schema enfocado, afirmaciones de reglas de negocio en Node y los límites de los tipos generados.

Editor: REACTAPP.TOP7 min de lectura1572 palabras

Fuentes: Catch JSON Contract Regressions Before They Reach Your App

Una respuesta puede llegar con HTTP 200, parsearse sin ningún error y, aun así, dañar tu aplicación. La validación sintáctica solo demuestra que el texto es JSON; no indica si la carga útil sigue coincidiendo con lo que espera tu código. Esta guía crea una pequeña verificación de regresión en capas (comparación semántica, esquema específico y reglas de negocio explícitas) para que las infracciones del contrato se manifiesten como mensajes precisos en lugar de errores extraños más adelante en el flujo.

Una carga útil que se parsea pero sigue fallando

Considere un servicio de pedidos. El endpoint responde con éxito, el JSON se parsea, y luego el código que suma las cantidades de los artículos produce "021" en lugar de 3. Este escenario es ficticio, pero el modo de fallo es común: en una carga pesada, es fácil pasar por alto la cadena "2" y el número 2; ambos son válidos en JSON, pero solo uno cumple con los términos del contrato.

La estructura a continuación representa el pedido recibido. Observe atentamente el primer artículo: su quantity es una cadena entre comillas, mientras que el segundo artículo utiliza un número simple. Todo lo demás son datos de pedido plausibles, incluyendo una instrucción de envío null y un totalCents precalculado.

{
  "orderId": "ORD-1042",
  "createdAt": "2026-09-21T09:30:00Z",
  "customer": {"id": "C-017", "name": "Avery Example", "vip": false},
  "items": [
    {"sku": "MUG-BLUE", "quantity": "2", "unitPriceCents": 1250},
    {"sku": "NOTE-A5", "quantity": 1, "unitPriceCents": 650}
  ],
  "shipping": {"city": "Exampleton", "instructions": null},
  "paid": true,
  "totalCents": 3150
}

Una verificación estricta de sintaxis acepta este documento sin problemas, y ese es un resultado legítimo: el texto cumple con la gramática JSON establecida en RFC 8259, donde las cadenas de texto, los números, los valores booleanos y null son todos valores válidos. Lo que la gramática no puede determinar son las reglas que su aplicación aplica encima de ella:

  • quantity debe ser un número entero mayor que cero
  • paid debe ser un valor booleano
  • la suma de cada cantidad multiplicada por su precio unitario debe ser igual a totalCents

Supongamos que la versión anterior de este pedido difería en exactamente dos aspectos: quantity era el número 2 y paid era false. Que la bandera de pago cambie a true representa un cambio de estado esperado. En cambio, que la cantidad pase de ser un número a una cadena de texto constituye una regresión.

Valores de diferencias por ruta, no por líneas de texto

Existen dos tipos de preguntas muy diferentes que se pueden hacer al comparar cargas útiles. Una comparación semántica analiza ambos documentos y compara los valores en cada ruta. Considera irrelevante el orden de las claves de los objetos, por lo que intercambiar la posición de customer.id y customer.name no se considera un cambio, mientras que mantiene importante el orden de los arrays, de modo que mover un elemento a otro índice sí puede considerarse un cambio. En este caso, la diferencia semántica indica exactamente dos rutas: $.items[0].quantity y $.paid. Eso responde a la pregunta “¿qué datos han cambiado?”

Una diferencia de texto responde a otra pregunta: “¿qué caracteres o líneas han cambiado?”. Sigue siendo una evidencia útil, y normalizar el sangrado y el formato en ambos lados elimina gran parte del ruido. Pero que los textos coincidan o difieran no es prueba de que dos documentos representen o no el mismo valor JSON, ya que el espacio en blanco, el orden de las claves y el formato numérico pueden variar sin cambiar el significado.

Reproducir las verificaciones en Node puro

No necesita ningún servicio externo para verificar estos hechos. El script a continuación lee order.json desde su propio directorio (a través de import.meta.url), reconstruye la versión anterior con structuredClone y verifica las diferencias utilizando node:assert/strict. Luego demuestra el error, corrige el archivo de prueba en una copia y comprueba la regla de negocio de que el total calculado coincida con totalCents. Guárdelo como check-order.mjs junto al archivo de prueba y ejecute node check-order.mjs.

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const incoming = JSON.parse(
  readFileSync(new URL('./order.json', import.meta.url), 'utf8')
);
const before = structuredClone(incoming);
before.items[0].quantity = 2;
before.paid = false;
assert.equal(before.items[0].quantity, 2);
assert.equal(incoming.items[0].quantity, '2');
assert.equal(before.paid, false);
assert.equal(incoming.paid, true);
const naiveCount = incoming.items.reduce(
  (sum, item) => sum + item.quantity,
  0
);
assert.equal(naiveCount, '021');
const corrected = structuredClone(incoming);
corrected.items[0].quantity = 2;
const count = corrected.items.reduce((sum, item) => sum + item.quantity, 0);
const computedTotal = corrected.items.reduce(
  (sum, item) => sum + item.quantity * item.unitPriceCents,
  0
);
assert.equal(count, 3);
assert.equal(computedTotal, corrected.totalCents);
console.log('Contract checks passed');

La aserción naiveCount es la interesante. La reducción comienza con el número 0, agrega la cadena "2" y obtiene la cadena "02", ya que el operador + pasa a ser de concatenación siempre que alguno de los operandos sea una cadena. El paso siguiente agrega 1, dando como resultado "021". Un valor numérico inicial no fuerza la conversión de los operandos posteriores; una vez que entra una cadena en el acumulador, todo lo que sigue se concatena.

Tenga en cuenta lo que la guía no recomienda. Modificar el valor fijo para que sea numérico es una forma de expresar el contrato correcto, no una indicación para envolver cada campo recibido en Number(). La coerción silenciosa oculta errores en el generador de datos. O bien se corrige el servicio que emite la cadena, o bien se define una regla de normalización explícita y documentada en el punto donde los datos ingresan a su sistema.

Convierte la regla de tipo en un esquema

Las reglas de prosa son fáciles de olvidar; las ejecutables, en cambio, no. El esquema Draft 2020-12 que se muestra a continuación codifica las expectativas relevantes para esta regresión. Requiere orderId, items, paid y totalCents, exige al menos un elemento e impone que quantity sea un número entero con un mínimo de 1, y que unitPriceCents sea un número entero no negativo.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["orderId", "items", "paid", "totalCents"],
  "properties": {
    "orderId": {"type": "string"},
    "items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["sku", "quantity", "unitPriceCents"],
        "properties": {
          "sku": {"type": "string"},
          "quantity": {"type": "integer", "minimum": 1},
          "unitPriceCents": {"type": "integer", "minimum": 0}
        }
      }
    },
    "paid": {"type": "boolean"},
    "totalCents": {"type": "integer", "minimum": 0}
  }
}

La validación del dato de entrada contra este esquema falla con un mensaje similar a $.items[0].quantity: tipo esperado: entero. Al cambiar ese único valor por el número 2, la validación tiene éxito. Ese camino específico es exactamente lo que se busca en una comprobación de regresión.

Sé qué cubre realmente tu validador

Los validadores difieren en la medida en que implementan las especificaciones. Algunos omiten deliberadamente la resolución de destinos remotos $ref, la evaluación de expresiones regulares pattern o la verificación de palabras clave format; por defecto, la especificación trata a format como una anotación y no como una afirmación. Asegúrese de que el validador que utilice en las pruebas cubra las mismas palabras clave de las que depende su contrato en producción, de lo contrario un esquema puede parecer más estricto que las verificaciones que lo aplican.

Casos límite que merecen una prueba cada uno

Un buen contrato rechaza las entradas inválidas por razones distintas. Cada una de ellas debería fallar por sí sola:

  • Falta total de items (violación de required)
  • items como un array vacío (minItems)
  • Cantidad de cero (minimum)
  • paid se envía como la cadena "true" (type)
  • Igualmente importante es qué debe transmitirse: un pedido sin pagar con paid: false. La palabra clave required solo verifica que la propiedad esté presente, no que su valor sea verdadero. Las guías oficiales de JSON Schema explican cómo required se relaciona con las propiedades de un objeto y cómo cada elemento del array se verifica en relación con el subschema items.

    Lo que el esquema omite intencionalmente

    Se trata de un esquema específico, no de una especificación completa del pedido. Los detalles del cliente, el envío, la semántica de las marcas de tiempo y los campos adicionales no están restringidos. La regla aritmética también está intencionadamente fuera del alcance: si los elementos de la línea suman totalCents es una lógica de aplicación que el JSON Schema estándar no puede expresar, por lo que la script de Node la verifica por separado. Al combinar ambos enfoques, cada herramienta hace lo que mejor sabe hacer.

    Los tipos generados describen un único ejemplo

    Es tentador pegar un payload en un generador de JSON a TypeScript y tratar la salida como el contrato. Al ejecutarse con los datos corregidos, dicho generador produce valores como quantity: number, createdAt: string y instructions: null dentro de los tipos anidados. Estos son observaciones precisas de este único ejemplo y nada más:

    • JSON cuenta con un único tipo numérico, por lo que number no puede indicar “entero positivo”.
    • Un solo ejemplo no permite determinar qué campos son opcionales.
    • Un campo que hoy es null podría legítimamente contener una cadena de texto mañana.
    • Los tipos de TypeScript desaparecen en tiempo de ejecución y nunca inspeccionan los bytes recibidos; la guía de TypeScript sobre afirmaciones de tipo indica claramente que las afirmaciones no realizan ninguna verificación en tiempo de ejecución.

    Si desea una única definición que sirva tanto para la validación en tiempo de ejecución como para los tipos estáticos, consulte nuestro artículo sobre compartir un mismo esquema Zod entre el frontend y el backend.

    La precisión también debe aplicarse en los límites

    Los números plantean otra trampa. JavaScript interpreta {"id":9007199254740993} como 9007199254740992, ya que el valor supera Number.MAX_SAFE_INTEGER y se redondea al doble más cercano que pueda representarse. Si un identificador debe conservar cada dígito, el contrato del creador debería definirlo como cadena de texto. Intentar corregirlo después de JSON.parse es demasiado tarde; los dígitos ya han desaparecido.

    Puntos clave

    • Mantenga una versión saneada de un payload conocido y funcional, y nunca utilice credenciales reales ni datos personales en los datos de prueba.
    • Trate las verificaciones como capas: sintaxis estricta, diferencia en valores semánticos, validación de esquema, y luego reglas de negocio que el esquema no puede expresar.
    • Genere tipos en TypeScript únicamente después de haber definido el contrato, y nunca los confunda con validaciones en tiempo de ejecución.
  • Busque errores que indiquen el camino y la causa, como "$.items[0].quantity pasó de ser un número entero a una cadena, por lo que la validación falla y la operación de suma se realiza mediante concatenación", en lugar de "el orden parece incorrecto">.