CodeGym /Cursos /Spring Test /Validación en pruebas de controlador

Validación en pruebas de controlador

Spring Test
Nivel 12, Lección 0
Disponible

1. Validación en el límite de la capa web

Cuando escribes una API por primera vez, es muy tentador pensar: «Bueno, si el cliente envió algo extraño, lo resolveremos en el servicio». Es natural, pero en un backend real esto rápidamente se convierte en caos: el servicio comienza a verificar cadenas vacías, null y longitudes de campos, y el controlador se convierte simplemente en un «tubo». La validación en el límite es como un torniquete en el metro: no decide a dónde vas, pero debe rechazar a una persona sin billete.

En ContentHub, la capa web es el lugar donde debemos filtrar solicitudes como «crea un artículo pero sin título» o «título de 5000 caracteres porque así lo siento». Dichas solicitudes no deben llegar a la capa de servicio en absoluto. No porque el servicio sea débil y se ofenda, sino porque el servicio tiene otro trabajo: reglas de negocio (estados, flujo de trabajo, restricciones de transición), no verificar que una cadena no esté vacía.

Hay otra razón pragmática: la validación en el DTO hace que el comportamiento de la API sea predecible. Obtienes un mecanismo unificado, errores unificados y un estilo unificado de «cómo el cliente puede entender qué hizo mal». Y esto encaja perfectamente en nuestro curso: fijamos este comportamiento con pruebas MVC, porque es parte del contrato de la API.

Imagina que eres un cliente (aplicación móvil, frontend, otro servicio). No te importa en qué nivel se verificó el campo title. Lo que te importa es que la API responda de manera estable: «400, aquí está la lista de infracciones, qué campos deben corregirse». Esa estabilidad debe ser protegida por la prueba.

2. Bean Validation en Spring MVC: dónde se rompe la solicitud

Para escribir buenas pruebas, es importante entender en qué etapa Spring logra validar los datos. Hoy no profundizaremos en «los internos de Spring MVC hasta el último filtro», pero necesitas un modelo mínimo de procesamiento de solicitudes; de lo contrario, los casos negativos se mezclan en una sopa de «simplemente 400».

Con Bean Validation la lógica es la siguiente: primero Spring intenta leer el cuerpo de la solicitud y armar el DTO (deserialización JSON → objeto Java). Si eso funciona, solo entonces se activa Bean Validation y verifica anotaciones como @NotBlank y @Size. Si el DTO viola las restricciones, ocurre un fallo de validación: la solicitud se leyó, el DTO se creó, pero el DTO es «malo».

Esta idea es útil mantenerla como un pequeño esquema. Aquí hay una «línea de vida» simplificada de la solicitud para nuestro POST /api/editor/articles:

