CodeGym /Cursos /Spring Test /Binding y conversion en MVC

Binding y conversion en MVC

Spring Test
Nivel 11, Lección 3
Disponible

1. Introducción

Cuando los aspectos infraestructurales de la solicitud están claros, la siguiente buena prueba de estrés para el mismo estilo MVC es el binding y la conversion. Aquí el problema surge incluso antes: el servicio puede estar perfectamente escrito, pero la solicitud simplemente no llegará si los parámetros de cadena no se vincularon o no se convirtieron.

Cuando escribes un controlador, a menudo parece que lo más importante es la lógica de negocio en el servicio, y los parámetros de la solicitud «llegarán de alguna manera». Pero en realidad, los clientes de tu API (incluyendo el frontend, la aplicación móvil e incluso el Postman de un colega) viven en un mundo de cadenas: ?page=0&size=10&sort=publishedAt,desc. Si el binding y la conversion no funcionan como esperas, tu hermoso servicio ni siquiera será invocado: la solicitud «caerá» antes, en el límite de la capa web.

Y esta es precisamente la razón por la que en @WebMvcTest tiene sentido verificar no solo el «200 OK», sino también que los parámetros se vincularon correctamente y se convirtieron en los tipos Java esperados. Esto no es probar la lógica de negocio; es probar que la entrada HTTP realmente pasa por la puerta de tu controlador, y no se golpea la frente contra el cartel de «cerrado por razones técnicas».

Por lo tanto, aquí nos interesa una ruta estrecha: cadena HTTP → binding/conversion → invocación del servicio o rechazo ya en la entrada. No nos adentramos en la lógica de negocio, sino que verificamos que la capa web entiende correctamente la solicitud.

2. Binding en Spring MVC

El binding en el contexto de Spring MVC es el proceso mediante el cual Spring toma los datos de una solicitud HTTP y los asigna a los parámetros de tu método del controlador. No es una única «llamada mágica», sino toda una cadena: Spring lee la URL, los query params, las path variables, los encabezados, el cuerpo de la solicitud e intenta ensamblar a partir de esto los valores de los tipos que especificaste en la firma del método.

Para simplificar, imagina que el método del controlador es como una función con parámetros, y la solicitud HTTP es como un sobre con papeles. El binding es el momento en que Spring saca los papeles y los coloca cuidadosamente en las casillas correspondientes. Si en un papel está escrito «page = gatito» y esperas un int page, Spring dirá honestamente «no puedo», y tu código nunca se ejecutará.

A continuación se muestra un esquema simplificado de dónde se encuentra este lugar en el procesamiento de la solicitud:

flowchart TD
    %% Cadena simplificada de procesamiento de solicitudes en Spring MVC
    A[HTTP request] --> B[Spring MVC]
    B --> C[Binding: encontrar valores de parámetros]
    C --> D[Conversion: String -> tipo Java necesario]
    D --> E[Invocación del método del controlador]
    E --> F[El controlador invoca al servicio]
    F --> G[Se forma la respuesta HTTP]

Observa una idea importante: la conversion es parte del binding. Primero Spring encuentra los valores de cadena (por ejemplo, "10"), y luego intenta convertirlos a int, long, enum, Sort, Pageable, etc.

3. Conversion: cadenas a tipos Java

La conversion es el lugar donde el desarrollador Java suele decir: «Bueno, Spring es inteligente». Y Spring realmente lo es, pero no es telépata. Puede convertir muchas cosas de forma nativa, pero solo si expresas las expectativas explícitamente y en el formato correcto. Por lo tanto, es útil entender la conversion no a nivel de «en algún lugar interno», sino a nivel de «qué formatos prometo al cliente».

El ejemplo más simple: @RequestParam int page. En HTTP todo es cadena, así que llega "0", y Spring debe convertirlo a 0. Lo mismo con @PathVariable long id: llega "42", se convierte en 42. Para los enums, normalmente llega una cadena "DRAFT" o "IN_REVIEW", y Spring intenta encontrar el valor de enumeración correspondiente.

Pero en la práctica, en las API REST también hay parámetros «compuestos». Un ejemplo clásico de los backends habituales es sort=publishedAt,desc. En Spring MVC + Spring Data, esto puede convertirse en Sort o formar parte de Pageable. Y aquí se hace evidente que la conversion ya no es simplemente «parsear un número», sino una parte bastante importante del contrato: si cambias el formato de ordenación, los clientes se romperán, y las pruebas deben decírtelo rápidamente.

Para fijar la idea, aquí hay una pequeña tabla de conversiones típicas que se encuentran en nuestra ContentHub‑API:

De dónde se toma el valor Cómo se ve en HTTP En qué queremos convertirlo en el método del controlador Ejemplo de tipo Java
Query param ?page=0 número de página int / Integer
Query param ?size=10 tamaño de página int / Integer
Query param (compuesto) ?sort=publishedAt,desc ordenación Sort o parte de Pageable
Query param ?status=IN_REVIEW estado del artículo ArticleStatus (enum)
Path variable /api/editor/articles/42 id del artículo long / Long
Path variable /api/public/articles/spring-basics slug del artículo String

Y ahora, una conclusión práctica importante para las pruebas: si el binding/conversion está roto, tu controlador puede verse «perfecto», los servicios también, pero el endpoint dejará de estar disponible. Esa es la situación típica en la que «todo funciona» termina con las palabras «excepto la API».

4. Binding de query params

En ContentHub, el endpoint público de lista de artículos es un excelente ejercicio para el binding, porque casi siempre acepta query params. Incluso si hoy haces la lista más simple, mañana el producto pedirá paginación, filtro por categoría, ordenación y (la guinda del pastel) valores por defecto si el cliente no envió nada.

Supongamos que nuestro controlador público se ve más o menos así (al menos en cuanto a significado; los DTO exactos ya los tienes en tu proyecto de días anteriores):

import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
class PublicArticleController {

    private final ArticleQueryService articleQueryService;

    PublicArticleController(ArticleQueryService articleQueryService) {
        // El servicio se inyecta a través del constructor: esta es una dependencia estándar del controlador
        this.articleQueryService = articleQueryService;
    }

    @GetMapping("/api/public/articles")
    PageResponse<ArticleSummaryResponse> listPublished(
            // category es un query param normal, puede estar ausente
            @RequestParam(required = false) String category,
            // Pageable no lo «ensamblamos» explícitamente: Spring intentará construirlo a partir de page/size/sort
            Pageable pageable
    ) {
        // El controlador aquí no «se las ingenia»: simplemente pasa los argumentos ya convertidos
        return articleQueryService.findPublished(category, pageable);
    }
}

Aquí, para un principiante, hay dos «trampas» (buenas, didácticas): category llega como cadena (lógico), mientras que page/size/sort no los declaramos en absoluto, pero «de alguna manera» terminan en Pageable. Esto es precisamente el binding + conversion de Spring MVC en acción: Spring ve que quieres un Pageable y, a través de mecanismos integrados, intenta ensamblarlo a partir de los query params.

¿Qué es exactamente importante probar en este endpoint a nivel de capa web? No necesitamos verificar que el servicio realmente filtró correctamente los artículos por categoría. Verificamos algo más simple y más propio de la capa web: que los parámetros de la solicitud se aceptan, se convierten y se pasan al servicio en la forma correcta.

5. Pruebas de binding en @WebMvcTest

El error más típico de un principiante es empezar a verificar el binding mediante aserciones complejas sobre la respuesta JSON, como si fuera una prueba de integración. Resulta doloroso: te atas a la estructura de la respuesta, a la serialización, a los campos del DTO, y al final la prueba falla por cualquier motivo excepto por el que la escribiste.

Es mucho más tranquilo para la mente (y para el mantenimiento del suite) separar: si estamos probando el binding, la principal evidencia no será todo el JSON, sino el hecho de que el controlador invocó al servicio con los argumentos correctos. Esta es una zona de responsabilidad pura de la capa web: leer correctamente la entrada, transformarla en parámetros y pasarla hacia adelante.

Para una prueba así, normalmente nos basta con @WebMvcTest, @MockitoBean y ArgumentCaptor. Y la propia solicitud es conveniente escribirla a través de MockMvcTester, porque en este día estamos precisamente «uniendo» el estilo.

El esquema sigue siendo el mismo que en los escenarios anteriores: una solicitud HTTP explícita, una comprobación breve del estado, y el sentido principal está en verify(...) y ArgumentCaptor, porque es allí donde se ve cómo la capa web entendió la entrada.

A continuación, un ejemplo de clase de prueba. Observa: el código es intencionadamente corto, sin detalles superfluos, y se concentra en el binding, no en «todo a la vez».

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerBindingWebMvcTest {

    @Autowired
    MockMvcTester mvc; // Utilidad para solicitudes «limpias» a la capa MVC sin levantar el servidor

    @MockitoBean
    ArticleQueryService articleQueryService; // Mock del servicio, que debe recibir los argumentos ya convertidos

    @Test
    void shouldBindCategoryPageAndSize() {
        // No nos importa la respuesta en sí, sino el hecho de que la capa web haya llegado exitosamente hasta la llamada al servicio
        given(articleQueryService.findPublished(any(), any()))
                .willReturn(PageResponse.empty());

        // Enviamos query params en formato HTTP «crudo» (cadenas) y luego verificamos en qué se convirtieron
        assertThat(mvc.get()
                .uri("/api/public/articles?category=java&page=0&size=10")
                .accept(MediaType.APPLICATION_JSON))
                .hasStatusOk();

        // Capturamos Pageable para verificar el resultado del binding/conversion
        var pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
        verify(articleQueryService).findPublished(eq("java"), pageableCaptor.capture());

        // Verificamos solo las cosas clave: número de página y tamaño
        assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(0);
        assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10);
    }
}

