Testing Spring Boot: Docker con Testcontainers y JUnit 5

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.

  1. El problema
  2. Proyecto de ejemplo
  3. Presentando Testcontainers para Java
    1. El pom
    2. El primer test con MySQL
    3. Ejecutando la prueba
    4. Script de inicio
    5. Juegos de datos de prueba
  4. Compartir el contenedor entre pruebas
  5. Reutilización de contenedores
  6. La vía rápida de las bases de datos relacionales
  7. Bases de datos más rápidas
  8. Imágenes genéricas
  9. Código de ejemplo

El problema

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 tiene 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 verifiquen el funcionamiento de la aplicación con H2 si en realidad usa MySQL. Y eso suponiendo que no contemos con sentencias SQL incompatibles entre ambas bases de datos.

Debido a lo anterior, en aquel tutorial la mayoría de tests usaban una base de datos MySQL exclusiva para las pruebas. La necesidad de instalarla y configurarla desaparecía con Docker. Aun así, hay que crear y gestionar el contenedor adecuado en cada entorno (las máquinas de los programadores, integración, pruebas…).

Si compartes mi punto de vista, 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 con un ejemplo que, además, no requiere de Spring Framework.

Proyecto de ejemplo

El proyecto de ejemplo solo se usará para ejecutar pruebas escritas con JUnit 5 (siendo rigurosos, con la librería de testeo Jupiter). Se trata de un proyecto Maven basado en Spring Boot 2.7 con soporte para acceder a MySQL 8 mediante la API JDBC. Por ello, en el pom tenemos los starter jdbc y test, y el controlador JDBC de MySQL.

<?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);
	}

}

Puedes importar el proyecto en cualquier IDE, como Eclipse o IntelliJ. Asimismo, en la raíz encontrás el wrapper de Maven, una pequeña herramienta que permite usar Maven sin tenerlo instalado en el equipo. Solo tienes que ejecutar cualquier comando de Maven a través del script mvnw.cmd (Windows) o mvnw (Linux).

mvnw test

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 integrarlas en nuestras pruebas con la mayor facilidad posible. Si bien pone el foco en bases de datos y navegadores compatibles con Selenium \ WebDriver, Testcontainers es válido para cualquier servicio <<dockerizable>>. Asimismo, existen versiones para otros lenguajes como Python y .NET.

El pom

Testcontainers provee una dependencia de tipo bom (bill of materials) para gestionar cómodamente la versión de todas sus librerías.

<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>

Añadimos al proyecto Testcontainers con soporte para Jupiter.

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

También agregamos el módulo de MySQL. Veremos que no es imprescindible, pero simplifica el trabajo.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>
El primer test con MySQL

Pasemos a crear una clase de prueba con la configuración mínima.

@SpringBootTest
@Testcontainers
class MySQLContainerTest {

Tenemos la consabida anotación @SpringBootTest. Arranca un contexto de Spring integrado con JUnit 5 gracias a la extensión SpringExtension.

Lo relevante es que la clase está marcada con @Testcontainers. Esto activa la extensión de JUnit 5 que permite a Testcontainers arrancar los contenedores como parte del ciclo de ejecución de las pruebas.

Observa que 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 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, serán destruidos sin piedad.

La alternativa 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 será un enorme desperdicio de tiempo.

Sea cual fuere nuestra elección, marcaremos los atributos que representan a los contenedores con @Container. Serán de la clase GenericContainer. Por fortuna, muchos servicios cuentan con módulos que proveen especializaciones que facilitan su configuración específica.

El módulo de MySQL nos da la clase MySQLContainer. La instanciamos indicando el nombre y la versión 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 necesita los valores de los parámetros de la conexión al servidor MySQL del contenedor para construir un bean con el DataSource para las pruebas. Estos valores deben reemplazar a los que tengamos en el fichero application.properties correspondientes a la base de datos que usa el proyecto.

Y aquí surge un problema. El puerto de conexión se establece de forma aleatoria cada vez que Testcontainers arranca un contenedor. El objetivo es asegurar que el puerto escogido no esté ya en uso por parte de algún servicio en ejecución. Dos procesos no pueden atender al mismo puerto.

Así pues, tenemos que averiguar el puerto y encontrar la manera de declararlo en la configuración de Spring. Al ser variable, no es viable definirlo en un fichero de configuración o en la anotación @SpringBootTest con el atributo properties.

Resolvemos lo primero preguntando a mySQLContainer. Para lo segundo, crearemos un método estático marcado con @DynamicPropertySource. Su único argumento es un objeto de tipo DynamicPropertyRegistry en el que estableceremos los distintos valores de la conexión a la base de datos requeridos por Spring Boot.

@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 comprueba si está en ejecución.

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

Aunque no parece gran cosa, es más que suficiente para asegurar que todo está en orden.

Si revisamos con atención la salida (la imagen pertenece a IntelliJ), averiguamos qué ocurre tras las bambalinas. Testcontainers crea y ejecuta el contenedor y espera a que el servicio que contiene esté operativo. Esto tarda unos segundos porque MySQL tiene que arrancar y configurarse. Solo entonces Testcontainers permitirá que JUnit continúe con el ciclo de ejecución de las pruebas, lo que 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 la técnica empleada para chequear la disponibilidad de MySQL. Consiste en ejecutar repetidamente una consulta de prueba (SELECT 1) hasta que se obtenga la respuesta o 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. Su principal cometido es destruirlos tras las pruebas.

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.

En un fichero Dockerfile copiamos un script en esa carpeta con la siguiente orden.

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

En MySQLContainer lo hacemos con el método withInitScript.

    @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 verificar 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 un uso directo 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 los scripts de configuración de MySQL, aunque será probable que tests distintos requieran datasets diferentes. Spring satisface esta necesidad con la anotación @Sql. Hablo un poco sobre ella aquí.

Compartir el contenedor entre pruebas

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 tarda unos quince segundos. Una eternidad cuando estamos programando y vamos lanzando las pruebas con cierta frecuencia.

Para colmo de males, si tenemos varias clases de prueba que precisen de un mismo contenedor y lo definimos en cada una de ellas, se creará y ejecutará uno distinto para cada una.

Podemos acelerar un poco las cosas si preparamos una imagen que ya contenga las tablas y los datos básicos. Pero no es ni mucho menos suficiente.

¿Cómo reducimos esta pérdida de tiempo? ¿Escribimos todas las pruebas en una clase de tamaño descomunal?

La solución es llevarnos la configuración del contenedor a una superclase. De este modo, no solo centralizamos la configuración, sino que además únicamente se arrancará un contenedor que compartirán todas las pruebas de las clases hijas.

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);
    }

}

Comparando esta clase con MySQLContainerTest, vemos que las anotaciones @Testcontainers y @Container han desaparecido. Si las dejamos, Testcontainers seguirá creando un contenedor para cada clase hija, justo lo que pretendemos evitar. Ahora esta acción la haremos <<a mano>> una única vez en un bloque static (línea 12). De la destrucción del contenedor se responsabiliza nuestro amigo Ryuk.

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

class MySQLContainerClass1Test extends MySQLContainerBaseTest {

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

}
class MySQLContainerClass2Test extends MySQLContainerBaseTest {

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

}

Si lanzamos MySQLContainerClass1Test y MySQLContainerClass2Test de manera conjunta, veremos en la salida que se ha arrancado un único contenedor. Si las ejecutamos junto a MySQLContainerTest, tendremos dos: el definido en 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 prueba. No obstante, cada vez que lancemos una o varias de esas clases se seguirá generando y arrancando un nuevo contenedor.

Por ello, aquí hay otro punto de mejora: conseguir reutilizar el contenedor entre distintas tandas de ejecución de tests. Es decir, en vez de destruirlo después de que todas las pruebas finalicen, mantenerlo en ejecución para usarlo en futuras pruebas.

Echando un vistazo a la API de GenericContainer, vemos que con withReuse podemos forzar este comportamiento.

@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);

Por desgracia, 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();
    }

¿Eureka? Va a ser que no. Falta un último paso: activar la funcionalidad de reutilización en el fichero de configuración de Testcontainers. 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 Oct 02 14:52:55 CEST 2022
docker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy

testcontainers.reuse.enable = true

Los pasos a seguir los he tomado de aquí.

Lancemos las pruebas de una clase que herede de MySQLContainerBaseTest. La primera vez que lo hagamos se crea y arranca un contenedor, con el tiempo que ello supone. Pero cuando las pruebas terminen, continuará 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. La reutilización solo es posible mientras los contenedores generados por Testcontainers permanezcan en ejecución.
  • ¿Qué pasa cuando cambia 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, iniciará un nuevo contenedor.

Por último, quiero subrayar un detalle fundamental. Será responsabilidad nuestra detener y destruir el contenedor que permanece en ejecución. Por este motivo, en los entornos de integración (Jenkins, Bamboo, etcétera) se desaconseja 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.

La vía rápida de las bases de datos relacionales

Ahora que tienes los conocimientos básicos, te mostraré un atajo. Hay una manera supersimple de conseguir un contenedor de ciertas bases de datos relacionales entre las que se encuentran MySQL y PostgreSQL.

spring.datasource.url=jdbc:tc:mysql:8.0.30:///testcontainer

Es la configuración típica de la url de una base de datos accesible con JDBC que pondrías en el fichero application.properties de Spring Boot para las pruebas. Tiene dos peculiaridades: la palabra <<tc>> después de <<jdbc>> y la versión de MySQL tras <<mysql>>. La cadena <<testcontainer>> es el nombre que queremos darle a la base de datos.

Eso es todo. Testcontainers levantará un nuevo contenedor en cuanto Spring Boot arranque, mientras que Ryuk lo liquidará cuando se detenga. Ni siquiera tienes que anotar la clase de prueba con @Testcontainer. ¿El truco? El controlador JDBC que atiende las url de tipo <<jdbc:tc>> es la clase ContainerDatabaseDriver. Actúa como proxy del controlador real JDBC y conjura la magia de Testcontainers cuando Spring -o quien sea- cree el DataSource.

Con esta técnica conservamos la capacidad de ejecutar un script de inicio. Basta con indicar su ruta relativa dentro del classpath con la propiedad TC_INITSCRIPT.

spring.datasource.url=jdbc:tc:mysql:8.0.30:///testcontainer?TC_INITSCRIPT=init.sql

En cuanto al rendimiento, si ejecutamos varias de clases de prueba y se requieren contextos de Spring distintos, se creará un contenedor para cada uno. Podemos compartir el contenedor activando el flag TC_DAEMON.

spring.datasource.url=jdbc:tc:mysql:8.0.30:///testcontainer?TC_DAEMON=true

Atención, <<compartir>> se refiere a todas las pruebas lanzadas en un mismo lote de ejecución. Cuando finalicen, Ryuk eliminará el contenedor. La reutilización del contenedor que vimos con el método withReuse no está disponible con esta técnica de configuración basada en la url.

He incluido este ejemplo en el proyecto. Para evitar interferencias con otras pruebas, declaré la url en @SpringBootTest.

@SpringBootTest(properties = "spring.datasource.url=jdbc:tc:mysql:8.0.30:///testcontainer?TC_INITSCRIPT=init.sql")
class MySQLContainerUrlTest {

    @Test
    void testTableExists(@Autowired DataSource dataSource) throws SQLException {
        TableTestAssertion.assertTableExists(dataSource);
    }

}

Tienes la documentación oficial sobre esta funcionalidad aquí.

Bases de datos más rápidas

En Docker, la opción tmpfs <<monta>> una carpeta del contenedor en la memoria RAM del host, varias veces más rápida que cualquier unidad SSD. El inconveniente es que el contenido de esa carpeta se perderá cuando el contenedor se detenga.

Sabiendo lo anterior, sigamos arañando segundos. Si redirigimos a la memoria la carpeta donde la base de datos almacena la información, nos ahorramos el tiempo que consumen sus lecturas y escrituras en disco. Podemos hacerlo sin problema alguno porque nos da igual que los datos se pierdan.

El equipo de Testcontainers no ha pasado esto por alto y permite configurar carpetas de tipo tmpfs con el método GenericContainer#withTmpFs.

.withTmpFs(Map.of("/var/lib/mysql", "rw"))

O también en la url.

spring.datasource.url=jdbc:tc:mysql:8.0.30:///testcontainer?TC_INITSCRIPT=init.sql&TC_TMPFS=/var/lib/mysql:rw

No esperes una mejora apreciable de rendimiento, a menos que las pruebas tengan grandes datasets y utilicen intensivamente de la base de datos.

Imágenes genéricas

MySQL es uno de los numerosos módulos con los que cuenta Testcontainers. Sin embargo, señalé que podemos trabajar con cualquier servicio <<dockerizado>> aunque no disponga de un módulo. Todo lo que necesitamos es configurar el contenedor con la clase GenericContainer.

En esta nueva clase de prueba -no usa Spring- se instancia un contenedor de la imagen oficial más reciente del servidor web Apache. El test se limita a comprobar que el contenedor está en ejecución.

@Testcontainers
class ApacheWebContainerTest {

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

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

}

GenericContainer tiene bastantes métodos. 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 a exponer. Se publican hacia fuera 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.

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

@Test
void testGetResponseIsOk() 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);
}

La url completa del servidor web, incluyendo el puerto aleatorio mediante el cual se publica el puerto 80 hacia el exterior del contenedor, se consigue preguntando a httpdContainer .

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

Veamos una configuración más compleja. Definamos un contenedor para 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");
        }
    }

}

Acabamos de replicar con nuestras <<manos desnudas>> 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 indica la documentación de la imagen. También copiamos el script init.sql y establecemos el criterio que decide si MySQL está listo para recibir conexiones.

Si no hacemos esto último, en cuanto el contenedor se inicie las pruebas se ejecutarán antes de que la base de datos esté operativa. La estrategia aplicada no es tan elegante y segura 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. Ni siquiera hay que escribir código para implementarla.

.waitingFor(Wait.forLogMessage(".*mysqld: ready for connections.*", 2))

La línea anterior establece que el contenedor estará listo cuando una cadena que contenga <<mysqld: ready for connections.>> aparezca dos veces en su log. La primera vez que se muestre no 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 GitHub, consultar este artículo.

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

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