Testing en Spring Boot con JUnit 4\5. Mockito, MockMvc, REST Assured, bases de datos en memoria.

Última actualización: 01/04/2023
logo spring

En el tutorial «Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA» explico paso a paso cómo coanstruir una pequeña API REST para una tabla de una base de datos MySQL a la que se accede a través de Spring Data JPA.

¿Proyecto finalizado? En absoluto. Falta un elemento vital: las pruebas automáticas. Es un asunto tan importante y extenso que decidí tratarlo con el cariño que merece. Por ello, en el presente artículo mostraré cómo escribir tests de integración y end-to-end (requieren iniciar el contexto de Spring) en esa misma aplicación.

Hay quien desprecia las pruebas de integración y similares por ser más lentas y complejas que las unitarias. Si bien estos incovenientes resultan innegables, la solución no es evitarlas o reducirlas al mínimo, sino hacerlas más simples y rápidas.

Si continúas leyendo, te ayudaré con ello. Ampliarás tu caja de herramientas con utilidades prácticas y potentes como @Sql, MockMvc, AssertJ, @TestConfiguration, RestAssured…

Índice

  1. Proyecto de ejemplo
  2. ¿JUnit 4 o 5?
  3. Escribiendo la primera prueba
    1. Las dependencias
    2. La anotación @SpringBootTest
    3. Parámetros de configuración. La anotación @TestPropertySource.
    4. Base de datos MySQL
    5. La anotación @Sql
    6. El test de JUnit 5
    7. AssertJ
  4. Base de datos en memoria
  5. Probando controladores web\API REST
    1. MockMVC
    2. WebTestClient
    3. REST Assured
    4. La configuración webEnvironment de Spring Boot
  6. Spring Security
  7. Mockito fácil con @MockBean y @SpyBean
  8. Clases de configuración para pruebas con @TestConfiguration
  9. Test slices \ Selección de capas
    1. @WebMvcTest
    2. @DataJpaTest
  10. Pruebas vintage con JUnit 4
  11. ¡Cuidado con el rendimiento!
  12. Código de ejemplo

Proyecto de ejemplo

Usaremos Spring Boot 3.0 (Java 17). Señalaré cuando sea oportuno algunas diferencias relevantes con respecto a versiones previas.

No procede comentar en detalle el proyecto Maven de ejemplo puesto que se construyó paso a paso en el tutorial anterior. Dejo aquí un diagrama de clases que ofrece una visión general (clic para ampliar).

Se trata de un sistema que explota una base de datos MySQL con Spring Data JPA (entidad Country). Cuenta con la API REST (CountryRestController) que muestra la siguiente imagen.

¿JUnit 4 o 5?

Mucho ha llovido desde la primera publicación del tutorial a finales de 2018. Estaba orientado al uso de JUnit 4 y se acompañaba de las explicaciones pertinentes para usar JUnit 5, una novedad en aquel momento. Aprovecho para subrayar que en realidad no es una nueva versión de JUnit como tal, sino una plataforma sobre la que construir librerías de testeo en Java. Incluye una llamada JUnit Jupiter. En la práctica, las pruebas escritas con ella se suelen denominar tests de JUnit 5, así que seguiré esta costumbre.

En la actualidad, JUnit 5 es ampliamente utilizado. De hecho, es la plataforma de testeo predeterminada en Spring Boot desde la versión 2.2 (octubre 2019). JUnit 4 queda relegado a proyectos ya existentes en los que la migración no se considere oportuna o viable.

Así pues, en la versión actual del tutorial he decidido seguir el enfoque contrario del cual partí: el código se basa en JUnit 5, y al final daré las instrucciones para usar JUnit 4. Veremos que las pruebas implementadas con JUnit 4 pueden ejecutarse en JUnit 5. Esto permite actualizar a JUnit 5 con el fin de escribir las nuevas pruebas con Jupiter sin tener que modificar las que ya tengamos con JUnit 4.

En el blog encontrarás tutoriales sobre JUnit 4 y JUnit 5:

Escribiendo la primera prueba

Empecemos el tutorial por lo más básico. En esta sección configuramos el proyecto para crear una prueba con JUnit 5 (Jupiter) que haga uso de la interfaz CountryService.

Las dependencias

En primer lugar, añadimos el starter o iniciador spring-boot-starter-test:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Como ya indiqué, Spring Boot 2.2 incluye JUnit 5. En versiones anteriores se necesita esta dependencia:

<dependency> 
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <scope>test</scope>
</dependency> 

Jupiter precisa la versión 2.22 o superior del plugin Maven Surefire, responsable de la ejecución de las pruebas. Spring Boot ya se encarga de que nuestro proyecto cumpla con este requerimiento.

La anotación @SpringBootTest

Pasemos a crear la clase que contendrá las pruebas. A este tipo de clases las llamaré clases de prueba. La marcamos con dos anotaciones:

@ExtendWith(SpringExtension.class)
@SpringBootTest
class CountryServiceTest {

@ExtendWith pertenece a JUnit 5. Indica que las pruebas necesitan la extensión SpringExtension, responsable de la integración de las librerías de testeo de Spring con JUnit 5. Su principal función es arrancar y detener un contexto a medida de las configuraciones de las clases de prueba.

Por si no leíste el tutorial previo, quiero aclarar el vocabulario que voy a emplear. Los beans de Spring son los objetos gestionados por el contenedor de dependencias, el núcleo de los sistemas construidos con Spring. Implementa el concepto inyección de dependencias. Dado que la interfaz ApplicationContext («contexto de la aplicación») representa al contenedor, a menudo nos referimos a este último como «contexto de Spring». En realidad, este concepto abarca el contenedor, su configuración y algunos servicios disponibles para los beans, como un sistema de eventos y la posibilidad de ejecutar métodos de manera asíncrona.

Con @SpringBootTest indicamos que la clase contiene pruebas que necesitan un ApplicationContext proporcionado por Spring Boot. Y Spring Boot requiere una clase anotada con @SpringBootApplication. La busca desde el paquete en el que se ubica la clase de prueba y hacia arriba siguiendo la jerarquía de paquetes. Elegirá la primera que encuentre.

Si tenemos varias clases de tipo @SpringBootApplication y no queremos que se aplique este comportamiento, especificaremos las que usaremos con el atributo classes de @SpringBootTest. Con él también podemos incluir otras clases de configuración (las marcadas con @Configuration). Volveré sobre este asunto cuando examinemos la anotación @TestConfiguration.

A partir de Spring Boot 2.1, @SpringBootTest lleva implícita la configuración @ExtendWith(SpringExtension.class). Por ello, no volverás a ver @ExtendWith en el tutorial.

Parámetros de configuración. La anotación @TestPropertySource.

Será habitual que necesitemos parámetros de configuración específicos para las pruebas, como la conexión a la base de datos. Contamos con tres estrategias:


1-. Duplicar en el directorio /src/test/resources los ficheros de /src/main/resources y modificarlos con los parámetros que necesitemos. Esto incluye al fichero application.properties (o application.yml).

2-.Crear ficheros de propiedades nuevos que sobrescriban solo los parámetros que cambian para los tests. Importaremos estos ficheros en las clases de prueba donde sus propiedades sean requeridas con la anotación @TestPropertySource.

3-.Modificar valores concretos con el atributo properties de @SpringBootTest.

@SpringBootTest(properties = "spring.datasource.url=jdbc:mysql://localhost:3306/country-test")

Sigamos la segunda estrategia para establecer la url de la base de datos de pruebas. Creamos el fichero /src/test/resources/db-test.properties con el siguiente contenido:

spring.datasource.url=jdbc:mysql://localhost:3306/country-test

Y lo importamos en CountryServiceTest así:

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
class CountryServiceTest {
Base de datos MySQL

Antes de ejecutar cualquier prueba, debemos asegurar que los parámetros de conexión al servidor MySQL (db-test.properties) son correctos y que existe tanto la base de datos como el usuario.

He incluido en la raíz del proyecto este fichero Dockerfile basado en la imagen oficial de MySQL. Al crearse un contenedor, se configura todo lo necesario porque se ejecutará el script init.sql. El contenedor es válido tanto para la ejecución de las pruebas (base de datos country-test) como para trabajar con la propia aplicación (base de datos country).

Con las siguientes órdenes se construye la imagen y arranca un contenedor de usar y tirar (la opción –rm borra el contenedor tras su detención):

docker build -t spring-boot-demo-mysql .
docker run --rm -d -p 3306:3306 spring-boot-demo-mysql

Docker 1: introducción
Docker 2: imágenes y contenedores

Observa que el contenedor expone el puerto 3306 de MySQL (el predeterminado) hacia fuera a través del puerto 3306. Cuando haya más de un MySQL en ejecución, cada uno deberá atender por un puerto diferente. Si te encuentras en esta situación, configura puertos distintos y tenlos en cuenta en los datos de conexión.

Nota. Puedes gestionar los contenedores para las pruebas con la librería Testcontainers:

La anotación @Sql

El uso de una base de datos en las pruebas requiere de un juego de datos predeterminado en el que basarnos. Incluso es muy probable que tests distintos requieran de juegos de datos diferentes.

Satisfacer esta necesidad en el mundo Spring no puede ser más fácil. Lo que haremos es poner los scripts SQL correspondientes dentro de la carpeta /src/test/resources/ y declararlos en la anotación @Sql en el orden de ejecución deseado. Anotando la clase, los scripts se ejecutarán antes de cada prueba (esto se puede cambiar con la propiedad executionPhase).

@SpringBootTest
@Sql(scripts = {"/scripts/dataset1.sql", "/scripts/dataset2.sql"})
class SomeRandomServiceTest {

El fragmento de código anterior ejecutará los scripts /src/test/resources/scripts/dataset1.sql y /src/test/resources/scripts/dataset2.sql.

@Sql admite sentencias SQL in situ con statements:

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql(scripts = {"/scripts/dataset1.sql", "/scripts/dataset2.sql"}, statements = "TRUNCATE countries")
class SomeRandomServiceTest {

Los statements se ejecutan después de los scripts. El comportamiento contrario se consigue separando la declaración de scripts y statements en dos anotaciones tal que así:

@Sql(statements = "TRUNCATE countries")
@Sql(scripts = {"/scripts/dataset1.sql", "/scripts/dataset2.sql"})
class SomeRandomServiceTest {

También es posible marcar con @Sql un método de prueba, pero ten en cuenta que de forma predeterminada esto descartará la anotación @Sql que pudiera tener la clase. Puedes combinar ambas configuraciones (clase y método) con la anotación @SqlMergeMode, de forma que Spring ejecutará primero el SQL de la anotación @Sql de la clase y luego el de la anotación del método.

@Test
@Sql("/scripts/dataset3.sql")
@SqlMergeMode(SqlMergeMode.MergeMode.MERGE)
void testFindAll() {

Sin duda, @Sql es potente. No obstante, nuestro modesto proyecto de ejemplo solo precisa ejecutar un script:

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
class CountryServiceTest {

Aquí tienes el contenido de test-mysql.sql:

BEGIN;
TRUNCATE countries;

INSERT INTO `countries` (`id`, `create_by`, `created_date`, `last_modified_by`, `last_modified_date`, `name`, `population`) VALUES
    (1, 'test', '2018-11-17 18:50:06', 'test', '2018-11-17 18:50:10', 'Mexico', 130497248),
    (2, 'test', '2018-11-17 18:50:06', 'test', '2018-11-17 18:50:10', 'Spain', 49067981),
    (3, 'test', '2018-11-17 18:50:06', 'test', '2018-11-17 18:50:10', 'Colombia', 46070146);

COMMIT;

Inserta tres países que tomaremos como referencia en las pruebas. La orden TRUNCATE vacía la tabla countries de la manera más eficiente posible y reinicia la cuenta del generador de claves primarias.

El test de JUnit 5

Ahora estamos en condiciones de escribir nuestra primera prueba en la clase CountryServiceTest. Lo haremos de la forma estándar en JUnit 5: anotando con @Test un método ni privado ni estático y sin retorno. La costumbre es que tenga visibilidad de paquete, al igual que la clase.

package com.danielme.springboot.services;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.jdbc.Sql;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
class CountryServiceTest {

    @Autowired
    CountryService countryService;

    @Test
    void testFindAll() {
        List<Country> allCountries = countryService.findAll();

        assertEquals(3, allCountries.size());
    }

}

La prueba verifica que la llamada al método findAll de CountryServiceImpl, la única implementación que tenemos de CountryService, retorna las tres entidades de la base de datos de pruebas. Sabemos que deberían estar ahí gracias a la ejecución de test-mysql.sql. La aserción —comprobación del resultado— es mejorable; en breve solventaremos esto.

Nótese que podemos inyectar cualquier bean de Spring en un atributo. Con JUnit 5, las inyecciones también pueden ser argumentos de la prueba:

@Test
void testFindAll(@Autowired CountryService countryService) {

¡Ha llegado el momento de la verdad! Ejecutemos la prueba con Maven o nuestro IDE favorito tal y como explico aquí. El resultado debe lucir «verde». En la salida veremos que Spring Boot arranca y luego ejecuta el método testFindAll.

Si no consigues ejecutar el test con éxito, lo habitual es que el problema se encuentre en la base de datos. Revisa su contenido y los datos de conexión.

AssertJ

El iniciador para testing incluye la librería AssertJ. Nunca me canso de recomendarla. Proporciona una API poderosa y sencilla para la construcción de aserciones. No hay comparación posible con la rudimentaria API de aserciones de JUnit, ni siquiera con la de Jupiter. ¡Y es gratis! Hablo un poco sobre esta librería aquí.

Mejoremos testFindAll:

assertThat(countries)
                .extracting(Country::getName)
                .containsExactlyInAnyOrder(
                        Dataset.NAME_COLOMBIA,
                        Dataset.NAME_MEXICO,
                        Dataset.NAME_SPAIN);

La nueva aserción resulta más rigurosa porque no se limita a comprobar el tamaño de la lista. Aunque sería sencillo implementarla con las aserciones de JUnit 5, es una de las numerosas situaciones ya resueltas por la API de AssertJ, así que no te compliques la vida. Además, el código resultante es bastante legible, aun por quienes desconozcan la librería.

Por supuesto, usaré AssertJ en todas las pruebas.

Base de datos en memoria

Para ejecutar con éxito la prueba anterior no basta con disponer de las herramientas de construcción del proyecto (en nuestro caso, Maven 3.5 y Java 11). Es necesario instalar y configurar en el equipo una infraestructura de software más compleja (el servidor MySQL). Para colmo de males, durante la ejecución de las pruebas la base de datos debe ser de uso exclusivo con el fin de evitar incongruencias con los datos esperados.

Hoy esto no supone un gran inconveniente. Con Docker podemos tener una imagen para los tests con la base de datos exacta de la aplicación, estrategia que comenté líneas atrás.

Lo cierto es que debemos aspirar a que las pruebas se puedan ejecutar en cualquier equipo, como suele decirse, con solo pulsar un botón.

Una opción para conseguirlo es recurrir a una base de datos empotrada o embebida (embedded). Este nombre se debe a que se empaquetan en la aplicación y se ejecutan dentro de ella. Cuando la aplicación arranque o se detenga, la base de datos hará lo mismo. Lo habitual es utilizarlas en modo in-memory (datos volátiles en memoria) para que todos los datos, incluyendo esquema y usuarios, se pierdan tras su detención.

Spring Boot es compatible con tres bases de datos embebidas: H2, HyperSQL y Derby. He elegido H2, por ningún motivo en particular.

Agregamos su dependencia al pom:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

Hay dos formas de configurarla:

1-. Declarar un datasource específico para los tests.

spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=sa

Ese «:mem:» que aparece en la url significa modo in-memory.

2-. Marcar la clase de prueba con @AutoConfigureTestDatabase. Spring Boot se encargará de reemplazar el datasource de la aplicación por uno válido para la base de datos embebida declarada en el pom. Más adelante veremos que esta anotación se incluye en otra llamada @DataJpaTest.

En el proyecto de ejemplo he optado por la segunda alternativa.

Ahora creamos un nuevo script de inicio SQL válido para H2:

INSERT INTO countries (id, create_by, created_date, last_modified_by, last_modified_date, name, population) VALUES
    (1, 'test', '2018-11-17 18:50:06', 'test', '2018-11-17 18:50:10', 'Mexico', 130497248),
    (2, 'test', '2018-11-17 18:50:06', 'test', '2018-11-17 18:50:10', 'Spain', 49067981);

La siguiente clase es una copia de CountryServiceTest que utiliza H2:

@SpringBootTest(properties = {"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect"})
@Sql("/test-h2.sql")
@AutoConfigureTestDatabase
class CountryServiceH2Test {

    @Autowired
    CountryService countryService;

    @Test
    void testFindAll() {
        List countries = countryService.findAll();

        assertThat(countries)
                .extracting(Country::getName)
                .containsExactlyInAnyOrder(
                        Dataset.NAME_MEXICO,
                        Dataset.NAME_SPAIN);
    }

}

Revisando la bitácora vemos que se configuró el datasource de H2:

 INFO TestDatabaseAutoConfiguration$EmbeddedDataSourceBeanFactoryPostProcessor:106 - Replacing 'dataSource' DataSource bean with embedded version

Y el arranque de H2:

 INFO EmbeddedDatabaseFactory:189 - Starting embedded database: url='jdbc:h2:mem:63084dbe-31c8-40aa-99c7-3e6e13518c95;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'

Nunca me convenció esta estrategia. Si no utilizamos la misma base de datos que usa la aplicación en una instalación real, la validez de las pruebas es cuestionable. Asimismo, podría causar problemas cuando el proyecto ejecuta consultas SQL, ya que, en algunos casos, serán incompatibles con el lenguaje SQL propio de la base de datos en memoria. De hecho, ya nos ha pasado con el script del dataset.

Por ello, te recomiendo usar la base de datos real en un contenedor gestionado por Testcontainers.

Probando controladores web\API REST

La cosa se pone más interesante si cabe. En esta sección veremos cómo llamar desde las pruebas a los endpoints de la API REST.

MockMVC

La solución «tradicional» —esto es, sin Spring Boot— al problema del testeo de llamadas web consiste en recurrir a MockMVC. Esta clase permite probar los controladores simulando el envío de peticiones HTTP tal y como lo haría cualquier cliente de la API. La idea es testear los endpoints de forma «interna» sin tener que desplegar la aplicación en un servidor. La simulación resulta precisa y realista.

Spring Boot simplifica el uso de la clase MockMVC. Marcaremos la clase con las pruebas con @AutoConfigureMockMvc para que Spring Boot instancie un bean de MockMVC que inyectaremos:

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
@AutoConfigureMockMvc
public class CountryRestControllerTest {

    @Autowired
    private MockMvc mockMvc;

}

Con MockMVC las peticiones HTTP se construyen, ejecutan y validan con una práctica API fluida.

Lo primero es realizar la llamada al endpoint a probar con el método perform. Su argumento es un RequestBuilder con la configuración necesaria para que pueda retornar un MockHttpServletRequest que represente el detalle de la solicitud web que queremos enviar. El nombre de esta clase deja poco a la imaginación: es una implementación que simula el objeto HttpServletRequest que los contenedores de servlets crean para recoger la información de cada petición HTTP que reciben.

Estas explicaciones te servirán de poco a menos que las acompañe de un caso práctico. El siguiente fragmento de código llama al servicio GET /api/countries/2 que deberá devolver los datos para España:

ResultActions resultActions = mockMvc.perform(
      MockHttpServletRequestBuilder.get(CountryRestController.COUNTRIES_RESOURCE + "/{id}/", SPAIN_ID)
);

La construcción de un RequestBuilder resulta sencilla con los métodos estáticos de MockMvcRequestBuilders. Lo más cómodo será decantarnos por el correspondiente al verbo de la petición (get, post, etcétera).

Vemos que el resultado de la llamada es un objeto de la interfaz ResultActions. Con su método andExpect iremos encadenando las verificaciones que queremos aplicar a la respuesta y que funcionarán como aserciones. Lo haremos construyendo objetos de tipo ResultMatcher con MockMvcResultMatchers:

result
       .andExpect(MockMvcResultMatchers.status().is(HttpStatus.OK.value()))
       .andExpect(MockMvcResultMatchers.jsonPath("$.name", is(Dataset.NAME_SPAIN)));

En el fragmento de código anterior se comprueba que el estado de la respuesta es OK (200) y que consiste en un JSON con el valor «Spain» en el atributo name. Se usó la librería JsonPath, incluida en el starter de testing. Permite validar respuestas JSON aplicando expresiones de Hamcrest.

Así queda la prueba completa:

@Test
void testGetSpain() throws Exception {
    mockMvc.perform(get(CountryRestController.COUNTRIES_RESOURCE + "/{id}", SPAIN_ID))
            .andExpect(status().is(HttpStatus.OK.value()))
            .andExpect(jsonPath("$.name", is(NAME_SPAIN)))
            .andReturn()
            .getResponse()
            .getContentAsString();
}

La validación del JSON parece fácil. No obstante, resultará complicada cuando nos enfrentemos a documentos JSON más complejos y \o queramos chequear muchos valores.

En general, si una aserción se basa en una cadena larga correspondiente a un documento de tipo JSON, XML o similar, lo más práctico es tener el documento esperado en un fichero y compararlo con la respuesta del código a probar. Es el caso de esta reescritura de testGetSpain que obtiene el JSON retornado por el servicio REST para verificar que coincida con el contenido del fichero /src/test/resources/responses/spain.json:

@Test
void testGetSpainAgainstFile() throws Exception {
        String spainResponseJson = mockMvc.perform(get(CountryRestController.COUNTRIES_RESOURCE + "/{id}", SPAIN_ID))
            .andExpect(status().is(HttpStatus.OK.value()))
            .andReturn()
            .getResponse()
            .getContentAsString();

      Resource resource = new ClassPathResource("/responses/spain.json");
      assertThat(resource.getFile()).hasContent(spainResponseJson);
}

testGetSpainAgainstFile muestra cómo recuperar la respuesta JSON en una cadena. Su otro punto de interés es la manera de compararla con el fichero spain.json. Primero hay que obtener el File que lo representa. Podemos hacerlo con clases estándar de Java como Path y Files, pero es más sencillo aprovecharse de la clase Resource de Spring. A continuación, tomamos ese File como argumento de una sobrecarga de Assertions#assertThat para asegurar con el método hasContent que su contenido coincide con la respuesta del servicio.

Ahora que hemos visto un primer ejemplo completo y realista, pongo el diagrama UML con las clases e interfaces más destacadas implicadas en el desarrollo de pruebas con MockMVC (Spring 5.3.22). Un magnífico arsenal a nuestra disposición. Haz clic para ampliar.

Probar la creación de una nueva entidad requiere enviar una petición POST que contenga en su cuerpo o body los datos en formato JSON. Podemos construir un objeto CountryRequest y convertirlo en JSON si inyectamos en CountryRestControllerTest un ObjectMapper. Esta clase pertenece a la librería Jackson en la que Spring se apoya para realizar las conversiones de objetos en JSON (métodos writeValueAsString) y viceversa (métodos readValue).

Estos dos métodos comprueban la creación correcta de un nuevo país y que se devuelva un error 400 cuando el país a crear no tenga un nombre:

@Autowired
private MockMvc mockMvc;

@Autowired
ObjectMapper objectmapper;

@Test  
void testAddGermany() throws Exception {
    CountryRequest country = new CountryRequest("Germany", 79778000);

    String response = mockMvc
            .perform(post(CountryRestController.COUNTRIES_RESOURCE)
                    .content(objectmapper.writeValueAsString(country))
                    .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().is(HttpStatus.CREATED.value()))
            .andReturn()
            .getResponse()
            .getContentAsString();

    logger.info(response);
}

@Test
void testNoNameCreateCountry() throws Exception {
    CountryRequest country = new CountryRequest(null, 1);

    String response = mockMvc
            .perform(post(CountryRestController.COUNTRIES_RESOURCE)
                    .content(objectmapper.writeValueAsString(country))
                    .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().is(HttpStatus.BAD_REQUEST.value()))
            .andReturn()
            .getResponse()
            .getContentAsString();

    logger.info(response);
}

Fíjate bien cómo se construye la petición: los métodos de MockHttpServletRequestBuilder devuelven el objeto implícito para que podamos ir configurando la petición paso a paso. De este modo, añadimos el contenido del body, parámetros al header, cookies, etcétera.

WebTestClient

Aunque MockMvc hace el trabajo, merece la pena destacar que también contamos con WebTestClient, una API muy similar. Fue creada para probar las API reactivas introducidas en Spring 5 con el módulo WebFlux. Desde Spring Boot 2.6 (noviembre 2021) es compatible con Spring MVC, el módulo que nos da los controladores web. La idea es contar con una única API que permita probar ambas tecnologías.

Te dejo una versión de testGetSpain con la que podrás hacerte una idea de cómo funciona WebTestClient. El punto de entrada a esta API es análogo al de MockMVC: Spring Boot nos lo da en un bean si aplicamos @AutoConfigureWebTestClient.

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
@AutoConfigureWebTestClient
class CountryRestControllerTest {

    @Test
    void testGetSpainWebTestClient(@Autowired WebTestClient client) {
        byte[] response = client.get().uri(CountryRestController.COUNTRIES_RESOURCE + "/{id}", SPAIN_ID)
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus()
                .isOk()
                .expectBody()
                .jsonPath("name").isEqualTo(NAME_SPAIN)
                .returnResult()
                .getResponseBody();

        logger.info(new String(response, StandardCharsets.UTF_8) );
    }

Si se produjera este error:

 No qualifying bean of type 'org.springframework.test.web.reactive.server.WebTestClient' available

Hay que añadir el iniciador de WebFlux:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <scope>test</scope>
</dependency>
REST Assured

Rest Assured es una popular librería Java para el testeo de servicios REST. Su API es compatible con MockMVC si incluimos en el pom la dependencia io.rest-assured\spring-mock-mvc. La versión la establece Spring Boot.

<dependency>
  <groupId>io.rest-assured</groupId>
  <artifactId>spring-mock-mvc</artifactId>
  <scope>test</scope>
</dependency>

Las pruebas deberán apoyarse en la clase io.restassured.module.mockmvc.RestAssuredMockMvc en vez de io.restassured.RestAssured para beneficiarnos de la integración con MockMVC. La clave está en pasar a RestAssuredMockMvc el objeto MockMVC con la que interactuará con la API REST.

Vamos con unos ejemplos. Este método es una implementación alternativa a testGetSpain basada en REST Assured:

@Test
void testGetSpainRestAssured() {
    RestAssuredMockMvc.mockMvc(mockMvc);

    String response = when().get(CountryRestController.COUNTRIES_RESOURCE + "/{id}/", SPAIN_ID)
            .then()
            .statusCode(HttpStatus.OK.value())
            .body("name", equalTo(NAME_SPAIN))
            .extract()
            .asString();

    logger.info(response);
}

Tiene algo menos de código y la validación de un campo del JSON es inmediata.

En esta otra prueba, RestAssured es capaz de transformar el resultado en una lista de países:

@Test
void testGetAllRestAssured() throws JsonProcessingException {
    RestAssuredMockMvc.mockMvc(mockMvc);

    List countries = when().get(CountryRestController.COUNTRIES_RESOURCE)
            .then()
            .statusCode(HttpStatus.OK.value())
            .extract()
            .as(new TypeRef() {});

    assertThat(countries)
            .extracting(Country::getName)
            .containsExactlyInAnyOrder(
                    NAME_COLOMBIA,
                    NAME_MEXICO,
                    NAME_SPAIN);

    logger.info(objectmapper.writeValueAsString(countries));
}

Estas son las pruebas de los restantes servicios REST (actualización y borrado) con RestAssured:

    @Test
    void testUpdateRestAssured() {
        RestAssuredMockMvc.mockMvc(mockMvc);
        String newName = "new name";
        Integer newPopulation = 1000;
        CountryRequest countryRequest = new CountryRequest(newName, newPopulation);

        given()
                .header("Content-type", "application/json")
                .and()
                .body(countryRequest)
                .when()
                .put(CountryRestController.COUNTRIES_RESOURCE + "/" + SPAIN_ID)
                .then()
                .statusCode(HttpStatus.NO_CONTENT.value());

        Optional country = countryRepository.findById((long) SPAIN_ID);
        assertThat(country).isNotEmpty();
        assertThat(country.get().getName()).isEqualTo(newName);
        assertThat(country.get().getPopulation()).isEqualTo(newPopulation);
    }

    @Test
    void testDeleteRestAssured() {
        RestAssuredMockMvc.mockMvc(mockMvc);

        given()
                .when()
                .delete(CountryRestController.COUNTRIES_RESOURCE + "/" + SPAIN_ID)
                .then()
                .statusCode(HttpStatus.NO_CONTENT.value());

        Optional country = countryRepository.findById((long) SPAIN_ID);
        assertThat(country).isEmpty();
    }
La configuración webEnvironment de Spring Boot

Veamos ahora una funcionalidad exclusiva de Spring Boot. La propiedad webEnvironment de @SpringBootTest configura el contexto de Spring que se arrancará en lo que respecta a los servicios web (si el proyecto cuenta con el iniciador web).

Esta tabla explica los cuatro valores posibles del enumerado del diagrama anterior.

WebEnvironmentFuncionamiento
MOCK (opción predeterminada)Simula un entorno web explotable con MockMVC y WebTestClient.
DEFINED_PORTInicia un servidor web embebido, accesible en el puerto configurado en el application.properties (el predeterminado es el 8080). Podemos llamar a los controladores de forma real con cualquier cliente HTTP.
RANDOM_PORTIgual que la opción anterior, pero se elige un puerto HTTP aleatorio distinto en cada ejecución. La idea es evitar conflictos con otros servidores que tengamos en ejecución. Averiguamos el puerto con esta inyección:
@LocalServerPort
private Integer port;
O esta:
@Value(«${local.server.port}»)
private Integer port;
NONESin soporte web.
Así pues, con las opciones segunda y tercera podrás desplegar tus servicios web en un servidor «de verdad». Las pruebas serán más realistas, pero ten en cuenta que tardarán un pelín más a causa del arranque y la detención del servidor.

Aquí tienes una demostración:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
class CountryRestControllerEmbeddedServerTest {

    @LocalServerPort
    private Integer port;

    @Test
    void testGetSpain() {
        Country spain = given()
                .contentType(ContentType.JSON)
                .when()
                .baseUri("http://localhost:" + port)
                .get(CountryRestController.COUNTRIES_RESOURCE + "/{id}", SPAIN_ID)
                .then()
                .statusCode(HttpStatus.OK.value())
                .extract()
                .as(Country.class);

        assertThat(spain.getId())
                .isEqualTo(SPAIN_ID);
    }
    
}

Rest Assured se conecta a localhost mediante el puerto indicado por Spring y obtiene la entidad Country.

Spring Security

¡Peligro! Nuestra API REST carece del más mínimo mecanismo de seguridad. Su segurización y las consecuencias a la hora de testearla son el tema de este tutorial:

Spoiler: se presenta la clase SecurityMockMvcRequestPostProcessor y la anotación @WithMockUser.

Mockito fácil con @MockBean y @SpyBean

Mockito es una librería superútil para crear objetos dobles de tests (test doubles). Está incluida en el iniciador para testing. Dado que ya publiqué el tutorial Test Doubles con Mockito me centraré en la integración con Spring Boot.

Esta integración es excepcional gracias a las anotaciones @MockBean y @SpyBean. Generan mocks y spys que reemplazan dentro del contexto de Spring a sus beans equivalentes. Veámoslo con un ejemplo.

Creamos la clase CountryServiceMockAnnotationTest, análoga a CountryServiceTest, para testear el bean CountryService existente en el contexto. Este bean (CountryServiceImpl) tiene como dependencia CountryRepository, así que vamos a declarar un mock del mismo con @MockBean. Haremos que la llamada a CountryRepository#findAll retorne una lista vacía:

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
public class CountryServiceMockAnnotationTest {

    @MockBean
    CountryRepository countryRepository;

    @Autowired
    CountryService countryService;

    @Test
    public void testFindAllEmptyResponse() {
        when(countryRepository.findAll()).thenReturn(Collections.emptyList());

        assertThat(countryService.findAll()).isEmpty();
    }

}

Lo más destacable del test es que la integración de Spring Boot con Mockito consigue que en el bean CountryService se inyecte el mock para CountryRepository. Por ello, se cumplirá la aserción, ya que el objeto «auténtico» de CountryRepository debería devolver tres países; algo que, recordarás, comprueba CountryServiceTest#testFindAll. Asimimo, el comportamiento del mock establecido en un método de prueba solo se aplica dentro del método.

@SpyMock puede causar problemas con repositorios de Spring Data JPA. Esto fue resuelto en Spring Boot 2.5.3 de acuerdo a esta incidencia. Ten presente que los beans de Spring son objetos proxy, lo que puede provocar algún que otro inconveniente cuando trabajamos con Mockito.

Clases de configuración para pruebas con @TestConfiguration

Quizás encontremos casos en los que @MockBean y @SpyBean sean insuficientes y necesitemos construir con código algunos beans específicos para ciertos tests. Al final de este tutorial explico cómo hacerlo combinando clases de configuración y perfiles.

De nuevo, todo resulta más sencillo con Spring Boot porque permite crear clases de configuración para ser aplicadas solo en las pruebas. La clave radica en marcarlas con @TestConfiguration en lugar de @Configuration.

Las clases @TestConfiguration pueden escribirse de dos maneras:

  1. En su propio fichero. Se aplican en aquellas clases de pruebas en las que se declaren con @Import.
  2. Si solo se van a requieren en una clase de pruebas, basta con crearlas como clases internas estáticas para que se apliquen de manera automática. Pero si en la anotación @SpringBootTest se especifican clases de configuración con el atributo classes, hay que añadir a classes esas clases internas.

Veamos un ejemplo del segundo punto. Vamos a crear un ObjectMapper de Jackson que formatee de forma bonita (pretty printing) el JSON. Dado que solo lo necesitamos en las pruebas de CountryRestControllerTest, podemos escribir una clase interna marcada con @TestConfiguration. En un método factoría -anotación @Bean– construimos y devolvemos el ObjectMapper que reemplazará al que Spring Boot configura de forma automática.

Nota. Esto es un ejemplo, puedes personalizar Jackson en el fichero application.properties.

class CountryRestControllerTest {

    private static final Logger logger = LoggerFactory.getLogger(CountryRestControllerTest.class);

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    ObjectMapper objectmapper;

    @TestConfiguration
    static class TestConfigurationApp {
        @Bean
        ObjectMapper objectMapperPrettyPrinting() {
            return JsonMapper.builder()
                    .enable(SerializationFeature.INDENT_OUTPUT)
                    .addModule(new JavaTimeModule())
                    .build();
        }
    }

Es posible que al construir un ObjectMapper se lance esta excepción:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"

El mensaje apunta la solución: añadir la dependencia indicada.

Ahora el JSON devuelto por los servicios REST resulta legible para un humano:

c.d.s.c.CountryRestControllerTest        : response: {
  "createdDate" : 1542480606000,
  "lastModifiedDate" : 1542480610000,
  "createBy" : "test",
  "lastModifiedBy" : "test",
  "id" : 2,
  "name" : "Spain",
  "population" : 49067981
}

Otro ejemplo. Esta es una variación de CountryServiceMockAnnotationTest en la que el mock se crea en una clase externa:

@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Import(TestFactory.class)
class CountryServiceMockFactoryTest {

    @Autowired
    CountryRepository countryRepository;

    @Autowired
    CountryService countryService;

    @Test
    void testFindAllEmptyResponse() {
        when(countryRepository.findAll()).thenReturn(Collections.emptyList());

        assertThat(countryService.findAll()).isEmpty();
    }
@TestConfiguration
class TestFactory {

    @Bean
    @Primary
    CountryRepository countryRepositoryMock() {
        CountryRepository mock = mock(CountryRepository.class);
        when(mock.findAll()).thenReturn(Collections.emptyList());
        return mock;
    }

}

Debemos aclarar una cuestión de suma importancia. Puesto que estamos creando un nuevo bean para CountryRepository, Spring tiene ahora dos beans distintos que puede inyectar: el «real» y el generado por TestFactory. Solucionamos esta ambigüedad con la anotación @Primary para que nuestro countryRepositoryMock se inyecte siempre de forma predeterminada. Por consiguiente, en el momento que se aplique TestFactory todas las inyecciones del bean CountryRepository, tanto las solicitadas en las clases de pruebas como en las clases «reales», se resolverán con countryRepositoryMock.

Test slices \ Selección de capas

Hasta ahora, hemos arrancado el contexto de Spring al completo. A veces, esto es demasiado. Penalizamos innecesariamente la ejecución de los tests.

Es posible configurar en cada clase los componentes de Spring necesarios mediante la creación de clases de configuración personalizadas. ¿No necesitas los controladores? ¡Descártalos! Fácil de decir, farragoso de hacer

Por fortuna, en Spring Boot contamos con el concepto Test slice: varias anotaciones alternativas a @SpringBootTest que permiten indicar las capas (slices) de Spring que necesitamos. Veamos los dos slices más importantes.

@WebMvcTest

Configura la capa web, incluyendo controladores, filtros y Spring Security. Quedan fuera todos los demás beans: la capa de persistencia con Spring Data y las clases marcadas con @Service, @Component o @Repository.

Como ejemplo, vamos a crear una nueva versión de CountryRestControllerTest#testGetSpain:

@WebMvcTest(CountryRestController.class)
class CountryRestControllerOnlyWebTest {

    private static final Logger logger = LoggerFactory.getLogger(CountryRestControllerOnlyWebTest.class);

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    CountryService countryService;

    @Test
    void testGetSpain() throws Exception {
        Mockito.when(countryService.findById((long) SPAIN_ID)).thenReturn(Optional.of(new Country("Spain", 0)));

        String response = mockMvc.perform(get(CountryRestController.COUNTRIES_RESOURCE + "/{id}", SPAIN_ID))
                .andExpect(status().is(HttpStatus.OK.value()))
                .andExpect(jsonPath("$.name", is("Spain")))
                .andReturn().getResponse()
                .getContentAsString();

        logger.info("response: " + response);
    }

}

Se requiere un mock de CountryService por ser dependencia de CountryRestController, pues debido a @WebMvcTest, Spring no lo crea.

Para mejorar aún más el rendimiento, he acotado en @WebMvcTest los controladores que necesitamos.

@DataJpaTest

Configura solo los repositorios de Spring Data JPA, el acceso a la base de datos y la anotación @Repository; desaparecen los controladores, Spring Security y los beans de tipo @Component y @Service. Por tanto, @DataJpaTest es ideal para una clase de prueba que testee CountryRepository:

@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class CountryRepositoryTest {

    @Autowired
    CountryRepository countryRepository;

    @Test
    void testFindAll() {
        assertThat(countryRepository.findAll()).hasSize(3);
    }

}

@DataJpaTest incluye @AutoConfigureTestDatabase por lo que las pruebas usarán una base de datos embebida. He deshabilitado este comportamiento con el atributo replace de @AutoConfigureTestDatabase porque quiero usar el MySQL definido en db-test.properties,

Pruebas vintage con JUnit 4

Además de Jupiter, JUnit 5 cuenta con otra librería llamada JUnit Vintage. Su cometido es ejecutar pruebas escritas con JUnit 3 y JUnit 4. Nos permitirá dar el salto a JUnit 5 en proyectos que ya cuenten con pruebas para versiones anteriores de JUnit sin tener que modificarlas.

JUnit Vintage no se incluye en spring-boot-starter-test desde Spring Boot 2.4 (noviembre 2020). Ningún problema, la traemos de vuelta con esta dependencia:

<dependency>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.hamcrest</groupId>
            <artifactId>hamcrest-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

El testeo con JUnit 4 en Spring lo traté en este tutorial, aunque sin Spring Boot. Sirva de ejemplo rápido la siguiente adaptación de la clase CountryServiceTest:

@RunWith(SpringRunner.class)
@SpringBootTest
@TestPropertySource(locations = "classpath:db-test.properties")
@Sql("/test-mysql.sql")
public class CountryServiceJUnit4Test {

    @Autowired
    CountryService countryService;

    @Test
    public void testFindAll() {
        List<Country> allCountries = countryService.findAll();
        
        assertThat(allCountries).hasSize(3);
    }

}

Jupiter y Vintage tienen algunas anotaciones de igual nombre, como @Test. Debemos ser cuidadosos y elegir las correctas en cada clase de prueba en función de la librería que queramos usar.

Con todo, la gran diferencia con respecto a CountryRepositoryTest radica en la declaración del runner que integra Spring con JUnit 4. Si no la hacemos, Spring Boot asumirá que la clase contiene pruebas de Jupiter.

¡Cuidado con el rendimiento!

Cierro el tutorial con un aviso. Si has lanzado las pruebas, habrás podido constatar por ti mismo que tienen un coste elevado en lo que respecta al tiempo de ejecución; el arranque del contexto de Spring es el principal culpable.

Spring Boot intenta reutilizar el contexto durante la ejecución conjunta de un grupo de clases de prueba. Uso el verbo intentar porque hay casos en los que resulta imposible: la próxima prueba cambia la configuración de Spring, el perfil y\o las propiedades, o bien usa las anotaciones @MockBean, @SpyBean o @DirtiesContext.

@DirtiesContext se aplica a una clase de prueba o a un método @Test para pedir la destrucción del contexto actual tras la ejecución de la prueba. Querrás este comportamiento cuando la prueba realice modificaciones en el contexto que puedan causar problemas en las pruebas posteriores. Un buen ejemplo es el sistema de caché integrado en Spring: si lo usas en tu proyecto y quieres limpiarlo tras una prueba, anótala con @DirtiesContext.

Veamos un ejemplo práctico. Si ejecutamos en conjunto las clases CountryServiceTest y CountryServiceJUnit4Test, en la bitácora solo aparece una vez el logotipo (configurable) que Spring Boot muestra durante el arranque. Una señal de que las pruebas están compartiendo el mismo contexto porque es válido para todas ellas.

Todo cambia si añadimos al lote anterior la clase CountryServiceMockAnnotationTest. Pese a que utiliza, en apariencia, la misma configuración de Spring que las otras dos clases, «mockea» (simula) un bean, lo que requiere la creación de un contexto específico que contenga ese mock. En esta ocasión, veremos en la salida que Spring arrancó dos contextos: uno para CountryServiceTest y CountryServiceJUnit4Test, y otro para CountryServiceMockAnnotationTest.

En consecuencia, si no prestamos atención, a medida que la aplicación crezca y con ella las pruebas, la ejecución de estas últimas puede eternizarse. Procuremos usar la misma configuración en el mayor número de pruebas y minimizar los mocks.

Código de ejemplo

El proyecto está en GitHub. Para más información sobre GitHub, consultar este artículo.

Tutoriales relacionados

Testing Spring Boot: Docker con Testcontainers y JUnit 5
Spring Boot: Gestión de errores en aplicaciones web y REST
Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA
Testing Spring con JUnit 4
Testing con JUnit 4
Introducción a JUnit 5
Persistencia en BD con Spring Data JPA

Un comentario sobre “Testing en Spring Boot con JUnit 4\5. Mockito, MockMvc, REST Assured, bases de datos en memoria.

  1. Excelente articulo, muy preciso para cada tipo de test… por ahi que a veces no cargan los ccs de la página pero eso no tiene que ver con la calidad del articulo

Deja un comentario

Este sitio utiliza Akismet para reducir el spam. Conoce cómo se procesan los datos de tus comentarios.