flowchart TD
    A["HTTP request
Content-Type: application/json"] --> B["JSON -> DTO binding
(Jackson)"] B -->|éxito| C["@Valid ejecuta Bean Validation"] B -->|error de lectura| X["no es nuestro caso hoy
(cuerpo no leído)"] C -->|DTO válido| D["Método del controlador invocado"] C -->|DTO no válido| E["MethodArgumentNotValidException"] D --> F["Servicio invocado"] E --> G["Respuesta de error (ApiProblem + violations)"]

Hoy trabajamos estrictamente con la rama donde el JSON es correcto, el DTO se puede armar, pero el DTO viola las reglas. Por lo tanto, probamos tres cosas al mismo tiempo (y es importante decirlo directamente): primero, el estado (generalmente 400), segundo, el formato del error (nuestro ApiProblem), y tercero, el límite: que el servicio no fue invocado.

Si te sorprendes pensando «¿por qué es 400 y no 422?», no estás solo. En diferentes sistemas se encuentra también 422 Unprocessable Entity, pero en el marco de nuestro proyecto y la metodología elegida mantenemos una disciplina simple: las violaciones de datos de entrada son 400, porque el cliente envió una solicitud que no pasa las reglas del formulario de entrada. Lo importante no es el número en sí, sino la estabilidad del contrato y la claridad de la causa.

3. Restricciones del DTO: @NotBlank y @Size

Bean Validation no es una «marca de calidad» mágica, sino un mecanismo bastante honesto: escribes restricciones en los campos, y Spring las verifica cuando se lo pides (@Valid). Si no escribes restricciones, Spring no «adivinará» ni validará por telepatía. Por lo tanto, lo primero que hacemos en ContentHub es declarar las reglas directamente en el DTO de solicitud.

Para el escenario de creación de borrador (API de editor), tiene sentido fijar requisitos básicos: el título, summary, body y category son obligatorios; el título no debe ser infinito. En un proyecto de aprendizaje se ve aproximadamente así:

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

class CreateArticleRequest {

    // Título obligatorio: null/""/"   " — no lo aceptamos
    @NotBlank
    // Limitamos la longitud del título en la entrada para no arrastrar una «sábana» más adelante
    @Size(max = 120)
    private String title;

    // Summary también obligatorio: cadenas vacías y espacios no se consideran datos
    @NotBlank
    private String summary;
}

Sí, el DTO está «recortado» para un ejemplo breve. En el CreateArticleRequest real tendrás body, category y posiblemente otros campos. La esencia es la misma: las restricciones están junto al campo, no «en algún lugar de la prueba» ni «en algún lugar del servicio».

Una pregunta muy frecuente entre principiantes: «¿Por qué @NotBlank y no @NotNull?» Porque @NotNull prohíbe null, pero permite una cadena vacía "" y una cadena de espacios " ". Para una API, eso generalmente no es lo que deseas. @NotBlank justamente dice: «la cadena debe no ser null, y después de trim() debe tener al menos un carácter». Esto se acerca más al sentido humano de «llena el campo».

Y @Size(max=120) no es por «estética» ni «por si acaso». Es una protección contra dos problemas reales: el primero, el usuario insertó accidentalmente un texto enorme en el título y la UI no lo detectó; el segundo, alguien envía intencionalmente campos enormes (a veces no por malicia, sino porque «a ver si funciona»). Limitar la longitud en la entrada es parte de la estabilidad de la API.

Para no tener todas las anotaciones en la cabeza, es útil tener una pequeña tabla, no como chuleta para un examen, sino como referencia de «qué se suele poner en un DTO de solicitud»:

Anotación Tipo de campo Significado en lenguaje humano Caso típico en ContentHub
@NotNull cualquiera el valor es obligatorio, pero no prohíbe cadenas vacías campos técnicos donde null no está permitido
@NotBlank String la cadena es obligatoria y no puede estar vacía/ser espacios title, summary, body, category
@Size(min, max) String, colecciones limitación de longitud/tamaño longitud del título, límites de listas
@Pattern(regexp=...) String formato de cadena según expresión regular por ejemplo, si validáramos slug en la entrada

La regla principal aquí es muy «ingenieril»: en el DTO viven reglas formales del formato de entrada, no reglas de negocio del nivel «un borrador no se puede publicar directamente». Las reglas de negocio ya son del servicio y la lógica de dominio. El DTO es el «formulario que el cliente llena».

4. @Valid en el controlador: «parada de emergencia» antes de la capa de servicio

Ahora un punto importante: las restricciones en el DTO no se activan por sí solas. Las activa la validación del argumento en el método del controlador. Para el cuerpo de la solicitud se hace simplemente: agregas @Valid en el parámetro @RequestBody. Eso es la «parada de emergencia» que no deja pasar una solicitud incorrecta más allá.

Ejemplo mínimo para el endpoint del editor en ContentHub:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

class EditorArticleController {

    // El servicio aquí es condicional: en el proyecto real se inyecta mediante constructor
    private final EditorArticleService service;

    EditorArticleController(EditorArticleService service) {
        this.service = service;
    }

    @PostMapping("/api/editor/articles")
    void create(@Valid @RequestBody CreateArticleRequest request) {
        // Importante: llegamos aquí solo si el DTO se ha ensamblado correctamente y pasó Bean Validation
        service.create(request);
    }
}

Si se quita @Valid, una solicitud con title vacío podría perfectamente llegar al método del controlador, y entonces te verías obligado a escribir verificaciones manualmente o capturar un error «en algún lugar más profundo». Y ese es justamente el caso en el que los principiantes tienen «pruebas extrañas»: la prueba espera 400, pero obtiene 200 o 500 porque el DTO no se validó en absoluto, el servicio intentó procesar basura y todo siguió un escenario impredecible.

En el contexto de @WebMvcTest es importante fijar una cosa más: si la validación no pasa, entonces el controlador no debe invocar al servicio. Y esto se prueba no «a ojo», sino literalmente: el servicio en un slice MVC es un mock, y verificamos que no hubo llamadas.

Aquí es muy útil un modelo mental simple: el controlador es un «traductor» de HTTP a llamada al servicio. Si la entrada es mala, el traductor debe detenerse y decir: «lo siento, no lo aceptamos así». No debe «pasar el papel malo más adelante en la cadena».

5. Validación en @WebMvcTest: 400 y violations

Ahora lo más sabroso: cómo verificar esto con una prueba que sea a la vez estricta y no frágil. En nuestro curso ya acordamos que los errores en la API tienen un contrato estable ApiProblem (estilo ProblemDetail con campos adicionales), y para los fallos de validación es especialmente importante el bloque violations. Por lo tanto, en la prueba no nos basta con ver 400: queremos ver que el cliente recibe una «lista de problemas» comprensible.

Imaginemos que nuestra API devuelve un error con una forma similar a esta:

{
  "title": "Validation failed",
  "status": 400,
  "detail": "Request validation failed",
  "errorCode": "VALIDATION_FAILED",
  "violations": [
    { "field": "title", "message": "must not be blank" }
  ]
}

No estás obligado a saber hoy exactamente cómo se ensambla esto internamente en @ControllerAdvice; hablaremos de eso por separado. Pero una prueba a nivel del límite web tiene derecho a esperar que el contrato sea exactamente ese: estado + lista de infracciones.

Prueba básica: título vacío → 400 + violations

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class EditorArticleControllerWebMvcTest {

    private MockMvc mvc;

    @Test
    void shouldReturn400WhenTitleIsBlank() throws Exception {
        // Importante: JSON correcto (se parsea), pero el DTO viola restricciones (title blank)
        String body = """
            {"title":" ","summary":"S","body":"B","category":"JAVA"}
            """;

        mvc.perform(
                post("/api/editor/articles")
                    // Especificamos explícitamente JSON para que la prueba verifique la validación, no la «adivinanza» de convertidores
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(body)
            )
            // Contrato: para fallos de validación esperamos 400
            .andExpect(status().isBadRequest())
            // Y como mínimo: en la respuesta debe estar presente el bloque violations
            .andExpect(jsonPath("$.violations[0]").exists());
    }
}

Observa el detalle: enviamos un JSON correcto, simplemente title son espacios. Es un fallo de validación puro. Especificamos explícitamente Content-Type: application/json para que la prueba no dependa de «lo que Spring decida por defecto». Y verificamos no solo el estado, sino también la presencia de violations.

Si tu contrato es más estricto (por ejemplo, violations siempre es un array de objetos {field, message}), puedes reforzar la verificación:

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void shouldIncludeTitleViolation() throws Exception {
    // Aquí title ya es una cadena vacía — también debe activar @NotBlank
    String body = """
        {"title":"","summary":"S","body":"B","category":"JAVA"}
        """;

    mvc.perform(
            post("/api/editor/articles")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body)
        )
        .andExpect(status().isBadRequest())
        // Verificamos el significado: la infracción se refiere al campo title
        .andExpect(jsonPath("$.violations[0].field").value("title"));
}

