Testcontainers – Dockeriza tus tests de integración en Java

3
4390

Índice de Contenidos

    1. Introducción
    2. Contenedor genérico
    3. Contenedor mysql
    4. Contendor Singleton

1. Introducción

El uso de una base de datos en memoria como H2 en Java tiene algunas desventajas porque los tests podrían depender de características que las bases de datos en memoria no pueden reproducir y algunos tests que han pasado en local pueden fallar en producción. Esto afecta a la fiabilidad de nuestros tests porque no cubriremos al 100% los mismos escenarios que en un entorno real. Testcontainers aparece en nuestro camino para que podamos dockerizar nuestros tests. Es una biblioteca de Java que permite crear cualquier instancia de Docker y manipularla. Claramente los tests van a tardar unos segundos mas que al usar una BBDD en memoria, pero debemos tener en cuenta que los estamos lanzando contra una base de datos igual a la de nuestro entorno de producción.

Los siguientes ejemplos están hechos con JUnit5, pero si estás usando JUnit4, los cambios son mínimos, por lo que no tendrás ningún problema. Comenzamos añadiendo la dependencia en el pom.xml. Podemos añadir la dependencia genérica o una más específica (si queremos un contenedor preconfigurado). También debemos añadir la dependencia del driver de base de datos (Testcontainers no lo añadirá por nosotros). En maven repository puedes ver la lista de contenedores ya preconfigurados. Algunos ejemplos son mongoDB, postgresql, cassandra, elasticsearch, rabbitmq, entre otros.

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

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.20</version>
</dependency>

¿Cómo funciona Testcontainers?

  1. Levanta un contenedor con la imagen de docker específica  (en mi ejemplo estaré usando una imagen mysql).
  2. Otro contenedor llamado Ryuk se levantará y su tarea principal es la de gestionar el arranque y la detención del contenedor.

consola: docker ps, 2 contenedores mysql y Ryuk

Una de las ventajas de esta biblioteca es su integración con JUnit. Para poder usar las siguientes anotaciones, necesitamos añadir esta dependencia.

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

De la documentación:

  • @Testcontainers: es una extensión de JUnit Jupiter para activar el inicio automático y la detención de los contenedores utilizados.
  • @Containers: se usa junto con la anotación @Testcontainers para señalar contenedores que deben ser administrados por Testcontainers.

2. Creando un contenedor genérico

Podemos crear un contenedor genérico a partir de cualquier imagen de docker pública o un docker-compose. Como este es un enfoque mas genérico, necesitamos realizar alguna configuración mas que si tuviésemos un contenedor preconfigurado.

 @Container
 private GenericContainer container = new GenericContainer("image_name")
            .withExposedPorts(port_number);

withExposedPorts(port),exponemos el puerto interno por defecto (en mysql es 3306) del contenedor, que será mapeado a un puerto aleatorio. Para recuperar ese puerto aleatorio en tiempo de ejecución, podemos usar el método getMappedPort(original_port) o simplemente getFirstMappedPort(). Si no exponemos el puerto, obtendremos el siguiente error ‘Container doesn’t expose any ports’. También podemos añadir variables de entorno al contenedor con .withEnv(), ejecutar comandos dentro de un contenedor (como docker exec), administrar nuestras propias estrategias de espera y arranque, etc. En la documentación podrás encontrar información mas detallada.

3. Creando un contenedor mysql

Como dije al principio, tenemos varios contenedores preconfigurados y listos para ser usados. En este caso, voy a utilizar un contenedor mysql para mis tests.
Podemos decidir si queremos iniciar y detener el contenedor cada vez que se ejecute un test o una única vez antes de cada clase de test (veremos más adelante cómo crear un contenedor singleton).

//Once per test class
 @Container
 private static final MySQLContainer mysql = new MySQLContainer("mysql:latest");
// Once per test method
 @Container
 private MySQLContainer mysql = new MySQLContainer("mysql:latest");

Nota: Si estás usando JUnit4, puedes usar las anotaciones @Rule y @ClassRule.

El siguiente ejemplo levanta un contenedor mysql y luego ejecuta mi HelloEndpointIT. Estoy usando static final en la instancia del contenedor, por lo que este será compartido entre todos los tests de la clase. El contenedor mysql me proporciona ciertos métodos para configurar un nombre de base de datos, un nombre de usuario y contraseña. Si no se especifica, se utilizan valores por defecto (nombre de base de datos: test, contraseña: test, nombre de usuario: test).

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(profiles = "test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Testcontainers
public class HelloEndpointIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @LocalServerPort
    private int port;

    @Container
    private static final MySQLContainer mysql = new MySQLContainer("mysql:latest")
                .withDatabaseName("demo_db_name")
                .withUsername("any_username")
                .withPassword("any_passw");

    @BeforeAll
    private void initDatabaseProperties() {
        System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
        System.setProperty("spring.datasource.username", mysql.getUsername());
        System.setProperty("spring.datasource.password", mysql.getPassword());
    }

    @Test
    public void hello_endpoint_should_return_hello_world() {
        HttpHeaders headers = new HttpHeaders();
        HttpEntity entity = new HttpEntity(headers);

        ResponseEntity<String> response = this.restTemplate.exchange(createUrlWith("/hello"), HttpMethod.GET, entity, String.class);

        assertThat(response.getStatusCode(), equalTo(HttpStatus.OK));
        assertThat(response.getBody(), equalTo("Hello world"));
    }

    private String createUrlWith(String endpoint) {
        return "http://localhost:" + port + endpoint;
    }
}

Cuando el contenedor ya está levantado, se necesita establecer la configuración del datasource. Podemos obtener la url con el puerto mapeado utilizando el método getJdbcUrl(), así como getUsername() y getPassword(). Se observa en el ejemplo cómo añado estos valores de configuración antes de ejecutar mis tests usando la anotación @BeforeAll proporcionada por JUnit5. En JUnit4 seria @BeforeClass.

mensaje en consola: contenedor docker arrancado

4. Creando un contendor Singleton

Hasta ahora hemos visto cómo levantar nuestro contenedor en una clase de tests, pero me gustaría crear una única instancia para todas mis clases. Veamos cómo levantar un contenedor singleton antes de ejecutar todos nuestros tests de integración.

Vamos a usar static Initializers para instanciar el contenedor solo una vez. Necesitamos hacerlo en una clase abstracta y extender todas nuestras clases de tests. En este caso, necesitamos iniciar manualmente el contenedor en nuestro Initializer blocker y cuando los tests hayan acabado, el contenedor Ryuk se encargará de detenerlo.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(profiles = "test")
public abstract class DemoEndpointIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @LocalServerPort
    private int port;

    private static final MySQLContainer mysql;

    static {
        mysql = new MySQLContainer("mysql:latest");
        mysql.start();
        System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
        System.setProperty("spring.datasource.username", mysql.getUsername());
        System.setProperty("spring.datasource.password", mysql.getPassword());
    }

    protected String createUrlWith(String endpoint) {
        return "http://localhost:" + port + endpoint;
    }

    protected TestRestTemplate getRestTemplate() {
        return this.restTemplate;
    }
}

Como hemos visto en los ejemplos, tener un contenedor mysql listo para los tests de integración ha sido bastante sencillo y la configuración ha sido mínima.

3 COMENTARIOS

  1. Muy útil 😋
    He visto en la documentación que una forma aún más facil de incluir las dependencias es importar el Maven BOM (org.testcontainers: testcontainers-bom) y de esa forma puedes añadir las dependencias que necesites sin indicar la versión (lo gestiona por ti para que todas sean las mismas).

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad