Spring Framework

Testing de aplicaciones Spring: tests unitarios, TestContext y MockMvc

Intermedio6 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

La mayor parte del código Spring debería probarse sin Spring, y el resto con un contexto cuidadosamente cacheado. Esta guía muestra cómo la inyección por constructor mantiene rápidos los tests unitarios, cómo el framework Spring TestContext carga y reutiliza contextos con JUnit 5, cómo probar el acceso a datos con rollback transaccional y @Sql, cómo sustituir beans por mocks y cómo poner a prueba los controladores con MockMvc y con MockMvcTester, basado en AssertJ, sin arrancar un servidor.

Lo que aprenderás

  • Decidir qué tests necesitan un contexto Spring y escribir el resto como tests JUnit 5 normales
  • Cargar un contexto de test con @SpringJUnitConfig, perfiles y propiedades de test, y mantenerlo en caché entre clases
  • Probar repositorios dentro de transacciones que se revierten, con datos cargados mediante @Sql
  • Sustituir colaboradores con @MockitoBean y @TestBean sin romper la caché de contextos más de lo necesario
  • Probar controladores MVC y endpoints REST con MockMvc y MockMvcTester, incluidas la validación y las respuestas de error

El mejor test de un servicio Spring es el que nunca carga Spring. Con inyección por constructor, un servicio es un objeto normal que construyes con fakes en unas pocas líneas, y esos tests se ejecutan en milisegundos. El soporte de testing de Spring está pensado para las partes que dependen de verdad del framework —cableado, transacciones, acceso a datos, mapeo de peticiones, seguridad— y su problema central es el coste: un ApplicationContext tarda segundos en construirse, así que todo el diseño del framework TestContext gira en torno a construir los menos posibles y reutilizarlos. Esta guía se centra en Spring Framework 6.x con JUnit 5 y el módulo spring-test; los test slices de Spring Boot se tratan en la materia de Spring Boot.

Nivel uno: sin contenedor

class OrderServiceTest {

    private final OrderRepository repository = mock(OrderRepository.class);
    private final Mailer mailer = mock(Mailer.class);
    private final OrderService service = new OrderService(repository, mailer);

    @Test
    void confirmSendsMailToCustomer() {
        when(repository.find(42L)).thenReturn(new Order(42L, "[email protected]"));

        service.confirm(42L);

        verify(mailer).send("[email protected]", "Order 42 confirmed");
    }
}

Aquí no hay nada específico de Spring, y de eso se trata. Si una clase no se puede probar así —porque usa inyección por campo, llama a getBean o lee un holder estático de ApplicationContext—, arregla la clase antes de recurrir a un contenedor. Lo que los tests puros no pueden verificar es el comportamiento que añade Spring: que @Transactional hace rollback, que @Cacheable devuelve el valor cacheado, que una URL se mapea al método correcto, que la regla de seguridad deniega el acceso. Para eso hace falta un contexto.

El framework TestContext

spring-test se integra con JUnit 5 mediante SpringExtension. La anotación compuesta @SpringJUnitConfig combina la extensión con @ContextConfiguration:

@SpringJUnitConfig(classes = { PersistenceConfig.class, ServiceConfig.class })
@ActiveProfiles("test")
@TestPropertySource(properties = "app.mail.enabled=false")
class CatalogServiceIntegrationTest {

    @Autowired CatalogService catalog;            // en los tests, la inyección por campo es aceptable

    @Test
    void repricesAndRecordsHistory(@Autowired PriceHistoryRepository history) {   // la inyección por parámetro también funciona
        catalog.reprice("SKU-1", Money.of(12_50));
        assertThat(history.forSku("SKU-1")).hasSize(1);
    }
}

Usa una configuración de test dedicada: una clase @Configuration en src/test/java que importe la configuración de producción y sustituya las piezas que necesitan un doble de test, como un Clock o un gateway saliente. Mantén pequeño el número de estas configuraciones, por el motivo que se explica a continuación.

La caché de contextos

El framework cachea cada contexto que construye, con una clave formada por el conjunto completo de entradas: clases o ubicaciones de configuración, perfiles activos, fuentes de propiedades de test, inicializadores de contexto, customizers (incluidas las sustituciones de beans) y el loader. Dos clases de test con entradas idénticas comparten un único contexto durante toda la ejecución de la JVM; cualquier diferencia crea otro. Una suite con cincuenta clases de test y tres configuraciones construye tres contextos; la misma suite con un @TestPropertySource distinto en cada clase construye cincuenta.

@DirtiesContext marca un contexto como inservible tras el test y obliga a reconstruirlo. A veces es necesario —un test que modifica el estado de un singleton o detiene un bean—, pero con más frecuencia es el síntoma de un test que debería restablecer lo que ha tocado. Es preferible restablecer el estado en @AfterEach. La propiedad estática spring.test.context.cache.maxSize y la línea de log Started … context te ayudan a ver cuántos contextos crea una ejecución.

Propiedades, perfiles y valores dinámicos

@ActiveProfiles selecciona los beans condicionados por perfil, @TestPropertySource añade propiedades con la máxima precedencia y @DynamicPropertySource aporta valores que solo se conocen en tiempo de ejecución, como el puerto de un contenedor arrancado por Testcontainers:

@SpringJUnitConfig(PersistenceConfig.class)
@Testcontainers
class RepositoryContractTest {

    @Container
    static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void datasource(DynamicPropertyRegistry registry) {
        registry.add("db.url", postgres::getJdbcUrl);
        registry.add("db.user", postgres::getUsername);
        registry.add("db.password", postgres::getPassword);
    }
    …
}

Probar contra el motor de base de datos real en lugar de H2 detecta diferencias de dialecto —secuencias, RETURNING, columnas JSON, sensibilidad a mayúsculas y minúsculas— que un sustituto en memoria oculta. Todos los tests de la clase comparten el contenedor porque el campo es estático.

Tests de acceso a datos

Añade @Transactional a una clase de test y cada método se ejecuta en una transacción que se revierte al terminar, de modo que los tests no pueden contaminar los datos de los demás. Carga filas con @Sql:

@SpringJUnitConfig(PersistenceConfig.class)
@Transactional
class JpaProductRepositoryTest {

    @Autowired ProductRepository products;
    @PersistenceContext EntityManager em;

    @Test
    @Sql("/sql/three-products.sql")                          // se ejecuta en la misma transacción, antes del método
    void findsBySku() {
        assertThat(products.findBySku("SKU-2")).isPresent();
    }

    @Test
    void savesNewProduct() {
        Product saved = products.save(new Product("SKU-9", "Kettle", 2_999));
        em.flush();                                          // fuerza el INSERT para que se comprueben las restricciones
        em.clear();                                          // vacía la caché de primer nivel para que la siguiente lectura vaya a SQL
        assertThat(products.findBySku("SKU-9")).map(Product::getName).contains("Kettle");
    }

    @Test
    @Commit                                                  // conserva los datos; rara vez es lo que quieres
    void seedsReferenceData() { … }
}

@Sql también acepta executionPhase = AFTER_TEST_METHOD para scripts de limpieza y @SqlConfig para separadores y gestión de errores. TestTransaction.flagForCommit() y TestTransaction.end() dan control programático cuando un test necesita observar datos confirmados; por ejemplo, para verificar un @TransactionalEventListener, que por definición nunca se dispara en un test revertido.

La línea flush() no es ceremonia opcional. Con JPA, el INSERT de save se aplaza hasta el flush; si el test solo comprueba la entidad gestionada en memoria, pasa aunque la base de datos hubiera rechazado la fila.

Sustituir beans

Spring Framework 6.2 incorporó la sustitución de beans a spring-test, así que ya no depende de Spring Boot:

@SpringJUnitConfig(ServiceConfig.class)
class CheckoutServiceTest {

    @MockitoBean PaymentGateway gateway;          // sustituye el bean PaymentGateway por un mock de Mockito
    @MockitoSpyBean OrderRepository orders;       // envuelve el bean real en un spy
    @Autowired CheckoutService checkout;

    @Test
    void declinedPaymentLeavesOrderUnpaid() {
        when(gateway.charge(any())).thenReturn(PaymentResult.declined("insufficient funds"));

        checkout.pay(7L);

        verify(orders, never()).markPaid(7L);
    }
}

@TestBean es la alternativa sin Mockito: sustituye un bean por el valor que devuelve un método factory estático de la clase de test, lo adecuado para un fake escrito a mano con comportamiento real. Los tres crean un customizer de contexto, así que una clase de test con @MockitoBean PaymentGateway obtiene un contexto cacheado distinto del de otra que no lo tenga. Declarar las sustituciones en una clase base abstracta que extiendan los tests relacionados mantiene idéntica la clave de caché entre ellos.

Mockea en los límites —pasarelas de pago, correo, APIs remotas, el reloj— y deja los beans reales en medio. Un test que mockea el repositorio bajo un servicio que, a su vez, está mockeado en el test del controlador no ha verificado nada del cableado.

Probar controladores con MockMvc

MockMvc pilota el DispatcherServlet con objetos simulados de petición y respuesta de servlet. Si lo construyes a partir de un WebApplicationContext, la petición recorre tus mapeos, conversores, @ControllerAdvice e interceptores reales:

@SpringJUnitWebConfig(WebConfig.class)             // @SpringJUnitConfig + @WebAppConfiguration
class ProductControllerTest {

    MockMvc mvc;
    @MockitoBean ProductService products;

    @BeforeEach
    void setUp(WebApplicationContext wac) {
        mvc = MockMvcBuilders.webAppContextSetup(wac).build();
    }

    @Test
    void returnsProductAsJson() throws Exception {
        when(products.find(1L)).thenReturn(Optional.of(new Product(1L, "Kettle", 2_999)));

        mvc.perform(get("/api/products/1").accept(MediaType.APPLICATION_JSON))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.name").value("Kettle"))
           .andExpect(jsonPath("$.priceCents").value(2999));
    }

    @Test
    void rejectsInvalidBodyWithProblemDetail() throws Exception {
        mvc.perform(post("/api/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"name": "", "priceCents": -5}
                        """))
           .andExpect(status().isBadRequest())
           .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
           .andExpect(jsonPath("$.errors[*].field").value(containsInAnyOrder("name", "priceCents")));
    }
}

MockMvcBuilders.standaloneSetup(new ProductController(products)) prescinde por completo del contexto y es la forma más rápida de probar el mapeo y el binding de un único controlador; registra el advice con .setControllerAdvice(new ApiExceptionHandler()). La contrapartida es que el modo standalone usa la configuración MVC por defecto, así que los conversores o argument resolvers personalizados de tu WebMvcConfigurer no están presentes a menos que los añadas a mano.

MockMvcTester

Spring Framework 6.2 añadió MockMvcTester, una fachada nativa de AssertJ sobre el mismo motor que elimina la excepción checked y la sopa de imports estáticos:

MockMvcTester tester = MockMvcTester.from(wac);

assertThat(tester.get().uri("/api/products/{id}", 1).accept(MediaType.APPLICATION_JSON))
        .hasStatusOk()
        .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
        .bodyJson().extractingPath("$.name").isEqualTo("Kettle");

assertThat(tester.get().uri("/api/products/999"))
        .hasStatus(HttpStatus.NOT_FOUND)
        .hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON);

Las excepciones que lanza el controlador se capturan como parte del resultado en lugar de propagarse, así que puedes comprobar .hasFailed() e inspeccionar la causa. Cualquiera de las dos APIs sirve; el tester es más legible para tests nuevos y requiere AssertJ en el classpath de test.

Si usas Spring Security, añade spring-security-test y aplica springSecurity() de SecurityMockMvcConfigurers al builder; @WithMockUser y with(jwt()) te permiten entonces probar las reglas de autorización sin pasar por un login.

Probar HTTP saliente

MockRestServiceServer se vincula a un RestClient.Builder (o a un RestTemplate) y reproduce respuestas predefinidas, de modo que una clase gateway se prueba contra el JSON exacto que documenta tu socio:

RestClient.Builder builder = RestClient.builder().baseUrl("https://inventory.internal/api");
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
InventoryGateway gateway = new InventoryGateway(builder.build());

server.expect(requestTo("https://inventory.internal/api/stock/SKU-1"))
      .andExpect(method(HttpMethod.GET))
      .andRespond(withSuccess("""
              {"sku": "SKU-1", "available": 3}
              """, MediaType.APPLICATION_JSON));

assertThat(gateway.stockFor("SKU-1").available()).isEqualTo(3);
server.verify();

Errores habituales

Cargar un contexto para probar lógica. Si la aserción trata sobre un cálculo o una rama, un test puro es más rápido, más claro y no se rompe cuando cambia la configuración.

Un contexto distinto por clase de test. Valores de @TestPropertySource por clase, @Import puntuales y @MockitoBean dispersos multiplican los contextos. Consolida.

Hacer aserciones sobre estado JPA sin flush. El test pasa; producción falla por la restricción. Haz flush y clear antes de volver a leer.

@DirtiesContext por costumbre. Convierte un contexto cacheado en uno reconstruido para cada clase que lo lleva. Restablece el estado en su lugar.

Mockear todo lo que hay debajo de la clase bajo prueba en un test con contexto. Has pagado por un contexto y no has verificado nada de lo que aporta. Mockea solo en los límites externos.

Probar la seguridad llamando directamente al servicio. @PreAuthorize es cosa del proxy; una llamada directa al objeto mockeado o real sin contexto de seguridad no demuestra nada. Usa @WithMockUser contra el bean con proxy.

Confiar en H2 para una aplicación PostgreSQL. Las características del dialecto, el comportamiento de las secuencias y los operadores JSON difieren. Usa el motor real en un contenedor para los tests de acceso a datos.

Ideas clave

  • Las clases con inyección por constructor se prueban como objetos normales; reserva el contexto de Spring para el cableado, las transacciones, el acceso a datos, la web y la seguridad.
  • @SpringJUnitConfig carga un contexto cacheado; cualquier diferencia de configuración, perfiles, propiedades o sustituciones crea otro, así que estandariza las entradas.
  • Los tests @Transactional se revierten automáticamente; @Sql carga datos; haz flush y clear antes de verificar escrituras JPA.
  • @MockitoBean, @MockitoSpyBean y @TestBean (6.2) sustituyen beans en los límites y forman parte de la clave de caché.
  • MockMvc y MockMvcTester recorren los mapeos, conversores y advices reales sin servidor; standaloneSetup aísla un único controlador.
  • MockRestServiceServer cubre el HTTP saliente, y @DynamicPropertySource con Testcontainers, las bases de datos reales.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué mi suite de tests es lenta si cada test es rápido?
Casi siempre por la proliferación de contextos. Cada combinación distinta de clases de configuración, perfiles activos, propiedades de test y sustituciones de beans crea y cachea un ApplicationContext aparte, y cada uno cuesta varios segundos. Limítate a un número pequeño de configuraciones de test, evita @DirtiesContext y coloca las declaraciones @MockitoBean en una clase base o una configuración compartida para que las clases compartan contexto. Spring registra en el log líneas «Started … context» que puedes contar.
¿@Transactional en un test revierte de verdad mis cambios JPA?
Sí —por defecto la transacción se revierte después de cada método de test—, pero con un matiz: Hibernate aplaza el SQL hasta el flush, y una transacción marcada para rollback puede no hacer flush nunca, así que las violaciones de restricciones y los errores de mapeo quedan ocultos. Llama a entityManager.flush() (y clear()) al final del test, o justo después de la escritura que quieras verificar, para que el SQL se ejecute de verdad.
¿Qué diferencia hay entre @MockitoBean y el @MockBean de Spring Boot?
Hacen lo mismo: sustituir o añadir un bean en el contexto de test con un mock de Mockito. @MockitoBean forma parte del módulo spring-test de Spring Framework 6.2 y funciona con cualquier test de Spring, mientras que @MockBean venía de Spring Boot y quedó obsoleto en Boot 3.4 en favor de @MockitoBean. Ambos generan una clave de caché de contexto distinta para cada combinación de beans mockeados.
¿Uso MockMvc o un servidor real con TestRestTemplate?
MockMvc ejecuta el DispatcherServlet dentro del proceso con objetos de petición simulados: sin puerto, sin parseo HTTP, rápido, y llegando al controlador a través de los handler mappings, conversores y advices reales. No puede probar filtros de servlet registrados fuera de Spring, la configuración del servidor embebido ni detalles propios de HTTP como la compresión. Usa MockMvc para el comportamiento de los controladores y unos pocos tests contra un servidor real para la superficie de despliegue.