1. Ruta negativa en el contrato REST
Si las pruebas de ruta feliz se parecen a comprobar «la puerta se abre, el tirador funciona», entonces las pruebas de ruta negativa son la comprobación de «y si la llave no es la correcta, la puerta no debe abrirse y además debe explicar educadamente por qué». En una API REST, los errores son una parte pública del contrato: al cliente le importan no solo los datos, sino también una forma de fallo predecible.
Para no probar los errores de forma caótica, es útil tener en mente un mapa simplificado de dónde exactamente en Spring MVC puede ocurrir un fallo. Esto ayuda a no confundir validation con malformed JSON y a no escribir pruebas «todo en un montón».
A continuación se muestra un esquema del pipeline de una solicitud a nivel «suficiente para Junior», donde marcamos los puntos de fallo:
flowchart TD A[MockMvc request] --> B[Handler mapping<br/>selección del método del controlador] B --> C[Binding path/query<br/>+ conversión de tipos] C -->|error de conversión| E1[400 INVALID_INPUT<br/>ProblemDetail] C --> D[HttpMessageConverter<br/>Jackson lee el body] D -->|JSON malformado| E2[400 MALFORMED_JSON<br/>ProblemDetail] D --> F["Bean Validation<br/>@Valid / @Validated"] F -->|fallo de validación| E3[400 INVALID_INPUT<br/>ProblemDetail + fieldErrors] F --> G[Método del controlador] G --> H[Llamada al servicio] H -->|excepción de dominio| E4[404 / 409 ...<br/>ProblemDetail + code] H --> I[Respuesta exitosa]
Observe un detalle importante: muchos errores ocurren antes de que el servicio sea siquiera invocado. Esto es un regalo para las pruebas: podemos comprobar no solo «qué respuesta devolvió», sino también «que el servicio no fue tocado en absoluto». Una prueba así detecta de inmediato una clase de errores del tipo «una solicitud no válida igualmente fue a la lógica de negocio y está haciendo cosas raras allí».
Las respuestas exitosas ya las hemos fijado: estado, encabezados, content type y JSON shape. Pero el contrato está medio ciego si los fallos no se describen con la misma rigurosidad. Ahora miremos los mismos endpoints desde el otro lado: dónde la solicitud puede fallar y cómo debería verse desde fuera.
2. Qué comprobar en ProblemDetail
ProblemDetail es un «esqueleto» de error estándar y cómodo: tiene campos que Spring sabe serializar a application/problem+json, y se lleva bien con @ControllerAdvice. Pero los estudiantes a menudo sienten la tentación: «Oh, ya que el error está en JSON, comprobemos todo el JSON al completo, incluido detail palabra por palabra». Esto suele llevar a pruebas que se rompen con cualquier cambio cosmético.
Por lo tanto, acordemos: en las pruebas negativas fijamos promesas estables del contrato, no la calidad literaria del texto del error. Normalmente son estables el estado, el tipo de contenido, el code específico de la aplicación, y para validación también la estructura de fieldErrors. En cambio, detail, title, timestamp y los mensajes localizados pueden cambiar, y eso es normal.
Es útil tener en mente una mini‑tabla como esta (no es una ley de la naturaleza, sino un compromiso práctico):
| Campo en la respuesta ProblemDetail | Qué tan estable | Qué comprobar normalmente en la prueba | Por qué así |
|---|---|---|---|
| status | alto | status().isBadRequest() / isNotFound() / isConflict() | es la semántica HTTP principal |
| Content-Type | alto | contentTypeCompatibleWith(APPLICATION_PROBLEM_JSON) | el contrato del error debe ser explícito |
| code | alto | jsonPath("$.code").value("...") | es un ancla legible por máquina para el cliente |
| instance | medio | a veces comprobar que apunta a un path concreto | útil, pero depende de su implementación |
| fieldErrors | alto (para validación) | presencia del array, nombre del campo | el cliente necesita errores dirigidos |
| detail / title | bajo‑medio | mejor no comprobar textualmente | cambia por redacción e i18n |
| timestamp | bajo | normalmente no comprobar | campo demasiado «vivo» |
De esto se derivan dos reglas prácticas. Primero, en la prueba casi siempre afirmamos status y code. Segundo, si probamos la validación, comprobamos que fieldErrors contiene el field correcto (por ejemplo, title), pero no intentamos fijar con clavos el texto exacto del mensaje.
3. Fallo de validación: 400 y INVALID_INPUT
Los errores de validación son el escenario negativo más común en una API CRUD normal. Basta con que el cliente envíe un title vacío una vez para que usted entienda: «ajá, la vida real ha comenzado». Nuestro objetivo en la prueba es demostrar que una solicitud no válida se convierte en un ProblemDetail correcto, que el error señala un campo concreto y que la capa de servicio no se invoca.
Empecemos con un esqueleto mínimo de prueba. Aquí es importante que probamos el corte MVC alrededor del controlador, y el servicio se sustituye mediante @MockitoBean.
import com.example.tasktracker.domain.service.TaskService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(TaskController.class)
class TaskControllerNegativeWebMvcTest {
@Autowired MockMvc mockMvc; // a través de MockMvc ejecutamos peticiones HTTP a la capa MVC
@Autowired ObjectMapper objectMapper; // será útil si necesitamos serializar/deserializar JSON en las pruebas
@MockitoBean TaskService taskService; // el servicio se sustituye por un mock: a menudo es importante demostrar que NO fue invocado
}
Ahora la prueba de validación propiamente dicha. Enviamos un JSON que viola las restricciones (por ejemplo, title vacío o solo con espacios) y comprobamos 400, application/problem+json, code=INVALID_INPUT y un error de campo en title.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createTask_withBlankTitle_returnsInvalidInput() throws Exception {
String body = """
{"title":" ","description":"text"}
"""; // title consiste en espacios → esperamos error de validación
mockMvc.perform(post("/api/v1/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.code").value("INVALID_INPUT"))
.andExpect(jsonPath("$.fieldErrors[0].field").value("title"));
verifyNoInteractions(taskService); // importante: con input no válido la petición no debe llegar al servicio
}
Observe verifyNoInteractions(taskService). No es una «manía de Mockito», sino una comprobación de sentido: si la entrada no es válida, el servicio no debe ni siquiera enterarse de que alguien intentó crear una tarea. De lo contrario, corremos el riesgo de obtener efectos secundarios (registros, métricas, intento de escritura, NPE accidentales) a partir de una entrada basura.
Un detalle frecuente: el orden de fieldErrors[0] puede no estar garantizado. Si no quiere depender del orden, puede comprobar «en la lista hay un elemento con field=title». En jsonPath esto se hace un poco más complicado, por lo que a nivel educativo es aceptable comprobar el primer elemento, pero es importante entender que en un producto real es mejor escribir aserciones más robustas.
4. Errores de parámetros query/path
Cuando los estudiantes piensan en errores de entrada, casi siempre imaginan solo @RequestBody. Pero una API real se rompe más a menudo no por el body, sino por los parámetros query: page=-1, size=100000, status=INPROGRESS en lugar de IN_PROGRESS, sort=hackMe,desc. Estos errores aparecen en la etapa de binding/conversión de tipos, y si no se prueban, el cliente recibe respuestas inesperadas y usted, informes de error inesperados.
Mostramos dos ejemplos breves. El primero: enumerado no válido en query. Supongamos que GET /api/v1/tasks acepta status y el cliente envía status=WRONG. En un mundo ideal, esto devuelve 400 y nuestro INVALID_INPUT.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void listTasks_withInvalidStatusEnum_returnsInvalidInput() throws Exception {
// Error de conversión: la cadena "WRONG" no se mapea al enumerado → esperamos 400 con code único
mockMvc.perform(get("/api/v1/tasks?status=WRONG"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_INPUT"));
verifyNoInteractions(taskService); // la petición no debe llegar al servicio
}
El segundo ejemplo: paginación no válida. Aquí el escenario más frecuente es «tipo válido, pero valor no válido», por ejemplo size=101 con un máximo de 100. Dependiendo de cómo valide TaskSearchCriteria, puede ser un error de Bean Validation (con fieldErrors) o una rama separada. Pero el sentido de la prueba es el mismo: 400, INVALID_INPUT, y no tocar el servicio.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void listTasks_withTooLargeSize_returnsInvalidInput() throws Exception {
// El tipo de parámetro es correcto (número), pero el valor excede las restricciones de negocio
mockMvc.perform(get("/api/v1/tasks?size=999"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_INPUT"));
verifyNoInteractions(taskService); // la restricción debe actuar antes de la lógica de negocio
}
Aquí deliberadamente no comprobamos fieldErrors, porque en su proyecto concreto puede ser tanto fieldErrors como simplemente un detail general. Lo importante es otra cosa: la forma del error y el código deben permanecer unificados; de lo contrario, el cliente tendrá que escribir un «zoológico de analizadores» para diferentes tipos de errores.
5. JSON malformado: MALFORMED_JSON
El JSON malformado es el caso en que el cliente no ha enviado «valores incorrectos», sino «sintaxis incorrecta». Por ejemplo, olvidó cerrar una comilla o un corchete. Es importante probar esto por separado, porque a menudo se confunden dos cosas distintas: Bean Validation comprueba el objeto ya parseado, mientras que el JSON malformado falla antes, en la etapa de HttpMessageConverter y Jackson. Si no distingue estos escenarios, fácilmente tendrá una prueba «universal» que accidentalmente comprueba algo que no debe.
La prueba se ve casi como la de validación, pero el JSON está deliberadamente roto. Esperamos 400 y el código MALFORMED_JSON. Y de nuevo: el servicio no debe ser invocado.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createTask_withMalformedJson_returnsMalformedJsonProblem() throws Exception {
String body = """
{"title":"Broken JSON"
"""; // falta la llave de cierre }
mockMvc.perform(post("/api/v1/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.code").value("MALFORMED_JSON"));
verifyNoInteractions(taskService); // el parseo falló → no se debe tocar el servicio
}
Aquí es importante mantener un code separado MALFORMED_JSON. El análisis sintáctico falló antes de que apareciera un objeto para Bean Validation, por lo que no conviene mezclar este escenario con INVALID_INPUT: para el cliente es una clase de error diferente y un motivo diferente para corregir la solicitud.
Un escenario similar es «cuerpo faltante», cuando el cliente envió Content-Type: application/json pero el cuerpo está vacío. Dependiendo de la configuración de MVC y la firma del controlador, esto a menudo también se convierte en HttpMessageNotReadableException. Se puede probar de la misma manera, simplemente enviando una cadena vacía en lugar de JSON.
6. No encontrado: 404 y TASK_NOT_FOUND
Después de la validación y el JSON malformado, llega otra categoría de errores: «la entrada es correcta, pero el recurso no se encuentra». Esto no es 400, porque el cliente no violó el formato de la solicitud; simplemente pidió un recurso que no existe. Aquí ya no probamos el pipeline hasta el servicio, sino el vínculo «el servicio lanzó una excepción de dominio → @ControllerAdvice la tradujo a un ProblemDetail unificado».
Primero configuramos el stubbing: el servicio, al solicitar un id concreto, lanza TaskNotFoundException. Esto es importante: no probamos la lógica de búsqueda en memoria, probamos que la capa del controlador traduce correctamente la situación a un contrato público.
import static org.mockito.Mockito.when;
String taskId = "missing-task";
when(taskService.getTaskById(taskId))
.thenThrow(new TaskNotFoundException(taskId)); // simulamos la situación de dominio "recurso no encontrado"
Ahora la prueba en sí: hacemos GET /api/v1/tasks/{taskId} y comprobamos 404 y TASK_NOT_FOUND. Si su ProblemDetail establece instance como la URI de la solicitud, es muy útil comprobarlo: al cliente y a los registros les resulta más fácil asociar errores con llamadas concretas.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void getTaskDetails_whenMissing_returnsTaskNotFound() throws Exception {
mockMvc.perform(get("/api/v1/tasks/{taskId}", "missing-task"))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"))
.andExpect(jsonPath("$.instance").value("/api/v1/tasks/missing-task")); // útil si instance = path de la solicitud
}
Observe que no comprobamos detail textualmente. Puede cambiar («Task not found», «Tarea no encontrada», «Task with id ... not found») y eso no debería romper la prueba. El código y el estado son su «hormigón».
7. Conflicto: 409 y INVALID_STATUS_TRANSITION
Los errores de conflicto son el tipo más desagradable para un principiante, porque se parecen a la validación, pero en esencia son completamente diferentes. Validación es «la entrada está rota», mientras que conflicto es «la entrada es normal, pero la operación está prohibida por las reglas del dominio». En nuestro Task Tracker, esto puede ser una transición de estado no permitida o un intento de modificar una tarea archivada. Y es un candidato ideal para 409 Conflict con un code claro.
El escenario de la prueba es similar al de no encontrado: el servicio lanza una excepción de dominio, @ControllerAdvice la traduce a ProblemDetail. Supongamos que PATCH intenta mover una tarea directamente a ARCHIVED, el servicio lanza InvalidStatusTransitionException, y esperamos 409 y INVALID_STATUS_TRANSITION.
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
String taskId = "task-1";
when(taskService.patchTask(eq(taskId), any()))
.thenThrow(new InvalidStatusTransitionException("TODO", "ARCHIVED")); // conflicto: transición prohibida por reglas de dominio
Para no ahogarnos en detalles del DTO del patch (ahora mismo no estamos probando el mapeo PATCH como tema), podemos enviar un JSON mínimo y centrarnos en el estado y el código.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void patchTask_withInvalidTransition_returnsConflict() throws Exception {
String body = "{\"status\":\"ARCHIVED\"}";
mockMvc.perform(patch("/api/v1/tasks/{taskId}", "task-1")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}
Si desea comprobar también status=409 dentro del JSON, es admisible, pero normalmente es redundante: el estado ya lo ha comprobado a nivel HTTP. Las pruebas se vuelven más cortas cuando no duplica lo mismo en varias aserciones.
8. Pruebas de ruta negativa cortas
Las pruebas de ruta negativa se convierten fácilmente en una «cadena de veinte .andExpect(...)», especialmente cuando se comprueba ProblemDetail y errores de campo. Por un lado, se quiere reducir la repetición. Por otro lado, si esconde la URI, el método y el cuerpo de la solicitud en helpers, la prueba deja de leerse como un escenario HTTP. Aquí es útil una disciplina simple: los helpers deben eliminar el ruido, pero dejar visible el contrato.
Por ejemplo, podemos hacer un mini‑helper que envíe una solicitud JSON y devuelva ResultActions. No oculta ni la URL, ni el método, ni el cuerpo; simplemente elimina la rutina de contentType.
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
private ResultActions postJson(String url, String body) throws Exception {
// El helper elimina solo el "ruido" (contentType), pero deja visibles la URL y el body en la prueba
return mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON) // Content-Type único para solicitudes JSON
.content(body));
}
Entonces la prueba de validación se vuelve más compacta, pero sigue siendo legible:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createTask_withBlankTitle_returnsInvalidInput_short() throws Exception {
postJson("/api/v1/tasks", "{\"title\":\" \"}")
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_INPUT"));
}
Cuando escribe pruebas, ayuda mucho añadir temporalmente .andDo(print()) para ver la respuesta real. Pero es mejor no convertir esto en una «decoración» permanente de cada prueba: la impresión es útil en la etapa de desarrollo, pero luego solo añade ruido en los registros del CI.
9. Errores típicos en pruebas de ruta negativa con @WebMvcTest
Error n.º 1: mezclar validación y JSON malformado en un mismo escenario.
Ambos casos dan 400, pero las razones son diferentes: en un caso los datos no pasaron la validación, en el otro ni siquiera se parsearon. Si las pruebas no distinguen estos casos, pierde precisión diagnóstica y no detecta regresiones en el manejo de excepciones.
Error n.º 2: comprobar detail como una cadena fija.
detail es un mensaje legible por humanos, no un contrato estable. Puede cambiar durante una refactorización o localización. Para pruebas robustas, es mejor basarse en code y fieldErrors, y no usar detail como criterio estricto.
Error n.º 3: no tener en cuenta que @WebMvcTest levanta solo una parte del contexto.
Si @ControllerAdvice no entra en el corte de prueba, obtendrá un formato de errores diferente y buscará el problema «en la lógica» cuando en realidad está en la configuración de la prueba. En tales casos, debe importar explícitamente el manejador o comprobar el área de escaneo.
Error n.º 4: hacer stub del servicio donde no debe ser invocado.
En escenarios de validación o JSON malformado, la solicitud no debe llegar al servicio en absoluto. Si escribe when(service...) en esas pruebas, no está probando lo que debe. Una comprobación más correcta es asegurarse de que el servicio no fue invocado.
Error n.º 5: intentar comprobar varios tipos de errores en una sola prueba.
Cuando una prueba cubre a la vez validación, análisis sintáctico y errores de negocio, pierde claridad y se vuelve frágil. Las pruebas de ruta negativa deben ser de un solo escenario: una prueba, un tipo de fallo. Así cada prueba se convierte en un «sensor» preciso de un problema concreto.
GO TO FULL VERSION