Varios matices importantes que vale la pena mencionar explícitamente, y no «que el estudiante lo deduzca solo». Hacemos given(...).willReturn(...) no porque nos importe la respuesta, sino para que el controlador pueda completar con éxito el procesamiento y devolver 200. Luego verificamos que el servicio fue invocado con "java", y que Pageable realmente contiene page=0 y size=10. Esta es nuestra prueba de binding.

6. Binding de sort: publishedAt,desc

La ordenación es una fuente clásica de errores a nivel de «la solicitud parece correcta, pero por alguna razón no ordena como debería». Además, parte de los problemas ni siquiera están en el repositorio, sino en que el cliente envió sort en otro formato, o el controlador espera otro.

En una prueba de slice MVC, de nuevo podemos no comparar todo el JSON, sino verificar que Pageable incluye el Sort deseado. En Pageable, la ordenación vive internamente, y podemos extraerla y ver qué resultó exactamente después de la conversión.

Aquí hay un ejemplo compacto que se centra en sort:

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@Test
void shouldConvertSortParameterToPageableSort() {
    // Se asume que mvc y articleQueryService están declarados como en el ejemplo anterior

    // El stub es necesario para que el controlador llegue hasta el final y devuelva 200
    given(articleQueryService.findPublished(any(), any()))
            .willReturn(PageResponse.empty());

    // En HTTP, la ordenación llega como cadena, y es precisamente su conversión lo que queremos verificar
    assertThat(mvc.get()
            .uri("/api/public/articles?sort=publishedAt,desc")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk();

    // Capturamos Pageable para ver qué Sort interno resultó
    var pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
    verify(articleQueryService).findPublished(any(), pageableCaptor.capture());

    Sort sort = pageableCaptor.getValue().getSort();
    // Verificamos la dirección de ordenación para un campo específico; esto es suficiente para una prueba de binding
    assertThat(sort.getOrderFor("publishedAt").getDirection())
            .isEqualTo(Sort.Direction.DESC);
}

Aquí verificamos exactamente lo que necesitamos: que la cadena publishedAt,desc se convirtió en un Sort en el que el campo publishedAt tiene la dirección DESC. Este es el caso en que la prueba realmente «detecta el riesgo»: si accidentalmente cambias el parámetro en el controlador, o dejas de aceptar sort, o comienzas a construir Pageable manualmente y te equivocas, la prueba fallará.

7. Path variables y errores de conversión

Binding de path variable id

Los query params a menudo se rompen debido a formatos y valores por defecto, mientras que las path variables se rompen por razones más «tontas»: alguien cambió la plantilla de la ruta, alguien renombró la variable, alguien cambió el tipo del id, y como resultado el endpoint dejó de coincidir o de convertirse.

Verificar la path variable también se puede hacer a nivel de slice MVC de forma muy sencilla. Supongamos que el endpoint de lectura de artículos del editor se ve así: GET /api/editor/articles/{id} y el método del controlador acepta long id. Es importante para nosotros que la cadena "42" se convierta exitosamente en un número, y que luego el servicio reciba 42L.

Mini-ejemplo de prueba (sin profundizar en seguridad y roles, eso es otro plano; ahora estamos en binding):

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

import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldBindPathVariableIdToLong() {
    // Stub de la respuesta: nos importa que la capa MVC llegue exitosamente a la llamada al servicio y devuelva 200
    given(editorArticleService.getById(42L))
            .willReturn(ArticleDetailsResponse.stub());

    // En path variable siempre hay una cadena, y nosotros queremos un long; eso es lo que verificamos
    assertThat(mvc.get()
            .uri("/api/editor/articles/42")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk();

    // La afirmación principal: el servicio recibió el long ya convertido
    verify(editorArticleService).getById(42L);
}

El sentido aquí es casi «infantil», pero útil: si alguien en el controlador escribe accidentalmente @GetMapping("/api/editor/articles/{articleId}") y olvida @PathVariable("articleId") long id, o cambia el tipo a UUID mientras el cliente sigue enviando números, detectarás el problema a nivel de prueba web muy rápidamente.

Tipo de parámetro incorrecto: el servicio no se invoca

La pregunta que tarde o temprano surge en cada estudiante: «Si la conversión falla, ¿qué sucede?» Y la respuesta correcta es: la mayoría de las veces, el método del controlador simplemente no se invocará. Es decir, tu servicio @MockitoBean se quedará sin llamadas, y Spring devolverá al cliente un error a nivel de capa web.

Hoy no nos adentramos en una matriz completa de errores, payload de error y estrategia @ControllerAdvice (eso es un bloque grande aparte), pero una idea importante sobre el binding es útil consolidarla ahora mismo: un tipo de parámetro incorrecto no es un «error en la lógica de negocio», es «no se pudo leer la solicitud».

Por lo tanto, a veces es apropiada una prueba muy corta «el servicio no se invoca» para fijar el límite. Por ejemplo, si page debe ser un número y llega page=gato, la solicitud no debería llegar accidentalmente al servicio y comenzar a funcionar «de alguna manera». Debería fallar en la entrada.

Esta prueba se puede escribir de forma mínima y sin discutir el formato del error:

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

import static org.mockito.Mockito.verifyNoInteractions;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void shouldRejectRequestWhenPageIsNotNumber() {
    // Parte importante de la prueba: el parámetro page llega como una cadena «mala», y la conversión debe fallar en la entrada
    assertThat(mvc.get()
            .uri("/api/public/articles?page=gato")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatus4xxClientError();

    // Si la conversión falló, el controlador no se invoca, por lo tanto el servicio no debe participar en absoluto
    verifyNoInteractions(articleQueryService);
}

Aquí deliberadamente no afirmamos un estado específico ni verificamos el cuerpo del error, para no mezclar temas. Nos basta con fijar que el controlador no será invocado «ingenuamente» con parámetros rotos, y que el servicio no debe participar en este circo.

8. Errores típicos en las pruebas de binding MVC

Error n.º 1: intentar verificar el binding a través de la respuesta JSON completa, como si fuera una prueba de integración.
Una prueba así resulta frágil: falla tanto por cambios en el DTO, como por cambios en la serialización, como por cualquier modificación en el cuerpo de la respuesta. Al final, pierdes el foco: no está claro si la prueba falló por el binding o por todo lo demás. Para el binding, es mucho más fiable verificar los argumentos de la llamada al servicio mediante verify(...) y ArgumentCaptor.

Error n.º 2: «esconder bajo la alfombra» los detalles HTTP por estética.
A veces los estudiantes hacen un método helper que «arma la URL por sí mismo» y oculta qué query params se envían realmente. Como resultado, la prueba se lee bonito, pero no queda claro qué estamos verificando exactamente. Para las pruebas de binding, es importante que el formato de los parámetros sea visible directamente en la prueba: ?sort=publishedAt,desc, ?page=0&size=10, ?status=IN_REVIEW.

Error n.º 3: verificar demasiados detalles de Pageable y Sort (sobreespecificación).
Si en cada prueba empiezas a verificar todo el Sort hasta la última coma, el suite empieza a romperse con los cambios más pequeños e inocuos. Para una prueba de binding, normalmente basta con verificar una o dos cosas «portantes»: el número de página, el tamaño de página, la presencia de una orden específica para un campo clave.

Error n.º 4: olvidar los valores por defecto y asumir que el cliente siempre enviará todos los parámetros.
En la vida real, la mitad de los clientes envían GET /api/public/articles sin parámetros. Si tu controlador espera @RequestParam int page sin valor por defecto, entonces en una solicitud vacía puede empezar a fallar. Incluso si no pruebas la paginación por defecto completa, al menos una prueba «sin parámetros» suele compensar: fija que el endpoint está disponible y no requiere que el cliente conozca toda la configuración interna.

Error n.º 5: mezclar en una misma prueba binding, filtros, manejo de errores y lógica de negocio.
Esta «ensalada» es especialmente popular, porque se quiere «verificar todo con una sola prueba». Pero el resultado es una prueba difícil de leer, aún más difícil de mantener, y casi imposible de diagnosticar rápidamente cuando falla. Si el objetivo de la prueba es el binding, mantenla estrecha: solicitud → estado → argumentos de la llamada al servicio. Todo lo demás debe vivir en pruebas de su propia capa y con su propio objetivo.

1
Tarea
Spring Test, nivel 11, lección 3
Bloqueada
Binding category, page y size
Binding category, page y size
1
Tarea
Spring Test, nivel 11, lección 3
Bloqueada
Conversión del parámetro sort en Pageable
Conversión del parámetro sort en Pageable
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION