CodeGym /Cursos /Spring Test /@ControllerAdvice y ApiProblem

@ControllerAdvice y ApiProblem

Spring Test
Nivel 12, Lección 2
Disponible

1. Contrato de error unificado como parte del API

Cuando escribes un controlador, es muy tentador pensar que el API son solo respuestas «exitosas»: listas de artículos, fichas, estado 201 Created con Location. Pero el cliente real suele sufrir (en el buen sentido) no por tus DTO, sino por tus errores. Y si los errores son caóticos, el cliente lo pasa mal: el frontend no sabe qué mostrar al usuario, la aplicación móvil no puede localizar el mensaje, y tú ves en los logs «algo se ha caído en algún lado» y empiezas a adivinar qué fue.

Un contrato de error unificado es un acuerdo: «En cualquier error te daré un JSON de forma clara. Contendrá un estado, un título legible, un detalle, un errorCode interpretable por máquina, y si el problema está en los campos, una lista de violations». Ese acuerdo hace que el sistema sea predecible. Y para las pruebas es oro: no solo puedes comprobar que el error ocurrió, sino que siempre se traduce al mismo formato de respuesta, independientemente de dónde se haya originado.

Para tener más claro «qué queremos ver en general», es cómodo fijar una referencia en forma de tabla (no como un estándar universal estricto, sino como un mapa de trabajo para ContentHub):

Origen del problema Ejemplo de excepción status esperado Ejemplo errorCode violations
Error de datos de entrada (JSON válido, pero reglas DTO violadas) MethodArgumentNotValidException 400 VALIDATION_FAILED sí
El cuerpo de la solicitud no se puede leer (JSON roto) HttpMessageNotReadableException 400 MALFORMED_JSON normalmente no
Recurso no encontrado ArticleNotFoundException 404 ARTICLE_NOT_FOUND no
Conflicto de estado del recurso InvalidStatusTransitionException 409 INVALID_STATUS_TRANSITION no
Error interno Exception 500 INTERNAL_ERROR no

El sentido de esta tabla es uno: incluso si los detalles cambian, el formato del error debe permanecer estable. Y aquí pasamos a la mecánica de Spring.

2. Pipeline de errores en Spring MVC

Para que @ControllerAdvice no parezca una «anotación mágica para chamanes», es útil ver dónde exactamente nacen los errores en MVC. El controlador no es el único punto donde algo puede salir mal. Antes del controlador están la lectura del cuerpo, la conversión de parámetros y el binding. Después del controlador está la serialización de la respuesta. Y si dentro de un servicio delegado se lanza una excepción, también debe convertirse en una respuesta HTTP normal, no en «ups, stack trace en producción».

Si simplificamos el ciclo de vida de la solicitud a un esquema didáctico, queda aproximadamente así:

flowchart TD
    A["Solicitud HTTP"] --> B["Message converters / lectura de JSON"]
    B -->|éxito| C["Binding: ensamblar DTO"]
    B -->|error| X["HttpMessageNotReadableException"]

    C --> D["Bean Validation @Valid"]
    D -->|error| Y["MethodArgumentNotValidException"]
    D -->|éxito| E["Controller method"]

    E --> F["Service"]
    F -->|business error| Z1["ArticleNotFoundException / InvalidStatusTransitionException"]
    F -->|unexpected| Z2["RuntimeException"]

    X --> G["@ControllerAdvice"]
    Y --> G
    Z1 --> G
    Z2 --> G

    G --> H["ApiProblem JSON + status"]

En este esquema es importante que: @ControllerAdvice está «al lado» de todo el pipeline. No reemplaza la validación, no arregla el JSON roto ni repara la lógica de negocio. Hace algo más pragmático: toma una excepción y la convierte en una respuesta HTTP predecible.

Y si queremos probar la capa web como frontera del API, necesitamos ser capaces de «iluminar» esa traducción de excepción a ApiProblem en las pruebas.

3. try/catch en el controlador como antipatrón

Al comienzo del proyecto la tentación es muy simple: «Pues puedo hacer un try/catch directamente en el controlador y todo irá bien». Y realmente funcionará durante un tiempo. Pero luego aparecerá un segundo controlador, un tercero, se añadirán nuevos errores, luego querrás cambiar el formato de respuesta… y te encontrarás en un mundo de copy-paste donde un lugar se olvidó de actualizar y el cliente recibe dos formatos de error diferentes según el endpoint. Es un caos silencioso y educado.

Mira el antipatrón típico. Incluso se ve «ordenado», pero es un orden con carácter de «mientras el proyecto sea pequeño»:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class EditorArticleController {

    @PostMapping("/api/editor/articles/42/submit")
    ResponseEntity
   submit() {
        try {
            // Mal: el controlador decide por sí mismo cómo mapear los errores en la respuesta HTTP.
            // Debido a esto, el formato de errores se propaga por el proyecto.
            return ResponseEntity.ok().build();
        } catch (RuntimeException ex) {
            // Mal: aquí perdemos el contrato unificado (no hay ApiProblem, no hay errorCode, no hay instance).
            return ResponseEntity.status(500).build();
        }
    }
}

El problema aquí no es el catch. El problema es que el controlador empieza a responsabilizarse de lo que no debería: del formato de errores y del mapeo de excepciones. En una buena arquitectura, el controlador es delgado: recibe HTTP, valida, delega, devuelve el resultado. La traducción de excepciones es una responsabilidad separada.

Y lo más molesto para las pruebas: tienes que pensar en cada prueba «¿este controlador captura la excepción por sí mismo o no?», y pierdes un punto único que se pueda probar una vez y confiar después.

4. @ControllerAdvice como paraguas unificado

Cuando ya has visto que los errores llegan desde diferentes lugares, surge una pregunta lógica: «¿Dónde hacer un lugar único que normalice todo esto?» En Spring MVC la respuesta es justo: @ControllerAdvice. Es un componente que se conecta a la infraestructura MVC y permite manejar excepciones de forma centralizada, agregar atributos al modelo (para páginas MVC) y en general influir en el comportamiento de la capa web. En nuestra capa API, el escenario clave es precisamente el manejo de excepciones.

Para REST API se suele usar @RestControllerAdvice. Es efectivamente @ControllerAdvice + @ResponseBody automático para el objeto devuelto. Es decir, devuelves ApiProblem y Spring lo serializa a JSON (a través de Jackson 3) como un DTO de respuesta normal.

El esqueleto mínimo se ve así:

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(Exception.class)
    void handleAny() {
        // Importante: esto es un manejador de fallback para «todo lo desconocido».
        // En código real aquí normalmente habrá ResponseEntity<ApiProblem> + logging.
    }
}

Por sí mismo, este esqueleto aún no hace nada útil, pero establece una regla arquitectónica importante: cualquier controlador puede «simplemente lanzar una excepción», y el manejador de errores la convertirá en una respuesta estable. Y para las pruebas es cómodo: en @WebMvcTest podemos mockear el servicio y hacer que lance la excepción deseada sin modelar todo el mundo.

5. @ExceptionHandler: traducción a ApiProblem

Cuando escribimos un @ExceptionHandler, nuestro objetivo no es «ocultar el error», sino traducirlo al lenguaje del cliente. Es como un intérprete en una conferencia internacional: los participantes hablan diferentes idiomas (excepciones de distintos tipos), pero el cliente siempre quiere escuchar un solo idioma (nuestro ApiProblem). Y sí, el intérprete debe ser coherente.

Empecemos con las excepciones de negocio simples. Supongamos que tenemos ArticleNotFoundException que debe convertirse en 404, e InvalidStatusTransitionException que debe convertirse en 409. Dentro del handler ensamblamos ApiProblem y devolvemos un ResponseEntity con el estado adecuado.

Para que el ejemplo sea compacto, mostraremos solo la parte semántica:

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(ArticleNotFoundException.class)
ResponseEntity<ApiProblem> handleNotFound(ArticleNotFoundException ex, HttpServletRequest request) {
    // instance conviene llenarlo con la URL de la solicitud: ayuda tanto al cliente como a los logs.
    String instance = request.getRequestURI();

    // Importante: el método fábrica oculta los detalles de llenado de ApiProblem y reduce el copy-paste.
    ApiProblem problem = ApiProblem.notFound(instance, ex.getMessage());

    // El estado HTTP debe corresponder a la semántica del error.
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}

Aquí ya vemos varias ideas útiles. Primero, instance se puede llenar desde request.getRequestURI() para que el cliente sepa qué endpoint específico dio el error (es útil tanto para debug como para logs). Segundo, la creación de ApiProblem es mejor encapsularla en un método fábrica (notFound, conflict, badRequest) — así evitas copiar y pegar los mismos campos por todo el advice.

Las excepciones técnicas también deben caer bajo este paraguas. Por ejemplo, el malformed JSON, que en Spring suele manifestarse como HttpMessageNotReadableException. Ya lo vimos en la lección anterior, pero ahora nuestra tarea es hacer que se convierta no en el «error por defecto de Spring», sino en nuestro ApiProblem:

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ApiProblem> handleMalformedJson(HttpMessageNotReadableException ex, HttpServletRequest request) {
    // Aquí es importante no devolver «las tripas» (por ejemplo, parte del stacktrace) en detail.
    // Al cliente le basta con entender: el JSON no se puede leer.
    ApiProblem problem = ApiProblem.badRequest(
        request.getRequestURI(),
        "Malformed JSON",
        "MALFORMED_JSON"
    );

    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem);
}

Y, finalmente, el validation failure (MethodArgumentNotValidException) es una clase separada de errores, donde el cliente necesita ver violations. No repetiremos toda Bean Validation (ya fue en la lección 1), pero mostraremos una parte importante: las violations se forman a partir de FieldError en BindingResult.

import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiProblem> handleValidation(MethodArgumentNotValidException ex) {
    // Convertimos los errores de validación de Spring en nuestro formato estable de violations.
    List<Violation> violations = ex.getBindingResult().getFieldErrors().stream()
        // field = nombre del campo DTO, message = explicación legible
        .map(fe -> new Violation(fe.getField(), fe.getDefaultMessage()))
        .toList();

    // Importante: el contrato «validation failed» es diferente de «malformed JSON».
    return ResponseEntity.badRequest().body(ApiProblem.validationFailed(violations));
}

Observa qué «apetitoso» se ve el contrato aquí: el cliente recibe violaciones estructuradas, no un solo texto largo «Validation failed for object…». Y eso es exactamente lo que queremos capturar con las pruebas.

6. ApiProblem: qué estabilizar con pruebas

El problema con el DTO de error es el mismo que con cualquier DTO: puedes hacerlo demasiado suelto (y entonces es incómodo para el cliente) o demasiado «de hormigón» (y entonces cualquier cambio se convierte en dolor). Aquí se necesita equilibrio. Queremos que ApiProblem sea lo suficientemente estable para que el cliente pueda apoyarse en él, pero lo suficientemente flexible para que puedas cambiar las redacciones sin romper el mundo.

La forma mínima de ApiProblem para ContentHub puede verse así (no como «la única correcta», sino como un punto de referencia claro):

import java.util.List;

public record ApiProblem(
    int status,
    String title,
    String detail,
    String instance,
    String errorCode,
    List<Violation> violations
) { }

Y violation — un par simple «campo + mensaje»:

public record Violation(String field, String message) { }

¿Qué suele valer la pena estabilizar con pruebas? El status y errorCode — casi siempre sí, porque el cliente construye su lógica sobre ellos. La presencia o ausencia de violations — también sí, porque el frontend dibujará errores de formulario. En cambio, detail a menudo es mejor verificarlo no completamente, sino parcialmente: a veces el mensaje de la excepción cambia y no quieres que las pruebas fallen por una palabra de más.

En resumen, se obtiene una regla práctica para las pruebas: fija el significado, no la literatura. Los campos clave y la estructura — sí. Los textos completos de los mensajes — solo si realmente son parte de tu contrato.

7. @WebMvcTest y conexión del advice

Con @WebMvcTest hay un detalle sutil pero importante. Este slice levanta la infraestructura MVC y tu controlador, pero no está obligado a levantar automáticamente todo tu conjunto de componentes «de producción». Y si tu @ControllerAdvice por alguna razón no está en el contexto de la prueba, empezarás a verificar el comportamiento por defecto de Spring/Boot: en algún lugar habrá una respuesta estándar, en algún lugar una página HTML de error, en algún lugar otro formato de ProblemDetail. Y mirarás eso y pensarás: «Qué raro, pero el test está verde…» — sí, está verde porque no estaba probando el contrato correcto.

Por lo tanto, en las pruebas web-slice es un buen hábito: si quieres comprobar la traducción de excepciones a ApiProblem, conecta el advice explícitamente. La opción más directa es @Import.

Ejemplo de esqueleto de prueba:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;

