Probar la validación y los errores

Entradas malas, campos ausentes, tipos equivocados y cómo es un buen error.

La lección anterior envió datos buenos y comprobó que se guardaban. Esta envía datos malos y comprueba que se rechazan. En la mayoría de los proyectos, aquí es donde están los bugs. Los desarrolladores escriben primero el camino feliz, lo prueban a mano mientras lo construyen y luego se quedan sin tiempo antes de pensar en qué otra cosa podría enviar un cliente.

Un test de validación tiene dos partes. El servidor tiene que rechazar la petición con el estado correcto, y no debe guardar nada. Después importa la calidad del mensaje de error, porque en algún momento otro desarrollador lo leerá intentando averiguar qué hizo mal.

Un formulario con reglas

POST /api/validation/registerinicia sesión para verlo es un endpoint de registro con reglas escritas. La página de referencia las enumera: el nombre de usuario tiene de 3 a 20 letras, dígitos o guiones bajos, la edad es un entero de 18 a 120, el país es uno de nueve códigos, etcétera. No se guarda nada, así que puedes enviar el mismo cuerpo tantas veces como quieras. Una petición válida:

POST/api/validation/register
{
  "username": "ava_t",
  "email": "ava@example.com",
  "password": "Str0ng!Pass",
  "confirmPassword": "Str0ng!Pass",
  "age": 29,
  "country": "us",
  "phone": "+15550101",
  "acceptTerms": true
}
Esta petición usa un endpoint para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Respuesta
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{
  "message": "User registered successfully",
  "data": {
    "id": 1001,
    "username": "ava_t",
    "email": "ava@example.com",
    "age": 29,
    "country": "US",
    "createdAt": "2026-09-30T18:32:12Z"
  }
}

Fíjate en que "us" volvió como "US". El servidor normalizó el valor en lugar de rechazarlo. Está bien, pero es un comportamiento, y un test debería fijarlo para que nadie lo cambie sin querer.

Ahora incumple casi todas las reglas a la vez:

POST/api/validation/register
{
  "username": "ab",
  "email": "ava@example",
  "password": "password",
  "confirmPassword": "Password",
  "age": 17,
  "country": "XX",
  "acceptTerms": false
}
Esta petición usa un endpoint para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Respuesta
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://example.com/problems/validation-error",
  "title": "One or more fields failed validation.",
  "status": 422,
  "detail": "7 field(s) failed validation.",
  "instance": "/api/validation/register",
  "errors": {
    "username": ["Username must be 3-20 characters long."],
    "email": ["Email must be a valid email address."],
    "password": [
      "Password must contain an uppercase letter.",
      "Password must contain a digit.",
      "Password must contain a special character."
    ],
    "confirmPassword": ["Passwords do not match."],
    "age": ["Age must be between 18 and 120."],
    "country": ["Country must be one of: US, GB, IN, DE, FR, JP, CA, AU, BR."],
    "acceptTerms": ["You must accept the terms and conditions."]
  }
}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Esta es una buena respuesta de error, y merece la pena ver por qué, para reconocer una mala más adelante.

  • Informa de todos los problemas de una vez. Un cliente que corrige el formulario no tiene que enviarlo siete veces para encontrar siete errores.
  • Cada error va indexado por el nombre de campo que envió el cliente, así que una interfaz puede mostrarlo junto al campo correcto.
  • Los mensajes enuncian la regla ("between 18 and 120") en lugar de decir que el valor está mal y dejar que el cliente adivine por qué.
  • Sigue el RFC 7807 (actualizado como RFC 9457), un formato estándar para errores HTTP con los campos type, title, status, detail e instance, y el tipo de contenido application/problem+json. No hace falta que exijas este formato, pero si una API dice usarlo, comprueba que los campos están y que el status del cuerpo coincide con el código de estado real.

Qué enviar

No vas a probarlo todo en cada campo. Recorre cada campo y pregúntate cuáles de estos podrían aplicarse:

Tipo de entradaEjemplos
AusenteOmite el campo por completo. Envíalo como null. Envía una cadena vacía. Pueden comportarse de forma distinta.
Tipo equivocado"29" para un número, 29 para una cadena, true para una cadena, un objeto donde se espera una lista
LímitesEl mínimo y el máximo exactos, uno a cada lado, cero, números negativos, un decimal donde se espera un entero
LongitudVacío, un carácter, la longitud máxima, uno más, y algo muy largo (10.000 caracteres)
FormatoEmails, fechas, teléfonos, URLs y códigos que casi están bien
Espacios y mayúsculasEspacios al principio, un título solo de espacios, Admin cuando admin ya está cogido
Campos desconocidosUn nombre de campo mal escrito, un campo de más, un campo que el cliente no debería poder fijar
El propio cuerpoSin cuerpo, JSON mal formado, un array en lugar de un objeto, el Content-Type equivocado

Límites en el endpoint de registro

La regla de edad es de 18 a 120, así que los valores interesantes son 17, 18, 120 y 121. En esta API, 18 y 120 se aceptan, y 17 y 121 reciben "Age must be between 18 and 120." Un valor de 18.5recibe otro mensaje, "Age must be an integer.", y la cadena "29" también. Algunas APIs convierten "29" en 29 sin decir nada. Ninguno de los dos comportamientos está mal en sí, pero los clientes acabarán dependiendo del que salga a producción.

El nombre de usuario tiene de 3 a 20 caracteres: abc y un nombre de 20 letras pasan, uno de 21 letras falla. Un nombre con un espacio se rechaza con un mensaje sobre los caracteres permitidos. El nombre ya cogido admin se rechaza, y Admin también, lo que te dice que la comprobación de unicidad ignora las mayúsculas. Si no lo hiciera, dos usuarios podrían acabar con nombres que se ven iguales en casi todas partes.

La comprobación del email es el lío de siempre. ava@example, ava@@example.com y ava.example.com se rechazan, y " ava@example.com", con un espacio al principio, también. ava+qa@example.com y AVA@EXAMPLE.COM se aceptan, que es lo correcto. a@b.c también se acepta. La validación real de emails es una discusión larga que nadie gana, así que el test trata menos de la regla perfecta y más de los casos que le importan a tu producto: las direcciones con signo más, las mayúsculas y si los espacios alrededor se recortan o se rechazan.

Vacío no es lo mismo que ausente

Envía {}y todos los campos obligatorios se notifican como "is required". Envía los mismos campos con valores null y obtienes el mismo resultado. Es coherente, y eso está bien. En /api/Booksinicia sesión para verlo, un título de tres espacios también se trata como ausente. Muchas APIs solo comprueban que exista una cadena, así que un título de espacios, o una cadena vacía, pasa sin problema y más tarde aparece como una fila en blanco en la tabla de alguien.

A menudo faltan del todo los límites de longitud. /api/Booksinicia sesión para verlo aceptó un título de 5.000 caracteres con un 201. No hay límite documentado, así que puede ser intencionado, pero es el tipo de cosa con la que una columna de la base de datos o el diseño de una interfaz no estarán de acuerdo más adelante. Pregunta cuál debería ser el límite y prueba un carácter a cada lado.

Campos desconocidos

Cuando un cliente envía un campo que el servidor no conoce, el servidor puede ignorarlo o rechazarlo. En la lección de CRUD, /api/Booksinicia sesión para verlo ignoró un campo year mal escrito y guardó el año 0. /api/validation/strictinicia sesión para verlo lo rechaza:

Terminal
curl -X POST https://api.testingapis.com/api/validation/strict \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget", "email": "a@b.co", "quantity": 2, "discount": 50}'

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://example.com/problems/unknown-fields",
  "title": "Request body contains unknown fields.",
  "status": 400,
  "detail": "Allowed fields: name, email, quantity (names are case-sensitive).",
  "instance": "/api/validation/strict",
  "unknownFields": ["discount"]
}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Aquí los nombres de los campos distinguen mayúsculas y minúsculas, así que Name en lugar de name también es un campo desconocido. Rechazar es más seguro, porque las erratas salen a la luz enseguida. Ignorar es más amable cuando una API evoluciona y los clientes antiguos envían campos que se eliminaron. Tu trabajo es averiguar qué enfoque usa la API y si lo usa de forma coherente en todos los endpoints.

Hay un tipo de campo desconocido que es un problema de seguridad y no una cuestión de estilo: un campo que existe en el registro pero que el cliente no debería poder fijar, como role, isAdmin, el price de un pedido o el id. Si el servidor copia en el registro todos los campos que recibe, un usuario puede ascenderse a sí mismo. La lección de seguridad lo llama asignación masiva.

Cuerpos rotos

Después, prueba la petición en su conjunto. Estas son respuestas reales del endpoint de registro:

Respuestas
# No body at all
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"":["A non-empty request body is required."]},"traceId":"00-fc01be6d..."}

# {"username":"ava_t",}  (trailing comma)
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"$":["The JSON object contains a trailing comma at the end which is not supported in this mode. Change the reader options. Path: $ | LineNumber: 0 | BytePositionInLine: 20."]},"traceId":"00-e13ac011..."}

# [] instead of an object
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"status":400,"error":"Bad Request","message":"Request body must be a JSON object."}

# Valid JSON sent as Content-Type: text/plain
HTTP/1.1 415 Unsupported Media Type
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-4cadc1e1..."}

Las cuatro se gestionan, y el 415 para el Content-Type equivocado es exactamente lo correcto. Pero mira las formas. Tres respuestas son problem+json con un mapa errors. El caso del array devuelve un formato completamente distinto, {status, error, message}, con un tipo de contenido JSON normal. Un cliente que analice los errores de una forma se romperá con la otra. Esto pasa cuando unos errores vienen del framework y otros del código de la aplicación, y es uno de los hallazgos más comunes del testing de API. Reúne las formas de error que veas en una API y ponlas una al lado de otra.

Lo que un error no debería contener

Esto es lo que pasa cuando /api/Booksinicia sesión para verlo recibe una cadena donde espera un número:

Terminal
curl -X POST https://api.testingapis.com/api/Books \
  -H "Content-Type: application/json" \
  -d '{"title": "x", "author": "y", "pages": "many"}'

HTTP/1.1 400 Bad Request

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "item": ["The item field is required."],
    "$.pages": ["The JSON value could not be converted to System.Int32. Path: $.pages | LineNumber: 0 | BytePositionInLine: 40."]
  },
  "traceId": "00-d027cc55a2925746a1bf93cc96d2306d-02234fa55d940604-00"
}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

El estado es correcto. El contenido tiene dos problemas. El mensaje menciona System.Int32, lo que le dice a un extraño que el servidor usa .NET y deja al cliente con un nombre de tipo en lugar de "pages tiene que ser un número entero". Y el error extra "The item field is required." se refiere a un nombre de parámetro interno, no a nada que enviara el cliente. Ninguno es peligroso aquí, pero en un producto real informarías de los dos.

Lo que nunca debe aparecer en un error son trazas de pila, fragmentos de SQL, rutas de archivos, nombres de hosts internos y valores de secretos. Un traceId, en cambio, es útil: permite a soporte encontrar el registro correspondiente del servidor sin exponer nada.

Qué código de estado

Los equipos discuten sobre 400 frente a 422. Un reparto habitual es 400 para un cuerpo que el servidor no pudo leer (JSON mal formado, tipos equivocados) y 422 para un cuerpo legible que incumple una regla (edad 17). /api/validation/registerinicia sesión para verlo devuelve 400 solo cuando no puede analizar el cuerpo en absoluto, y 422 para todo lo demás, incluida una cadena donde debería ir la edad. /api/validation/annotationsinicia sesión para verlo usa 400 para todo. Cualquiera de los dos enfoques es aceptable si es coherente y está documentado, y que dos endpoints de la misma API lo hagan distinto merece una pregunta.

EstadoQué significa ante una entrada mala
400 Bad RequestLa petición está mal formada o no es válida. El cliente tiene que cambiarla.
415 Unsupported Media TypeEl Content-Type no es uno de los que acepta el endpoint.
422 Unprocessable ContentEl cuerpo se pudo analizar, pero los valores incumplen una regla.
409 ConflictEs válido por sí mismo, pero choca con el estado actual, como un duplicado. (El endpoint de registro usa 422 para un nombre de usuario ya cogido.)
500 Internal Server ErrorNunca es correcto para una entrada mala. El servidor se cayó con algo que debería haber rechazado.

La última fila es la única regla sin excepciones. Si cualquier entrada que envíes produce un 500, has encontrado un bug, por rara que fuera la entrada. El servidor debía rechazarla y en su lugar se cayó. Puedes ver cómo es un 500 bien formado en /api/validation/problem/internalinicia sesión para verlo, y los demás documentos de problema de ejemplo se listan aquí:

GET/api/validation/problem
Esta petición usa un endpoint para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Ejercicio

Prueba POST /api/validation/annotationsinicia sesión para verlo. Su ejemplo válido está en la página de referencia. Usa la validación integrada del framework en lugar de reglas escritas a mano.

  • Encuentra los límites de username, age y tags.
  • Prueba tres teléfonos y webs no válidos y dos válidos.
  • Envía un cuerpo no válido a /annotations y a /register y compara las respuestas de error. ¿Qué estado usa cada uno? ¿Cómo se escriben los nombres de campo de errors comparados con los que enviaste?
  • Envía una cadena donde age espera un número. ¿Filtra algo el mensaje?

Redacta las diferencias entre los dos endpoints como si se las presentaras a un desarrollador: qué enviaste, qué volvió y qué esperabas.

Practica y ve más allá