Aquí ya fijamos el significado: se violó exactamente el campo title. Esto es útil porque el código del cliente a menudo resalta campos específicos del formulario.

Prueba «parada de emergencia»: el servicio no se invoca

En un slice MVC, el servicio normalmente se reemplaza por un mock. Entonces la prueba puede y debe demostrar que la solicitud no pasó más allá.

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void shouldStopBeforeServiceWhenRequestIsInvalid() throws Exception {
    // El DTO no será válido debido a title=""
    String body = """
        {"title":"","summary":"S","body":"B","category":"JAVA"}
        """;

    mvc.perform(
            post("/api/editor/articles")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body)
        )
        .andExpect(status().isBadRequest());

    // Verificación clave del límite: el servicio no debe ser invocado en absoluto
    then(service).shouldHaveNoInteractions();
}

Esta prueba es una de las «más valiosas» en todo el tema de validación. Te protege de situaciones en las que alguien accidentalmente quitó @Valid, cambió la firma del controlador o reconfiguró algo, y de repente las solicitudes incorrectas comienzan a llegar al servicio. No es solo «estética», es un seguro real contra regresiones.

Prueba parametrizada para una regla (sin copiar y pegar)

Cuando la regla es la misma y hay varias variantes de entrada, es conveniente usar pruebas parametrizadas. Por ejemplo, @NotBlank debe verificarse con "" y " " — y es suficiente, no hace falta hacer una olimpiada de cantidad de espacios.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ParameterizedTest
@ValueSource(strings = {"", " "})
void shouldRejectBlankTitle(String title) throws Exception {
    // Construimos el cuerpo de la solicitud de manera que solo cambie title (verificamos una idea)
    String body = """
        {"title":"%s","summary":"S","body":"B","category":"JAVA"}
        """.formatted(title);

    mvc.perform(
            post("/api/editor/articles")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body)
        )
        .andExpect(status().isBadRequest());
}

Esta prueba sigue siendo legible porque verifica una idea: «title no debe estar en blanco». Y no se convierte en una combinación que verifica simultáneamente title, summary y category.

Sobre los «mensajes de error»: por qué es mejor no verificarlos textualmente. Es muy tentador en la prueba verificar el mensaje "must not be blank" o "Title is required". A veces es apropiado, pero más a menudo hace que la prueba sea frágil. Los mensajes pueden cambiar debido a la localización, a la configuración del proveedor, a que cambiaste el texto en el DTO sin cambiar el significado del contrato.

Es más confiable fijar cosas más estructurales: el estado, la presencia de violations, el nombre del campo, y que el mensaje existe (no necesariamente el texto exacto). Si realmente se quiere, se puede verificar la clave del error o tu propio errorCode, en lugar de la frase humana.

6. Validación en cascada: @Valid en DTO anidados

Mientras el DTO sea plano (todos los campos en un nivel), todo es bastante simple. Pero tan pronto como empiezas a hacer la solicitud «de forma humana», aparecen objetos anidados. Por ejemplo, decides agrupar el texto del artículo en payload y los metadatos por separado. Y aquí está el error clásico, muy fácil de pisar: @Valid en el parámetro del controlador no activa automáticamente la validación del objeto anidado. Para la validación en cascada se necesita @Valid directamente en el campo del objeto anidado.

Ejemplo — simplificado pero real:

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;

class WrapperRequest {

    // Punto clave: sin @Valid el objeto anidado NO será validado en cascada
    @Valid
    private NestedRequest payload;
}

class NestedRequest {

    // Esta restricción se activará solo si payload está marcado con @Valid en WrapperRequest
    @NotBlank
    private String value;
}

Si se quita @Valid del campo payload, entonces value dentro de NestedRequest puede estar vacío — y Bean Validation no lo notará. El DTO se considerará válido, aunque dentro tenga basura. Ese es el famoso «error silencioso» que luego estalla en algún lugar del servicio.

¿Cómo probar esto en el límite web? Exactamente igual que los casos planos: enviar un JSON correcto pero violar la regla dentro del objeto anidado, y esperar 400.

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void shouldReturn400WhenNestedValueIsBlank() throws Exception {
    // JSON correcto, pero payload.value viola @NotBlank
    String body = """
        {"payload":{"value":" "}}
        """;

    mvc.perform(
            post("/api/editor/articles/wrapper")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body)
        )
        .andExpect(status().isBadRequest());
}

Sí, es un «endpoint demo» para el ejemplo. En el ContentHub real encontrarás anidamiento similar cuando el DTO crezca: por ejemplo, aparecerá un objeto anidado para configuraciones, una subestructura para «contenido», una lista de adjuntos, etc. Y entonces la presencia de @Valid en el campo del objeto anidado deja de ser un «detalle de anotaciones» y se convierte en la pregunta: «¿funciona realmente nuestra protección de entrada?»

Aquí es útil recordar una analogía simple. @Valid en el parámetro del controlador es como una petición: «verifica este objeto». Y @Valid en el campo es como una instrucción: «y dentro de él también verifica todo lo que esté marcado con restricciones». Sin el segundo, Spring dirá honestamente: «verifiqué el objeto superior, no es null, así que todo está bien». Y formalmente tendrá razón.

7. Errores típicos en pruebas MVC de validación

Error nº1: esperar validación sin @Valid y luego culpar a las pruebas de «comportamiento extraño».
Este es el escenario más común: las restricciones en el DTO están, pero @Valid en @RequestBody se olvidó, y la solicitud pasa tranquilamente al controlador. La prueba o bien obtiene 200 o falla más adelante en la cadena. Se cura simplemente: en el controlador, @Valid no es un adorno, sino un interruptor del mecanismo.

Error nº2: verificar solo el estado 400 y no fijar el contrato del error.
El estado por sí solo demuestra poco: 400 se puede obtener por otras razones. Si no verificas al menos la presencia de violations, no proteges el contrato del cliente. Una prueba negativa correcta mira tanto el estado como la parte clave del cuerpo de la respuesta que el cliente realmente usará.

Error nº3: verificar el texto del mensaje de infracción textualmente y luego sorprenderse de «flakiness» sin motivo.
Los mensajes de restricciones pueden diferir entre entornos, pueden localizarse, pueden cambiar con una pequeña modificación. Si necesitas estabilidad, fija el campo (title, summary) y el hecho de que el mensaje existe, no la frase concreta. Los mensajes textuales son casi siempre una verificación demasiado frágil.

Error nº4: olvidar Content-Type: application/json y probar accidentalmente algo diferente de lo que crees.
En las pruebas MVC, los «detalles de HTTP» importan mucho. Sin Content-Type, Spring puede elegir otro convertidor, interpretar el cuerpo de otra manera, fallar de otra forma. Si estás probando la validación de un DTO de solicitud, especifica application/json explícitamente para que la prueba verifique la validación, no un «formato de solicitud desconocido» accidental.

Error nº5: no verificar el límite «el servicio no se invoca».
Incluso si la API devuelve 400, es útil demostrar que la solicitud no llegó al servicio. Esto protege contra regresiones donde alguien quitó la validación y comenzó a lanzar excepciones manualmente dentro del servicio (o peor aún, a procesar parcialmente una solicitud no válida). then(service).shouldHaveNoInteractions() — una línea pequeña que a menudo ahorra horas de depuración.

1
Tarea
Spring Test, nivel 12, lección 0
Bloqueada
Validación del título obligatorio de la nota
Validación del título obligatorio de la nota
1
Tarea
Spring Test, nivel 12, lección 0
Bloqueada
Validación en cascada de bloque SEO anidado
Validación en cascada de bloque SEO anidado
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION