Índice de Contenidos
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?
- Levanta un contenedor con la imagen de docker específica (en mi ejemplo estaré usando una imagen mysql).
- Otro contenedor llamado Ryuk se levantará y su tarea principal es la de gestionar el arranque y la detención del contenedor.
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.
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.
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).
Gracias Jordi, lo tendré en cuenta para la próxima vez.
Un saludo 🙂
oh…