Test de Integración con docker-maven-plugin
Debería ser una práctica habitual hacer pruebas unitarias en nuestro código. Eso básicamente consiste en usar JUnit o similar para realizar pruebas automáticas de nuestras clases. Pero el que las clases sueltas o en pequeños grupos funcionen, no quiere decir que la aplicación completa funcione. Es aquí donde entran las pruebas de integración. En estas pruebas se levanta nuestra aplicación completa y se le pasan prueba automáticas para ver que el conjunto funciona.
A veces, nuestra aplicación es más compleja y no es un único ejecutable que arrancamos y probamos. A veces nuestra aplicación puede estar compuesta de varios ejecutables y puede necesitar otras aplicaciones levantadas con las que habla de alguna manera, por ejemplo, una base de datos, un servidor de LDAP para autentificación de usuarios, etc.
Para que todo sea lo más automático posible, lo ideal sería que un solo comando podamos compilar nuestro proyecto, arrancar todos los ejecutables necesarios, tanto de nuestra aplicación como bases de datos u otros, pasar las pruebas de integración y luego parar todos los ejecutables.
Aquí es donde maven, docker y el plugin docker-maven-plugin vienen en nuestra ayuda. Veamos como.
La aplicación a probar[editar]
No nos vamos a meter en detalles de la aplicación a probar. Será una aplicación rest con base de datos y spring boot. En el enlace puedes ver cómo hemos metido la aplicación completa en un solo jar para no complicarnos la vida ahora. Aquí tienes el código de rest-java-application
Levanta un servicio web REST que solo tiene dos URL. Una es http://localhost:8080 que devuelve el consabido testo "Hello World!" y la otra es http://localhost:8080/accounts que devuelve en formato JSON una lista de usuarios (id, usuario, password y email). La lista de usuarios se consulta de una base de datos.
Si miras en src/main/resources, verás el fichero application.properties de spring boot con los parámetros de conexión a la base de datos.
Lo que queremos hacer con docker-maven-plugin es levantad dos docker, uno con nuestra aplicación y el otro con una base de datos postgres y algun dato en las tablas. Luego con los docker levantados, pasar nuestras pruebas de integración, sencillas en nuestro caso. Ver que una URL devuelve "Hello World!" y que la otra devuelve los usuarios que hemos metido en nuestra base de datos de docker.
El código completo tanto de la aplicación como de la generación de los docker y pruebas de integración lo tienes en docker-maven-examples
configurar docker-maven-plugin[editar]
En el pom.xml del proyecto maven que queramos generar los docker, añadimos el plugin docker-maven-plugin. Puedes ver el pom.xml] completo. Pero vamos con los detalles.
La estructura básica del plugin es
<project>
<build>
<plugins>
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
<version>0.34.1</version>
<configuration>
<images>
<image>
...
<build>...</build>
<run>...</run>
</image>
<image>
...
<build>...</build>
<run>...</run>
</image>
</configuration>
<executions>
<execution>
<id>start</id>
<phase>pre-integration-test</phase>
<goals>
<goal>build</goal>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>stop</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
...
La primera parte es fácil, el groupId, artifactId, etc del plugin.
Luego, en configuration, viene la lista de docker (image) que queremos construir y levantar. Aparte de algunos parámetros propios en cada image, tenemos por cada image una sección build para decir como construirla y una sección run para decir cómo arrancarla.
En la parte de executions, hemos ligado a la fase pre-integracion-test (antes de que se ejecuten los test de integración) los goal buidl y start, que construyen y arrancan respectivaemnte los docker. También hemos ligado a la fase post-integratoin-test el goal stop. Después de pasar los test de integración, el goal stop parará los docker.
Vamos con los detalles de build y run
docker-maven-plugin[editar]
Hay varias formas de construir una imagen con este plugin. Básicamente son estas tres
- Danto todos los datos necesarios directamente en el pom.xml, dentro del build de la image
- Referenciar a un fichero Dockerfile estándar que tengamos en el path.
- Referenciar a un fichero xml con la misma sintaxis que los ficheros de maven-assembly-plugin que tengamos en el path.
build docker con postgreSQL[editar]
Vamos con el de base de datos, que es un poco más sencillo.
<image>
<alias>database</alias>
<name>chuidiang/database:${project.version}</name>
<build>
<contextDir>${project.basedir}/src/main/docker/database</contextDir>
</build>
<run>
...
</run>
</image>
El primer alias nos sirve para poder referenciar este docker dentro del pom.xml en otros sitios. Por ejemplo, cuando hagamos el docker de nuestra aplicación, necesitaremos decirle a docker-maven-plugin que necesita la base de datos.
El name es el nombre de la imagen docker, al estilo docker.
Y la parte build. Hemos puesto cun contextDir para decirle a docker-maven-plugin donde está el fichero Dockerfile. Si vamos a ese path, veremos
30/01/2021 16:11 <DIR> docker-entrypoint-initdb.d 04/03/2019 14:19 130 Dockerfile
y dentro del directorio docker-entrypoint-initdb.d
30/01/2021 16:11 307 init.sql
Todo esto ya son detalles propios de docker y de la imagen de base de datos Postgresql que hemos elegido. Detallamos un poco por no dejar la explicación a medias. El fichero Dockerfile contiene, con sintaxis propia de docker
FROM mdillon/postgis COPY docker-entrypoint-initdb.d /docker-entrypoint-initdb.d RUN chmod +r /docker-entrypoint-initdb.d/*
Básicamente, que queremos partir de la imagen mdillon/postgis ya construida en docker-hub. ¿Por qué Postgis?. Pues por ningún motivo especial, copy-paste de código que tenía por ahí. Puedes elegir una postgresql normal que te guste.
COPY copia el contenido del directorio docker-entrypoint-initdb.d dentro de la imagen y RUN ejecuta todos los fichero sql que encuentre ahí dentro. El fichero sql solo tiene el create table y un insert para tener algún dato
-- Creacion de tablas
CREATE TABLE accounts (
user_id serial PRIMARY KEY,
username VARCHAR ( 50 ) UNIQUE NOT NULL,
password VARCHAR ( 50 ) NOT NULL,
email VARCHAR ( 255 ) UNIQUE NOT NULL
);
INSERT INTO accounts (username,password,email)
VALUES ('the username', 'the password', 'the email');
run docker con postgreSQL[editar]
Vamos con la parte de ejecución de este docker. En el pom.xml tenemos lo siguiente
<run>
<hostname>database</hostname>
<env>
<POSTGRES_USER>postgres</POSTGRES_USER>
<POSTGRES_PASSWORD>postgres</POSTGRES_PASSWORD>
<POSTGRES_DB>docker_tests</POSTGRES_DB>
</env>
<ports>
<port>5432:5432</port>
</ports>
<wait>
<time>30000</time> <!-- 30 seconds max -->
<log>database system is ready to accept connections</log>
</wait>
</run>
Le damos un hostname. En este caso es importante, porque el docker de nuestra aplicación rest necesita saber la ip/nombre del docker donde está la base de datos. Docker monta un DNS interno y las máquinas se pueden resolver por nombre. Esta hostname es distinto del alias que pusimos antes. El alias es para referenciar este docker dentro del pom.xml y para el log de construcción y arranque, mientras que el hostname será le nombre que tenga el docker cuando se esté ejecutando y que será visible para los demás docker que intervengan.
Definimos una serie de variables de entorno env. Estas variables son propias del docker de postgres del que hemos partido mdillon/postgis y son específicas de la imagen concreta de la que partamos. En nuestro caso, indicamos el usuario y password de acceso a la base de datos y el nombre de la base de datos dentro de postgres, donde se ejecutará el init.sql que vimos antes. En decir, esto creará dentro del servidor postgres una base de datos docker_test, con usuario postgres y password posgres.
Los puertos son los puertos que publica este docker. El 5432 es el de defecto de la base de datos postgres. El primer 5432 es el puerto con el que lo vamos a ver desde fuera del docker, el segundo es el interno al docker. Queremos que sea el mismo, pero podemos cambiarlo si nos apetece.
wait le indica al docker-maven-plugin a qué tiene que esperar para saber que el docker está correctamente arrancado. Si no llega esa condición en el tiempo indicado, supondrá que algo ha ido mal y abortará todo el proceso. Hay varias opciones para la condición, cada una con una etiqueta xml específica. En este caso usamos log. docker-maven-plugin estará pendiente del log del docker de base de datos mientras arranca buscando ese texto. Cuando aparezca el texto en el log, supondrá que todo ha ido correcto y pasará al siguiente paso.
build docker con nuestra aplicación[editar]
Para construir un docker con nuestra aplicación rest-java-application tenemos el siguiente trozo de xml
<image>
<alias>rest-java-application</alias>
<name>chuidiang/rest-java-application:${project.version}</name>
<build>
<from>java:8</from>
<assembly>
<!-- name es tambien el directorio donde se colocaran las cosas -->
<name>rest-application</name>
<descriptor>rest-application/rest-application-assembly.xml</descriptor>
</assembly>
<cmd>
<!-- /rest-application por el name de assembly
bin porque dentro del xml assembly dice directorio bin -->
<shell>java -jar /rest-application/bin/rest-java-application-1.0-SNAPSHOT.jar</shell>
</cmd>
</build>
<run>
...
</run>
Igual que antes, le damos un alias por si hace falta referenciar este docker en otras partes del pom.xml. También el nombre de nuestra imagen docker al estilo docker.
Como la aplicación java es más compleja de construir, ya que debe traernos el jar de nuestra aplicación y todas sus dependencias, es más cómodo en este caso usar un fichero estilo assembly de maven a un fichero Dockerfile. El fichero Dockerfile requiriría que el jar con todas sus dependencias estén previamente copiados en algún directorio accesible. Se pueden traer todas las dependencias y copiarlas en directorios específicos usando plugins de maven, pero el plugin maven assembly nos hace esto de forma fácil.
Así que en vez de Dockerfile, usarmos la opción del fichero assembly.
En from decimos de que imagen docker partimos para construir la nuestra. De una de java:8 como se indica ahí.
En la parte assembly indicamos el path donde está nuestro fichero assembly. Se busca por defecto en src/main/docker. Debajo de ese directorio hemos creado dos subdirectorios, uno para lo de la base de datos y otro para este con la aplicación. Así que en esta etiqueta xml ponemos rest-application/rest-application-assembly.xml, que es el subdirectorio que creamos y el nombre del fichero. Le hemos dado también un nombre, rest-application. Vemos el assemmbly con más calma después del siguiente punto.
En cmd indicamos qué queremos ejecutar. El comando es java -jar y el path del jar. Assembly lo mete en el raíz del docker, crea un directorio con el name del assembly (rest-application) y ahí desempaqueta nuestro assembly. Lo de bin es porque así lo hemos puesto en el assembly.
Y vamos con el assembly, es este
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd">
<id>rest-application</id>
<formats>
<format>jar</format>
</formats>
<dependencySets>
<dependencySet>
<outputDirectory>bin</outputDirectory>
<scope>runtime</scope>
</dependencySet>
</dependencySets>
<fileSets>
<fileSet>
<directory>src/main/docker/rest-application</directory>
<includes>
<include>application.properties</include>
</includes>
<outputDirectory>bin</outputDirectory>
</fileSet>
</fileSets>
</assembly>
Es un assembly normal, de los de maven-assembly-plugin. Se añaden las dependencias de runtime y un punto importante, se añade un fichero application.properties de spring-boot junto al jar del ejecutable. ¿Por qué?. Spring boot mira este fichero y si no existe, lee el que tiene dentro del jar. El que está dentro del jar se supone que tiene la conexión con la base de datos "real", mientras que aquí queremos conectarnos a la base de datos del docker base de datos. En este application.properties, que hemos creado en el path que se indica en la etiqueta directory del xml, tenemos el siguiente contenido
spring.jpa.generate-ddl=false spring.datasource.url=jdbc:postgresql://database:5432/docker_tests spring.datasource.username=postgres spring.datasource.password=postgres
Decimos que no queremos que en el arranque se creen las tablas (generate-dll a false), porque nuestro docker de base de datos no solo las tiene creadas, sino que también tiene datos. La conexión a la base de datos en con la máquina database, que es el hostname que pusimos en el image run de la base de datos. La base de datos a la que conectarse es docker_test y usuario y password para la conexión son postgres. Estas tres cosas las definimos en las variables de entorno env en el image run de la base de datos.
run docker con nuestra aplicación[editar]
Vamos con la parte del run.
<run>
<hostname>rest</hostname>
<ports>
<!-- elige un puerto al azar para exponer el 8080 del docker
y lo guarda en la variable tomcat.port -->
<port>8080:8080</port>
</ports>
<wait>
<http>
<url>http://localhost:8080/</url>
</http>
<time>10000</time>
</wait>
<links>
<link>database:db</link>
</links>
<workingDir>/rest-application/bin</workingDir>
</run>
Le hemos dado un hostname, pero nos daría lo mismo porque nadie va a buscar este docker cuando esté en ejecución. Los puertos, el 8080 de la aplicación rest que da por defecto spring boot. Igual que antes, primero el puerto con el que queremos verlo desde fuera y luego el puerto interno del docker.
En la parte del wait aquí ponemos otra cosa que no es el log. Otra opción es esperar a que una url http esté disponbible. Y qué mejor que la de nuestra aplicación. Hemos publicado el puerto 8080 interno del docker en el puerto 8080 externo, así que veremos desde maven nuestra aplicación, según documentación de docker, en localhost puerto 8080.
En la parte links indicamos que necesitamos la base de datos. Ponemos aquí el alias que dimos a nuestra base de datos. El :db es para tener como DB_* disponbiles las variables propias del docker de base de datos, con DB_NAME, DB_PORT, etc. por si nos hicieran falta, que no es el caso.
Finalmente, ponemos el workingDir de nuestra aplicación (del cmd que pusimos arriba). Elegimos el directorio donde está la aplicación y el fichero application.properties /rest-application/bin. Si no lo hacemos así, en este caso, nuestra aplicación no encontraría el fichero application.properties, porque spring boot lo busca en el workingDir.
Test de Integración con maven-failsafe-plugin[editar]
Solo nos queda ya ejecutar los test de integración. Maven, por defecto, usa maven-surefire-plugin, pero este está pensado para los test unitarios, los que prueban clases sueltas. Y debemos tenerlos y ejecutarlo. Sin embargo, para test de integración, una buena opción es maven-failsafe-plugin. Este ejecuta los test de integración y no aborta la ejecución si alguno falla. Otra diferencia es que surefire se ejecuta durante la fase de test, mientras que failsafe se ejecuta durante la fase de integration-test.
surefire ejecutará los test de Junit que estén en src/test/java y que el nombre de la clase empiece o acabe por Test. failsafe hará lo mismo, pero el nombre de la clase de test debe empezar o terminar por IT (Integration Test), Así que nuestros test de integración los pondremos en src/main/test y el nombre de las clases de test, con Junit, terminará en IT. Este puede ser un test
package com.chuidiang.examples;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.Map;
import java.util.Scanner;
public class RestIT {
static TestRestTemplate restTemplate;
@BeforeClass
public static void init(){
restTemplate= new TestRestTemplate();
}
@Test
public void whenSendingGet_thenMessageIsReturned() throws IOException {
String url = "http://localhost:8080";
URLConnection connection = new URL(url).openConnection();
try (InputStream response = connection.getInputStream();
Scanner scanner = new Scanner(response)) {
String responseBody = scanner.nextLine();
Assert.assertEquals("Hello World!", responseBody);
}
}
@Test
public void whenGettingAccounts_thenAccountsAreReturned() throws IOException {
String url = "http://localhost:8080/accounts";
ResponseEntity<Map[]> response = restTemplate.getForEntity(url, Map[].class);
Map[] accounts = response.getBody();
Assert.assertNotNull(accounts);
Assert.assertEquals(1,accounts.length);
Map account = accounts[0];
Assert.assertEquals("the username", account.get("username"));
Assert.assertEquals("the password", account.get("password"));
Assert.assertEquals("the email", account.get("email"));
}
}
El primero, con la clase URL, conecta con http://localhost:8080 (expuesto en la etiqueta ports del xml) y verifica que nos llega el texto Hello World!.
El segundo, usa RestTemplate, clase de spring que es capaz de hacer una llamada a un servicio rest. Llama a la URL http://localhost:8080/accounts. Ante esta llamada, nuestra aplicación consulta la base de datos y devuelve un array de usuarios en formato json. Verificamos que nos devuelve uno y que su nombre, password y email son los que habiamos puesto en el fichero init.sql del docker de base de datos.
Ejecución de los test de integración[editar]
Listo, no necesitamos más.
mvn verify pasa todos los test de integración con failsafe. Como pusimos build y start de las imagenes docker en la fase pre-integration-test, estos docker se crearán con la última versión disponible de nuestra aplicación y so arrancarán. Sobre ellos se ejecutarán los test de integración. Obtendremos por pantalla los resultados y finalmente, se pararán los docker por la fase post-integration-test.
mvn docker:build nos permitiría en cualquier momento que nos interese reconstruir las imagenes.
mvn docke:start y mvn docker:run son comandos que nos permitirían arrancar los docker creados y mantenerlos arrancdos, por si queremos hacer algún tipo de prueba manual de nuestra aplicación o ver si está todo correcto. docker:start no muestra apenas log de los docker, mientras que docker:run nos muestra todo el log de arranque de todas las máquinas. Esta segunda opción está bien para depurar los arranque si no nos funcionan, porque veremos qué esta fallando.
mvn docker:stop nos permite parar los docker arrancados. Hay que asegurarse de ejecutar este comando si arrancamos a mano con docker:start o docker:run, o se nos irán quedando dockers arrancados.
Esta sería la parte interesante del log del comando mvn verify
[INFO] --- docker-maven-plugin:0.34.1:build (start) @ docker-rest-application --- [INFO] Reading assembly descriptor: D:\PROYECTOS\chuidiang-ejemplos\DOCKER\docker-maven-examples\docker-deploy\docker-rest-application\src\main\docker\rest-application\rest-application-assembly.xml [INFO] Copying files to D:\PROYECTOS\chuidiang-ejemplos\DOCKER\docker-maven-examples\docker-deploy\docker-rest-application\target\docker\chuidiang\rest-java-application\1.0-SNAPSHOT\build\rest-application [INFO] Building tar: D:\PROYECTOS\chuidiang-ejemplos\DOCKER\docker-maven-examples\docker-deploy\docker-rest-application\target\docker\chuidiang\rest-java-application\1.0-SNAPSHOT\tmp\docker-build.tar [INFO] DOCKER> [chuidiang/rest-java-application:1.0-SNAPSHOT] "rest-java-application": Created docker-build.tar in 16 seconds [INFO] DOCKER> [chuidiang/rest-java-application:1.0-SNAPSHOT] "rest-java-application": Built image sha256:16e20 [INFO] Building tar: D:\PROYECTOS\chuidiang-ejemplos\DOCKER\docker-maven-examples\docker-deploy\docker-rest-application\target\docker\chuidiang\database\1.0-SNAPSHOT\tmp\docker-build.tar [INFO] DOCKER> [chuidiang/database:1.0-SNAPSHOT] "database": Created docker-build.tar in 48 milliseconds [INFO] DOCKER> [chuidiang/database:1.0-SNAPSHOT] "database": Built image sha256:cf81b [INFO] [INFO] --- docker-maven-plugin:0.34.1:start (start) @ docker-rest-application --- [INFO] DOCKER> [chuidiang/database:1.0-SNAPSHOT] "database": Start container 32c0405226ce [INFO] DOCKER> Pattern 'database system is ready to accept connections' matched for container 32c0405226ce [INFO] DOCKER> [chuidiang/database:1.0-SNAPSHOT] "database": Waited on log out 'database system is ready to accept connections' 2022 ms [INFO] DOCKER> [chuidiang/rest-java-application:1.0-SNAPSHOT] "rest-java-application": Start container 9bab69ec7071 [INFO] DOCKER> [chuidiang/rest-java-application:1.0-SNAPSHOT] "rest-java-application": Waiting on url http://localhost:8080/ with method HEAD for status 200..399. [INFO] DOCKER> [chuidiang/rest-java-application:1.0-SNAPSHOT] "rest-java-application": Waited on url http://localhost:8080/ 8713 ms [INFO] [INFO] --- maven-failsafe-plugin:3.0.0-M5:integration-test (default) @ docker-rest-application --- [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running com.chuidiang.examples.RestIT 12:49:23.591 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET http://localhost:8080/accounts 12:49:23.617 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[application/json, application/*+json] 12:49:23.953 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK 12:49:23.967 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.util.Map<?, ?>[]] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.264 s - in com.chuidiang.examples.RestIT [INFO] [INFO] Results: [INFO] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] [INFO] --- docker-maven-plugin:0.34.1:stop (stop) @ docker-rest-application --- [INFO] DOCKER> [chuidiang/rest-java-application:1.0-SNAPSHOT] "rest-java-application": Stop and removed container 9bab69ec7071 after 0 ms [INFO] DOCKER> [chuidiang/database:1.0-SNAPSHOT] "database": Stop and removed container 32c0405226ce after 0 ms [INFO] [INFO] --- maven-failsafe-plugin:3.0.0-M5:verify (default) @ docker-rest-application --- [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 50.391 s [INFO] Finished at: 2021-01-31T12:49:34+01:00 [INFO] ------------------------------------------------------------------------
Vemos primero la creación de los dos docker "building ..."
Luego la parte de ejecución (start) y vemos que espera el patrón o la url (waiting) para ver cuando el docker arranca y el momento en que la condición se cumple (waited).
Y una vez arrancado todo, la parte de test de integración con failsafe y los resultados.
Finalmente, la parada de los docker (stop and removed)