Absortio

Email → Summary → Bookmark → Email

Testing Spring Boot: Docker con Testcontainers y JUnit 5

Extracto

Las pruebas de integración y end-to-end suelen presentar diversas dificultades y retos. Un caso típico es el uso de una base de datos. En mi tutorial introductorio al testing automatizado con Sprin…

Contenido

logo spring

Las pruebas de integración y end-to-end suelen presentar diversas dificultades y retos. Un caso típico es el uso de una base de datos. En mi tutorial introductorio al testing automatizado con Spring Boot explico el empleo de una base de datos en memoria como alternativa al MySQL que usa el proyecto. Con esta estrategia ganamos velocidad en la ejecución de las pruebas y las dotamos de mayor autonomía al reducir las dependencias de sistemas externos.

Sin embargo, esta solución presenta un problema importante. Para que las pruebas sean realistas y útiles, debemos realizarlas en un entorno lo más parecido al de explotación final. De poco nos sirve que las pruebas verifiquen el funcionamiento de la aplicación con H2 si en realidad usa MySQL. Eso suponiendo que no contemos con sentencias SQL incompatible entre ambas bases de datos.

Debido a lo anterior, la mayoría de tests de aquel tutorial se realizaban empleando una base de datos en MySQL exclusiva para las pruebas. La necesidad de instalar y configurar MySQL desaparecía si recurrimos al uso de contenedores Docker. Aun así, debemos crear y gestionar el contenedor adecuado en cada entorno (las máquinas de los programadores, los entornos de integración y pruebas…).

Si compartes todas estas inquietudes, con el presente tutorial te vas a enamorar de Testcontainers.

Automatizaremos la creación y ejecución de contenedores Docker desde nuestros tests. Prestaré especial atención a la eficiencia, pues no queremos lastrar la velocidad de las pruebas de integración, ya de por sí lentas debido a su naturaleza. Aunque me centraré en MySQL, al final explicaré cómo usar cualquier imagen.

  1. Proyecto de ejemplo
  2. Presentando Testcontainers para Java
    1. El primer contenedor con MySQL
    2. Ejecutando la prueba
    3. Script de inicio
    4. Juegos de datos de prueba
  3. Compartir el contenedor entre pruebas
  4. Reutilización de contenedores
  5. Imágenes genéricas
  6. Código de ejemplo

Proyecto de ejemplo

El proyecto de ejemplo solo servirá para ejecutar pruebas escritas con JUnit 5 (siendo rigurosos, con la librería de testing Jupiter). Se trata de un proyecto Maven basado en Spring Boot 2.7 con soporte para acceder a MySQL 8 mediante la API JDBC.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.3</version>
        <relativePath/>
    </parent>
    <groupId>com.danielme</groupId>
    <artifactId>spring-testcontainers</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-testcontainers</name>
    <description>Demo project for Spring Boot and TestContainers</description>

    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>

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

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

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

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

La correspondiente clase Main.

package com.danielme.spring.testcontainers;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringTestcontainersApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringTestcontainersApplication.class, args);
	}

}

Presentando Testcontainers para Java

Testcontainers es una librería de código abierto compatible con JUnit (4 y 5) y Spock. Su finalidad es la gestión de instancias de contenedores Docker para que podamos usarlas en nuestras pruebas con la mayor facilidad posible. Si bien pone el foco en base de datos y navegadores compatibles con Selenium \ WebDriver, Testcontainers es válido para cualquier servicio que sea «dockerizable». Asimismo, existen versiones para otros lenguajes como Python y .NET.

El primer contenedor con MySQL

Añadimos Testcontainers con soporte para Jupiter al proyecto de ejemplo con esta dependencia.

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

Y el módulo específico para MySQL. Veremos que no es imprescindible, pero simplifica el trabajo.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>

Las versiones se pueden gestionar con un artefacto de tipo bom (bill of materials).

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>${testcontainers.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<properties>
    <testcontainers.version>1.17.3</testcontainers.version>
    <java.version>11</java.version>
</properties>

Pasemos a escribir una clase de pruebas con la configuración mínima necesaria.

@SpringBootTest
@Testcontainers
class MySQLContainerTest {

Además de la consabida anotación @SpringBootTest, la cual lleva implícita la aplicación de la extensión SpringExtension, la clase está marcada con @Testcontainers. Esto activa la extensión de JUnit 5 que permite a Testcontainers arrancar y detener los contenedores como parte del ciclo de ejecución de las pruebas. En realidad, Spring y Testcontainers no se integran entre sí, sino que ambos lo hacen de forma independiente con JUnit 5. Sus extensiones se encargan de que colaboren de manera armoniosa.

A continuación, procedemos a declarar como atributos cada contenedor. Si se guardan en una propiedad estática, se comparten entre todos las pruebas de la clase. Así, los contenedores se arrancarán antes de la ejecución del primer test y permanecerán disponibles hasta que se ejecute el último. En ese momento, todos serán destruidos.

La otra opción es recurrir a un atributo que no sea estático. En ese caso, los contenedores se crean y destruyen para cada prueba. Algo que, salvo escenarios poco habituales, no queremos que suceda porque es una pérdida de tiempo innecesaria.

Sea cual sea nuestra elección, marcaremos los atributos que representan a los contenedores con @Container. Cada uno se declara y construye como una instancia de la clase GenericContainer. Por fortuna, muchos servicios cuenta con módulos que proveen especializaciones que facilitan su configuración específica.

El módulo de MySQL nos da la clase MySQLContainer que podemos instanciar indicando el nombre de la imagen. Usaremos la oficial disponible en Docker Hub.

@Container
private static final MySQLContainer mySQLContainer = new MySQLContainer<>("mysql:8.0.30");

De forma predeterminada, MySQLContainer crea una base de datos llamada test accesible con las credenciales test\test. Si alguno de estos valores no fuera adecuado, lo cambiamos.

@Container
private static final MySQLContainer mySQLContainer = new MySQLContainer<>("mysql:8.0.30")
        .withDatabaseName("testcontainer")
        .withUsername("test")
        .withPassword("test");

Spring Boot debe conocer los parámetros de conexión al servidor MySQL situado dentro mySQLContainer para que pueda instanciar el DataSource apropiado. Estos parámetros deben reemplazar a los que tengamos en el fichero application.properties correspondientes a la base de datos que usa el proyecto. Lo que haremos es recuperarlos del objeto que representa al contenedor. De hecho, es la única manera de conocer el puerto de conexión ya que se genera de forma aleatoria cada vez que Testcontainers arranca un contenedor con el fin de asegurar que no haya colisión con cualquier otro servidor MySQL que esté en ejecución.

Por tanto, tenemos que los datos de conexión, al menos el puerto, son dinámicos, por lo que no es posible declararlos en un hipotético fichero application.properties específico para las pruebas. La solución consiste en añadirlos a las propiedades de Spring en un método de nuestra clase de pruebas marcado con @DynamicPropertySource.

@DynamicPropertySource
private static void setupProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
    registry.add("spring.datasource.username", mySQLContainer::getUsername);
    registry.add("spring.datasource.password", mySQLContainer::getPassword);
}
Ejecutando la prueba

¡Todo listo! Usemos el contenedor en un test. El siguiente pregunta a mySQLContainer si el contenedor que representa está en ejecución.

@Test
void testRunning() {
    assertThat(mySQLContainer.isRunning()).isTrue();
}

Aún cuando no parece gran cosa, es más que suficiente para comprobar que todo está en orden.

Si revisamos con atención la salida (la imagen pertenece a IntelliJ), averiguamos qué ocurre tras las bambalinas. Testcontainers inicia el contenedor y espera a que el servicio esté operativo, lo cual tarda unos segundos debido a que MySQL tiene que arrancar y configurarse. Solo entonces permite que JUnit proceda con la ejecución de las pruebas, lo que en el ejemplo implica el arranque del contexto Spring.

Esta línea es muy interesante.

Waiting for database connection to become available at jdbc:mysql://localhost:49164/testcontainer using query 'SELECT 1'

Además de revelar la url de conexión, indica que la disponibilidad de MySQL se detecta ejecutando repetidamente una consulta de prueba (SELECT 1) hasta que se obtenga la respuesta o bien se supere el tiempo de espera máximo de arranque, situado por omisión en 120 segundos. Este valor, generoso en extremo, puede modificarse con el método MySQLContainer#withStartupTimeoutSeconds.

Con una herramienta como DockStation veremos lo siguiente durante la ejecución.

Ryuk es el siniestro nombre de la imagen que Testcontainers usa para gestionar los contenedores. Uno de sus cometidos es asegurar que se destruyan tras las pruebas, suponiendo que este sea el comportamiento configurado. Lo trataremos en la próxima sección.

Script de inicio

La imagen oficial de MySQL viene preparada para ejecutar scripts de inicio si los ubicamos en la carpeta /docker-entrypoint-initdb.d. Permiten crear la estructura de la base de datos, los registros predefinidos, procedimientos almacenados, etcétera, con independencia de que luego las pruebas establezcan los datos que necesiten.

En un fichero Dockerfile sería así.

COPY init.sql /docker-entrypoint-initdb.d/

Con MySQLContainer lo hacemos del siguiente modo.

    @Container
    private static final MySQLContainer mySQLContainer = new MySQLContainer<>("mysql:8.0.30")
            .withDatabaseName("testcontainer")
            .withUsername("user")
            .withPassword("pass")
            .withInitScript("init.sql");

Indicamos la ruta relativa al fichero, teniendo en cuenta que la raíz es la carpeta /src/test/resources/ del proyecto. Este es el contenido de init.sql en el proyecto de ejemplo; crea una tabla con su clave primaria.

USE testcontainer;

CREATE TABLE tests (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
)

Mejoremos MysqlContainerTest para comprobar que el script init.sql fue ejecutado. Se precisa del DataSource de la base de datos.

@Autowired
private DataSource dataSource;    

@Test
void testTableExists() throws SQLException {
    try (Connection conn = dataSource.getConnection();
         ResultSet resultSet = conn.prepareStatement("SHOW TABLES").executeQuery();) {
         resultSet.next();

        String table = resultSet.getString(1);
        assertThat(table).isEqualTo("tests");
   }
}

testTableExists chequea la existencia de la tabla tests haciendo uso de la API estándar JDBC de acceso a base de datos relacionales. Obtiene una conexión del DataSource, ejecuta con ella una consulta y recoge el resultado en un ResultSet. Todo ello dentro de un bloque try-with-resources que garantice el cierre de los recursos abiertos.

Juegos de datos de prueba

Pocos tests seremos capaces de escribir si no contamos con un juego de datos o dataset de prueba en el que basarnos. Podríamos incluirlo en un script de arranque, aunque será más que probable que tests distintos requieran datasets diferentes. Spring satisface esta necesidad con la anotación @Sql de la que hablo aquí.

Compartir el contenedor entre pruebas

Si tenemos varias clases con tests que precisen de un mismo contenedor y lo definimos en cada una de ellas, se creará uno distinto para cada una. En nuestro caso, supone una pérdida de tiempo enorme. Asimismo, es a todas luces innecesario: solo precisamos un mismo servidor MySQL con la base de datos del proyecto. Ya nos encargaremos de establecer los distintos juegos de datos de pruebas con @Sql.

La solución es crear una superclase con toda la configuración relativa a Testcontainers para poder heredarla. Con ello no solo centralizamos la configuración, sino que además el mismo contenedor se usará en todas las pruebas de las clases hijas.

Así pues, vamos a llevarnos mySQLContainer a una superclase.

@SpringBootTest
public class MySQLContainerBaseTest {

    @Autowired
    protected DataSource dataSource;
    
    protected static final MySQLContainer mySQLContainer = new MySQLContainer<>("mysql:8.0.30")
            .withDatabaseName("testcontainer")
            .withUsername("user")
            .withPassword("pass");
    static {
        mySQLContainer.start();
    }

    @DynamicPropertySource
    private static void setupProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mySQLContainer::getUsername);
        registry.add("spring.datasource.password", mySQLContainer::getPassword);
    }

}

Encontramos dos diferencias con respecto a la clase MySQLContainerTest. La más obvia es la desaparición de las anotaciones @Testcontainers y @Container. No las necesitamos porque no queremos que Testcontainers gestione el contenedor; lo arrancamos nosotros. La consecuencia de esta decisión es que en la línea 12 se invoca al método start. De la destrucción del contenedor se hace cargo nuestro amigo Ryuk.

Comprobémoslo con estas dos nuevas clases que heredan de la anterior.

class MySQLContainerClass1Test extends MySQLContainerBaseTest {

    @Test
    void testRunning() {
        assertThat(mySQLContainer.isRunning()).isTrue();
    }

}
class MySQLContainerClass2Test extends MySQLContainerBaseTest {

    @Test
    void testRunning() {
        assertThat(mySQLContainer.isRunning()).isTrue();
    }

}

Si lanzamos MySQLContainerClass1Test y MySQLContainerClass2Test de manera conjunta, veremos en la salida que se ha creado un único contenedor. Si las ejecutamos junto a MySQLContainerTest, tendremos dos: el de esa clase y el definido en MySQLContainerBaseTest. Ambos coexistirán hasta que las pruebas terminen. Esto es posible porque, recuerda, cada contenedor se expone por un puerto distinto.

Reutilización de contenedores

Hemos visto cómo escribir una superclase para compartir un contenedor entre varias clases de pruebas. No obstante, cada vez que lancemos un lote de pruebas se seguirá generando y destruyendo uno nuevo.

La creación y arranque del contenedor no supone una gran demora de tiempo, pero la configuración inicial de MySQL sí, tal y como habrás notado si ejecutaste los ejemplos. En el equipo en el que estoy escribiendo el tutorial, lleva unos quince segundos. Una eternidad cuando estamos desarrollando y tenemos que ir lanzando con frecuencia las pruebas.

Dado que en nuestro ejemplo la configuración del contenedor y MySQL nunca cambia, deberíamos crear solo uno y, además de compartirlo entre clases, reutilizarlo. Es decir, en vez de destruirlo después de su uso, tenerlo siempre en ejecución. Así, nos ahorramos tanto el inicio del contenedor como la configuración inicial de MySQL. No es poca cosa.

Echando vistazo a la documentación de GenericContainer, vemos que withReuse debería hacer lo que indica el párrafo anterior.

@SpringBootTest
@Testcontainers
class MySQLContainerTest {

    @Container
    private static final MySQLContainer mySQLContainer = new MySQLContainer<>("mysql:8.0.30")
       .withDatabaseName("testcontainer")
       .withUsername("user")
       .withPassword("pass")
       .withInitScript("mysql/schema.sql")
       .withReuse(true);

Lastimosamente, no es tan sencillo. La configuración anterior no cambia nada porque debemos eliminar las anotaciones @Container y @Testcontainers, y pasar a gestionar manualmente el contenedor. Es lo mismo que hicimos en la clase MySQLContainerBaseTest. Apliquemos en ella withReuse.

@SpringBootTest
public class MySQLContainerBaseTest {

    @Autowired
    protected DataSource dataSource;

    protected static final MySQLContainer mySQLContainer = new MySQLContainer<>("mysql:8.0.30")
            .withDatabaseName("testcontainer")
            .withUsername("user")
            .withPassword("pass")
            .withReuse(true);
    static {
        mySQLContainer.start();
    }

Queda un último paso «oculto»: activar la funcionalidad de reutilización. Tenemos tres opciones.

  • Establecer esta variable de entorno del sistema
TESTCONTAINERS_REUSE_ENABLED = true
  • Usar el fichero con la configuración «global». Se encuentra en la carpeta del usuario en el sistema operativo, según explica la documentación. En mi equipo (uso Linux), es /home/dani/.testcontainers.properties.
#Modified by Testcontainers
#Sun Aug 28 14:52:55 CEST 2022
docker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy

testcontainers.reuse.enable = true
  • En lugar del anterior, crear uno para el proyecto llamado testcontainers.properties y ubicarlo en el classpath. Por ejemplo, en /src/test/resources.

El orden de preferencia de aplicación de la configuración coincide con el de los puntos.

Sea como fuere, lancemos ahora una prueba que herede MySQLContainerBaseTest. La primera vez que lo hagamos se crea y arranca un contenedor, con el tiempo que ello supone. Pero cuando los tests finalicen, ese contenedor continúa en ejecución. En consecuencia, las siguientes veces que lancemos las mismas pruebas serán más rápidas porque el contenedor requerido ya está disponible. Un plan perfecto.

La reutilización plantea un par de preguntas más que pertinentes.

  • ¿Y si detenemos el contenedor? El invento no funciona y se crea uno nuevo en lugar de arrancar el ya existente. Por ende, la reutilización solo es posible mientras los contenedores generados por Testcontainers permanezcan en ejecución.
  • ¿Qué pasa cuando cambie la configuración? Por ejemplo, si modificamos el nombre de la base de datos. En este caso, Testcontainers detecta el cambio y, con muy buen criterio, creará un nuevo contenedor.

Por último, quiero subrayar un detalle importante. Será responsabilidad nuestra detener y destruir el contenedor que queda en ejecución. Por este motivo, en los entornos de integración (Jenkins, Bamboo, etcétera) no es recomendable activar la propiedad REUSE para evitar que con el paso del tiempo se vayan convirtiendo en un «vertedero» de contenedores. Y, por supuesto, en nuestra máquina de desarrollo también debemos tener cuidado con esto.

Imágenes genéricas

MySQL es uno de los numerosos módulos con los que cuenta Testcontainers. Sin embargo, como ya he apuntado, podemos trabajar con cualquier imagen aunque no tenga un módulo específico. En esta nueva clase -no requiere Spring- se instancia un contenedor de la imagen oficial más reciente del servidor web Apache.

@Testcontainers
class ApacheWebContainerTest {

    @Container
    private static final GenericContainer httpdContainer = new GenericContainer<>("httpd:latest");

    @Test
    void testRunning() {
        assertThat(httpdContainer.isRunning()).isTrue();
    }

}

La configuración del contenedor se realiza con los numerosos métodos de GenericContainer. MySQLContainer, por ser una especialización suya, también cuenta con ellos. He recopilado en una tabla los más importantes según mi experiencia.

withExposedPortsLos puertos del contenedor que hay que exponer. Se publican hacia fuera del contenedor a través de puertos aleatorios.
withEnvEstablece una variable de entorno.
withCopyFileToContainerCopia un fichero al contenedor antes de que arranque. Equivale al comando COPY de Dockerfile.
withCommandSobrescribe el comando que el contenedor ejecuta al arrancar.
waitingForPermite definir con WaitStrategy la estrategia de detección de la disponibilidad del servicio que ofrece el contenedor.
withFileSystemBindPublica un fichero del host dentro del contenedor.
withClasspathResourceMappingPublica un fichero del classpath dentro del contenedor.

Mejoremos la prueba publicando el puerto 80 del servidor web. La url completa, incluyendo el puerto aleatorio mediante el cual se publica el 80 hacia el exterior del contenedor, se consigue preguntando a httpdContainer (método getMappedPort).

@Container
private static final GenericContainer httpdContainer = new GenericContainer<>("httpd:latest")
                                                                                       .withExposedPorts(80);

@Test
void testGet() throws Exception {
  String address = "http://" + httpdContainer.getHost() + ":" + httpdContainer.getMappedPort(80);
  HttpRequest localhost = HttpRequest.newBuilder(new URI(address)).build();

  HttpResponse<Void> response = HttpClient.newHttpClient()
           .send(localhost, HttpResponse.BodyHandlers.discarding());

   assertThat(response.statusCode()).isEqualTo(HttpStatus.SC_OK);
}

testGet emplea la API de networking introducida en Java 11 para verificar que la llamada a la url raíz del servidor web devuelve el código 200.

Veamos un ejemplo más complejo. Definamos un contenedor de MySQL con las mismas características de los ejemplos previos.

@SpringBootTest
@Testcontainers
class MySQLCustomContainerTest {

    @Autowired
    private DataSource dataSource;

    @Container
    private static final GenericContainer mySQLContainer = new GenericContainer<>("mysql:8.0.30")
            .withEnv("MYSQL_ROOT_PASSWORD", "pass")
            .withEnv("MYSQL_DATABASE", "testcontainer")
            .withEnv("MYSQL_USER", "user")
            .withEnv("MYSQL_PASSWORD", "pass")
            .withExposedPorts(3306)
            .waitingFor(Wait.forLogMessage(".*mysqld: ready for connections.*", 2))
            .withCopyFileToContainer(MountableFile.forClasspathResource("init.sql"), "/docker-entrypoint-initdb.d/schema.sql");

    @DynamicPropertySource
    private static void setupProperties(DynamicPropertyRegistry registry) {
        String url = "jdbc:mysql://localhost:" + mySQLContainer.getMappedPort(3306) + "/testcontainer";
        registry.add("spring.datasource.url", () -> url);
        registry.add("spring.datasource.username", () -> "user");
        registry.add("spring.datasource.password", () -> "pass");
    }

    @Test
    void testTableExists() throws SQLException {
        try (Connection conn = dataSource.getConnection();
             ResultSet resultSet = conn.prepareStatement("SHOW TABLES").executeQuery();) {
            resultSet.next();

            String table = resultSet.getString(1);
            assertThat(table).isEqualTo("tests");
        }
    }

}

Estamos replicando «a mano» la configuración que realiza internamente el módulo de MySQL. Las credenciales y el nombre de la base de datos se envían al contenedor con variables de entorno. Esto lo sabemos porque lo explica la documentación de la imagen. También copiamos el fichero de iniciación y establecemos el criterio que decide si MySQL está listo para recibir conexiones.

Si no hacemos esto último, en cuanto el contenedor se inicie se ejecutarán las pruebas antes de que la base de datos esté operativa. La estrategia aplicada no es tan fiable ni elegante como la que emplea el módulo y que ya comenté (ir ejecutando una consulta de prueba). Pero es simple, funciona y se puede adaptar a otros servicios. Consiste en esperar a que aparezca dos veces en la salida del contenedor una cadena que contenga «mysqld: ready for connections». La primera vez que sale el mensaje no nos sirve porque a continuación MySQL se reinicia para aplicar los cambios del script init.sql.

Código de ejemplo

El proyecto de ejemplo se encuentra en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.

Fuente: danielme.com