@WebMvcTest(EditorArticleController.class) // Levantamos solo la capa MVC y el controlador específico
@Import(ApiExceptionHandler.class)         // Conectamos explícitamente nuestro error-handling (advice)
class EditorArticleControllerErrorWebMvcTest { }

Así te dices honestamente a ti mismo (y al equipo): «En este test estoy verificando el comportamiento del controlador junto con nuestro error handling». Y eso es exactamente lo que se necesita.

8. Pruebas del mapeo de excepciones de negocio

Ahora la parte más agradable: cómo se ve en la prueba. La idea clave — no queremos modelar la lógica de negocio dentro del test del controlador. Queremos verificar el contrato web: si el servicio informa «conflicto de estado», entonces la respuesta HTTP debe ser 409 y tener errorCode = INVALID_STATUS_TRANSITION.

Lo hacemos simple: mock del servicio, given(...).willThrow(...), solicitud a través de MockMvc, verificaciones de estado y JSON.

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

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(EditorArticleController.class)
@Import(ApiExceptionHandler.class)
class EditorArticleControllerErrorWebMvcTest {

    @Autowired MockMvc mvc; // Herramienta para ejecutar solicitudes HTTP a la capa MVC en la prueba
    @org.springframework.test.context.bean.override.mockito.MockitoBean
    EditorArticleService service; // Mockeamos el servicio para controlar qué excepciones «saltan»

    @Test
    void shouldTranslateConflictToApiProblem() throws Exception {
        // Acordamos con el mock del servicio: en submit para el artículo 42 lanza una excepción de negocio
        given(service.submitForReview(42L))
            .willThrow(new InvalidStatusTransitionException("Already published"));

        // Verificamos con ojos de cliente: estado + campos clave de ApiProblem
        mvc.perform(post("/api/editor/articles/42/submit"))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.status").value(409))
            .andExpect(jsonPath("$.errorCode").value("INVALID_STATUS_TRANSITION"));
    }
}

Aquí ocurre algo muy «correcto». El test no sabe por qué la transición de estado es inválida. No repite la lógica de PublicationPolicy. Verifica la frontera HTTP: qué estado y qué payload verá el cliente. Y precisamente por eso @WebMvcTest es valioso — protege el contrato del API, no las reglas de negocio.

De la misma manera se puede verificar 404 si el servicio lanza ArticleNotFoundException. Y será un test de capa web muy honesto: la forma de la solicitud es correcta, pero el recurso no existe, y el advice debe traducirlo a un contrato unificado.

9. Errores técnicos: malformed JSON y validation

En la práctica, los clientes rompen las solicitudes de dos maneras: a veces «correctamente pero incorrecto» (JSON válido, pero title vacío), y a veces «incorrecto a nivel de sintaxis» (JSON roto). Estas dos ramas del pipeline ya las hemos separado: en un caso el DTO logra ensamblarse y validarse, en el otro no. Ahora el objetivo es diferente: ambos casos deben convertirse en ApiProblem, pero con diferentes campos semánticos.

Malformed JSON se prueba simplemente: enviamos un cuerpo deforme. El controlador ni siquiera llega a arrancar, pero el advice debe dar igualmente nuestro formato.

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

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

@Test
void shouldReturnApiProblemWhenJsonIsMalformed() throws Exception {
    // Importante: aquí el JSON está sintácticamente roto (falta cerrar la llave).
    String body = "{\"title\":\"A\",\"summary\":\"B\",\"body\":\"C\",\"category\":\"JAVA\"";

    // El controlador no se invocará — fallará la lectura del cuerpo. Pero el contrato de errores debe cumplirse.
    mvc.perform(post("/api/editor/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errorCode").value("MALFORMED_JSON"));
}

Validation failure — un caso similar en cuanto al estado (400), pero estructuralmente diferente: aquí deben estar presentes violations. No estamos obligados a verificar toda la lista en cada test, pero comprobar que violations existen y que contienen el campo necesario — es útil.

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

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

@Test
void shouldReturnViolationsWhenTitleIsBlank() throws Exception {
    // JSON es válido, pero el valor de title no pasa @NotBlank/@NotEmpty (o verificación similar).
    String body = "{\"title\":\" \",\"summary\":\"S\",\"body\":\"B\",\"category\":\"JAVA\"}";

    // Nos importa no el nombre de la excepción, sino que el cliente reciba violations y pueda resaltar el campo.
    mvc.perform(post("/api/editor/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.violations[0].field").exists());
}

Observa un detalle psicológico importante: al cliente no le interesa que sea un MethodArgumentNotValidException. Al cliente le interesa: «¿Puedo resaltar los campos según violations?». Y eso es exactamente lo que capturamos.

Matiz ingenieril: prueba el mapeo, no los detalles de las excepciones

Cuando empiezas a escribir error tests activamente, es muy fácil caer en «reverificar Spring». Por ejemplo, intentar comprobar que con malformed JSON se lanza exactamente HttpMessageNotReadableException, o que validation falla con tal clase. Normalmente eso tiene poco valor: esos detalles pertenecen al framework y pueden cambiar entre versiones (y nosotros, recuerdo, tenemos el stack moderno Spring 7 / Boot 4).

Un test mucho más sólido es: «con tal solicitud de entrada, tal ApiProblem de salida». Eso es verificar tu contrato. Y sobrevivirá tanto al cambio de versión de Jackson como a reordenamientos internos en Spring MVC (mientras el contrato siga siendo el mismo). Por supuesto, si deliberadamente te has atado a un tipo de excepción concreta en el advice — vale, lo has elegido conscientemente. Pero en los tests es mejor mirar con ojos de cliente, no con ojos de ExceptionResolver.

Y también es útil tener en cuenta otro limitador: los tests de controlador no deben convertirse en una colección de todos los errores posibles. Deben cubrir las clases clave de errores y fijar que tu advice es estable. Un formato de error unificado ya es suficiente para que el cliente pueda leer la respuesta cómodamente, pero para el contrato completo aún falta: el mismo ApiProblem debe llegar con el estado HTTP correcto.

10. Errores típicos: advice y pruebas

Error nº1: el advice está escrito, pero en @WebMvcTest no está conectado.
Los tests empiezan a verificar el formato de errores por defecto de Spring/Boot, y parece que «todo funciona». Luego ejecutas la aplicación, obtienes otro payload, y el cliente se rompe. Se cura muy simple: en los tests web-slice donde verificas el contrato de errores, conecta el advice explícitamente mediante @Import y no confíes en que «seguro se carga solo».

Error nº2: los controladores capturan excepciones manualmente, y el advice se vuelve inútil.
Si dentro del controlador hay un try/catch y devuelve una respuesta formada «de alguna manera», entonces @ControllerAdvice simplemente no verá el problema. Al final, en un endpoint los errores pasan por el advice, en otro por código manual, y el API deja de ser homogéneo. Es mejor mantener los controladores delgados y permitir que las excepciones «salgan afuera» para que el advice las traduzca.

Error nº3: todos los errores se amontonan en un solo handler con Exception.class, y se pierde el sentido de los estados y errorCode.
Sí, técnicamente se puede hacer «siempre 500» con un solo manejador. Pero entonces el cliente no distinguirá «no se encontró el artículo» de «el servidor cayó». Y lo principal — te privas a ti mismo de la posibilidad de escribir pruebas negativas precisas. Es mejor tener varios handlers para las clases principales de errores: not found, conflict, validation, unreadable body, y solo al final un fallback para unexpected.

Error nº4: en la respuesta de error se filtran detalles internos (stacktrace, nombres de clases, SQL).
Los novatos quieren enviar en detail ex.toString() — «bueno, que el cliente lo vea». En la práctica, eso no es seguro, no es elegante, y convierte tu API en un «libro abierto de las entrañas internas». En detail es mejor mantener una redacción segura y controlada, y dejar los detalles en los logs. En las pruebas se puede fijar que errorCode es correcto y que detail no contiene algo claramente superfluo.

Error nº5: las pruebas verifican el texto completo de detail y se vuelven frágiles.
Si en la prueba comparas detail en su totalidad, cualquier cambio de texto (incluso beneficioso) rompe el suite. Generalmente es más sensato verificar los campos semánticos (status, errorCode, presencia de violations) y, si es necesario, solo una parte de detail (o directamente no verificar el texto). Excepción: cuando el texto es realmente parte del contrato y el cliente lo usa tal cual.

1
Tarea
Spring Test, nivel 12, lección 2
Bloqueada
`ApiProblem` unificado para recurso ausente
`ApiProblem` unificado para recurso ausente
1
Tarea
Spring Test, nivel 12, lección 2
Bloqueada
`ApiProblem` para error de lectura JSON
`ApiProblem` para error de lectura JSON
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION