Aún me acuerdo de cómo hace muuuuchos años, en "la edad de los hierros", configurar un entorno local para desarrollar partes de plataformas complejas era un drama, y cómo se acababan configurando hosts in-house para replicar esa infraestructura externa y poder trabajar en equipo sobre la misma base.
Pero, a la hora de ejecutar tests en esas máquinas, a la que se necesitara algún cambio de versión mientras se seguía desarrollando sobre lo antiguo, o simplemente cambiar el tipo de dato de una columna de BBDD, drama otra vez, o replicarse esa máquina en local "temporalmente".
En este post os daré una pincelada a cómo podemos integrar TestContainers en el flujo de ejecución de tests con Junit5 para un servicio desarrollado con Spring Boot, dejando que sea Spring quien haga el trabajo por nosotros, aprovechándonos de las características del ciclo de vida del ApplicationContext durante la ejecución de los tests.
Para ello, integraré testcontainers
con distintas soluciones sobre un proyecto demo base, y os expondré corner cases en los que esas soluciones pueden no ser operativas, teniendo en cuenta que para los tests, y entornos de CI, por lo general es [una mala práctica fijar puertos.], de modo que este post se focalizará en la ejecución de tests con infraestructuras externas levantadas en local en puertos dinámicos.
(https://www.testcontainers.org/features/networking/) From the host's perspective Testcontainers actually exposes this on a random free port. This is by design, to avoid port collisions that may arise with locally running software or in between parallel test runs.
Para darle un poco de gracia al uso de contenedores tanto para el desarrollo local como para los tests, y entender por qué usándolos podemos lograr reducir al máximo el riesgo de subir código a un entorno real cuando los tests pasan y "en local funciona", os propongo un servicio que no expondrá una API, sino que:
- debe escuchar mensajes de texto emitidos por otro servicio a una cola de RabbitMQ
- debe grabar esos mensajes a una base de datos PostgreSQL
A priori, un servicio muy sencillo, pero
- cómo validaríamos que realmente funciona?
- cómo automatizaríamos esas validaciones?
Como base, usaré este repositorio que implementa el servicio:
😑 Rama base, sin TestContainers, pero podremos probarlo haciendo hecho un
docker-compose up
previamente.
También, como referencia a las pruebas que comentaré por puntos al final del post, os dejo estas PR para poder ver cómo cambiaría el código base, o descargaros la rama para jugar.
😕 TestContainers integrados con JUnit
🚀 TestContainers singleton gestionados manualmente
😍 TestContainers gestionados por Spring
Pero antes de empezar, una breve intro a Docker Compose y TestContainers
Docker Compose
Hoy en día, Docker facilita y estandariza la forma en cómo los servicios de nuestras plataformas se despliegan en la nube, y Docker Compose nos facilita la vida en el entorno local, habilitándonos esos servicios de infraestructuras (bases de datos, sistemas de mensajería, ...) externas de un modo fiable.
Seguramente habréis visto en la raíz de gran cantidad de repositorios un fichero docker-compose.yml
, por lo general muy fáciles de entender a primera vista, como por ejemplo:
version: '3.5'
services:
# base de datos PostgreSQL
message-store-db:
# https://hub.docker.com/_/postgres
image: postgres:13.0-alpine
# mapeo de puertos (puerto_accesible_desde_localhost:puerto_del_servicio_en_el_contenedor)
ports:
- "5432:5432"
# configuración de la imagen de docker
environment:
POSTGRES_USER: "demo"
POSTGRES_PASSWORD: "p4ssw0rd"
POSTGRES_DB: "messagestore"
# inicialización de la base de datos
volumes:
- ./.init/message-store-db/init.sql:/docker-entrypoint-initdb.d/init.sql
Con instrucciones para levantar el repositorio en local, y que con un simple comando:
docker-compose up
Tenemos la infraestructura externa, en este caso una base de datos, lista en nuestra máquina para poder operar con ella desde nuestro servicio levantado en local, o incluso en este ejemplo, conectándonos directamente a PostgreSQL con algún visor de bases de datos.
Esto nos permite validar que nuestro proyecto, que usará un driver específico para la base de datos, ejecutando sentencias o configurando opciones que quizá no están disponibles para todas las versiones de ese sistema de base de datos, funciona.
Y como podremos levantar en local la misma versión de base de datos que tengamos en entorno real, el riesgo de que no funcione ahí se reduce a mínimos.
Esto no pasaría implementando los accesos a base de datos de los tests con por ejemplo, una base de datos en memoria como H2, que por defecto no soporta PL/SQL.
Pero, si desarrollamos en local con esos servicios, mirando que nuestro servicio opera correctamente con ellos, ¿por qué no automatizar los tests de integración usando el mismo sistema y así poder ejecutar los tests tanto en nuestra máquina como en nuestro sistema de CI?
TestContainers
TestContainers es una librería Java diseñada para controlar las instancias de servicios dockerizados, enfocada a ser integrada con JUnit/Spock/... para nuestros tests de integración.
Como ejemplo de uso, la misma PostgreSQL creada con la API de TestContainers quedaría así:
PostgreSQLContainer messageStoreDbContainer =
new PostgreSQLContainer<>("postgres:13.0-alpine")
.withDatabaseName("message-store-db")
.withUsername("demo")
.withPassword("p4ssw0rd")
.withInitScript("/.init/message-store-db/init.sql");
messageStoreDbContainer.setPortBindings(Arrays.asList("5432:5432"));
// podemos arrancar el servicio con
messageStoreDbContainer.start();
// y pararlo con
messageStoreDbContainer.stop();
Otro componente que nos ofrece, para permitirnos simular los tests con exactamente los mismos servicios que levantaríamos con el docker-compose.yml
, es DockerComposeContainer
Un ejemplo de uso:
WaitStrategy postgresWaitStrategy = new WaitAllStrategy(WITH_INDIVIDUAL_TIMEOUTS_ONLY)
.withStrategy(Wait.forListeningPort())
.withStrategy(Wait.forLogMessage(".*database system is ready to accept connections.*", 1));
DockerComposeContainer dockerComposeContainer =
new DockerComposeContainer(new File("docker-compose.yml"))
.withLocalCompose(true)
.waitingFor("message-store-db", postgresWaitStrategy);
dockerComposeContainer.start();
Veréis que aquí se usa una funcionaliad opcional de la API de DockerComposeContainer para poder definir cómo debe esperarse el componente para decir que está ready hasta que el servicio "message-store-db" del "docker-compose.yml" esté ready.
Por defecto, se esperaría 60 segundos a que el servicio tuviera el puerto expuesto y listo para aceptar conexiones.
Ahora le estamos indicando que a parte de esperar al puerto, se espere también a que PostgreSQL, en su proceso de inicialización, haya sacado esa traza de log indicando que está completamente ready
Para el caso propuesto, dado que los tests deben validar la integración con los servicios definidos en el docker-composer
, usaré DockerComposeContainer
.
Y en realidad, para la mayoría de casos es el único que vamos a necesitar.
Así que al lío :)
¿Cómo lo integramos en nuestros tests?
Nos tenemos que preguntar: ¿Los servicios de infraestructura externa deben cambiar para distintos escenarios de test, independientemente del contexto de Spring?
O por lo contrario, ¿Los servicios de infraestructura externa deben tener el mismo ciclo de vida que el contexto de Spring?
Para ejemplificar las pruebas de integración de TestContainers en JUnit y SpringBoot, encontraréis este test sencillo en la rama master del proyecto demo.
@SpringBootTest
@Sql("/fixtures/message-store-db/clean.sql")
class ApplicationTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private MessageMapper messageMapper;
@Value("${mq-server.routing.exchange}")
private String demoExchange;
@Value("${mq-server.routing.message.queue.dispatched}")
private String messageDispatchedQueue;
@Test
void shouldSaveEmittedMessage() {
String givenText = "hello world";
rabbitTemplate.convertAndSend(demoExchange, messageDispatchedQueue, givenText);
await().atMost(Duration.ONE_SECOND)
.alias(format("message [%s] has been saved", givenText))
.until(() -> messageMapper.selectOneByText(givenText).isPresent());
}
}
Nota: para los que no la conozcáis,
await
es un operador de awaitility, muy útil para validación de resultados en procesos asíncronos.
Ciclo de vida controlado por JUnit
Empecemos con la chicha :)
Si miramos la documentación para integrar TestContainers con JUnit, la integración básica es muy sencilla: mediante anotaciones de la propia librería, se habilita la gestión de arranque y parada de los servicios dockerizados, y éstos pueden exponer los puertos que ha podido coger por estar libres.
⚠️ Pero ojo, nosotros necesitamos ciertas propiedades en el contexto de Spring, o dicho de otra manera, ¿cómo asignaremos los puertos designados a nuestras conexiones de base de datos y MQ?
En este caso, usaremos la capacidad de Spring de recoger propiedades de System
, pudiendo configurar nuestros tests así:
@SpringBootTest
@Testcontainers
abstract class AbstractIntegrationTest {
private static final String MESSAGE_STORE_DB_SERVICE = "message-store-db";
private static final Integer MESSAGE_STORE_DB_PORT = 5432;
private static final String SPRING_MESSAGE_STORE_DB_PORT = "message-store-db.port";
private static final String MQ_SERVER_SERVICE = "mq-server";
private static final Integer MQ_SERVER_PORT = 5672;
private static final String SPRING_MQ_SERVER_PORT = "mq-server.port";
@Container
private static DockerComposeContainer dockerServices =
new DockerComposeContainer<>(new File("docker-compose.yml"))
.withLocalCompose(true)
.withExposedService(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)
.withExposedService(MQ_SERVER_SERVICE, MQ_SERVER_PORT);
@BeforeAll
public static void beforeAll() {
setProperty(SPRING_MESSAGE_STORE_DB_PORT, valueOf(dockerServices.getServicePort(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)));
setProperty(SPRING_MQ_SERVER_PORT, valueOf(dockerServices.getServicePort(MQ_SERVER_SERVICE, MQ_SERVER_PORT)));
}
}
En este caso, sólo que nuestro ApplicationTest
extienda esta abstracción, al ejecutarse, dispondrá de todo lo necesario para funcionar porque:
- Antes de la ejecución de los tests, TestContainers mediante los eventos de JUnit habrá levantado el DockerComposeContainer.
- Después, JUnit procesará el
@BeforeAll
, donde hemos seteado las propiedades necesarias para el contexto de Spring, comomessage-store-db.port
, con el valor que dispondrá cada servicio ya esperando conexiones.
Y el post terminaría aquí, si no fuera porque:
¿Funcionaría este approach si AbstractIntegrationTest
fuera extendida por más de una implementación?
Correcto, en lugar de usar el ejemplo de TestContainers lo he puesto en una abstracción, porque como deduciréis, la respuesta es NO.
Si ejecutáramos sólo 1 clase de tests extendiendo AbstractIntegrationTest
, ya veríamos una cosa que nos debería chirriar en los logs:
El test pasa, y al terminar, TestContainers intercepta el final de ejecución de los tests de la clase mediante JUnit y detiene el contenedor, antes de que se cierre el contexto de Spring.
¿Por qué y por qué puede ser un problema?
Eso pasa porque Spring, va a mantener el mismo ApplicationContext
para la ejecución de todos los tests de todas las clases que no estén marcadas con @DirtiesContext
, para evitar el coste de levantar el contexto para cada clase anotada con @SpringBootTest
.
Y eso puede ser un problema si nos interesa hacer grupos de tests (por ejemplo, una clase de test para cada @RestController
si estamos desarrollando APIs), ya que
- los servicios dockerizados, al volverse a levantar, van a tener otro puerto libre designado para conectarnos a ellos.
- aunque se ejecute el
@BeforeAll
asignando nuevos puertos aSystem
, el contexto de Spring no los va a usar dado que no se va a refrescar.
Y como seguramente no nos va a interesar que los servicios dockerizados se apaguen y levanten de nuevo para cada conjunto de tests, por el coste en tiempo, esto nos lleva a la gestión manual de los contenedores, ya que There is no special support for this use case provided by the Testcontainers extension
tal como indica la sección Singleton containers de la documentación.
Pero no preocuparse, trabajamos con Spring por lo que los singletons no nos dan miedo, ¿no? ;)
TestContainers Singleton gestionados manualmente, con ciclo de vida controlado por JUnit
JUnit5 tiene una característica que nos permite anidar en una única clase, mediante @Nested
, conjuntos de clases (test cases) que a su vez definen los tests a ejecutar.
Así que podemos:
- Tener una clase única, anotada con
@SpringBootTest
para levantar el contexto de Spring, en la que el ciclo de de vida de JUnit (@BeforeAll
-> ejecución de los tests de cada test case ->@AfterAll
) va a ser el mismo que el de Spring. - Definir test cases independientes con su grupo de
@Test
a ejecutar. - Anidar a la clase única estos test cases con
@Nested
Algo tal que así:
public class IntegrationTest {
private static final DockerizedInfrastructure DOCKERIZED_INFRASTRUCTURE = new DockerizedInfrastructure();
@BeforeAll
static void dockerComposeUp() {
DOCKERIZED_INFRASTRUCTURE.start();
}
@AfterAll
static void dockerComposeDown() {
DOCKERIZED_INFRASTRUCTURE.stop();
}
@Nested
class Application extends ApplicationTestCase {
}
}
Sólo necesitaremos que en este caso, ApplicationTest
de la rama master, sea abstracta (y para este ejemplo, renombrada a ApplicationTestCase
que define mejor su intención).
A parte, para hacer legible el ejemplo y evitar una mezcla de responsabilidades entre la coordinación de los tests que hará IntegrationTest
y la coordinación de los servicios dockerizados, aquí sólo queda el singleton DOCKERIZED_INFRASTRUCTURE
para poderlo gestionar al inicio y final de la ejecución de test cases, pero su implementación se ha separado a:
DockerizedInfrastructure.java
public class DockerizedInfrastructure {
private static final String MESSAGE_STORE_DB_SERVICE = "message-store-db";
private static final Integer MESSAGE_STORE_DB_PORT = 5432;
private static final String SPRING_MESSAGE_STORE_DB_PORT = "message-store-db.port";
private static final String MQ_SERVER_SERVICE = "mq-server";
private static final Integer MQ_SERVER_PORT = 5672;
private static final String SPRING_MQ_SERVER_PORT = "mq-server.port";
private final DockerComposeContainer dockerServices;
public DockerizedInfrastructure() {
// Inicializamos los servicios de docker-compose
dockerServices = new DockerComposeContainer<>(new File("docker-compose.yml"))
.withLocalCompose(true)
.withExposedService(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)
.withExposedService(MQ_SERVER_SERVICE, MQ_SERVER_PORT);
}
public void start() {
// Arrancamos los servicios dockerizados
dockerServices.start();
// Seteamos System properties para propiedades del contexto de Spring
setProperty(SPRING_MESSAGE_STORE_DB_PORT, valueOf(dockerServices.getServicePort(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)));
setProperty(SPRING_MQ_SERVER_PORT, valueOf(dockerServices.getServicePort(MQ_SERVER_SERVICE, MQ_SERVER_PORT)));
}
public void stop() {
// Apagamos los servicios dockerizados
dockerServices.stop();
}
}
Y llegados a este punto, que ahora sí funcionará tal como esperamos a medida que el proyecto vaya creciendo y necesitemos agrupar los tests en distintos test cases, otra vez daría el post por cerrado, si no fuera que ¿realmente es necesario que gestionemos manualmente un singleton y su ciclo de vida con JUnit para coordinarlo con el ciclo de vida del ApplicationContext de Spring?
Y aunque no suponga un problema hacerlo, otra vez la respuesta es NO, dado que Spring, como ya comenté, va a mantener el ApplicationContext
durante la ejecución de los tests y dispone de una serie de características que nos permitirán jugar con la gestión de los servicios dockerizados, las propiedades para el ApplicationContext (en este caso los puertos de los servicios),...
Además, con esta gestión manual, nos podríamos encontrar con los siguientes inconvenientes:
Añadir un nuevo test case requiere modificación en la base
IntegrationTest
.Ya sea por reagrupación de test cases o para configuraciones distintas por grupos de test cases, no podríamos tener 2
IntegrationTest
distintos anidando distintos test cases, sin más. Por ejemplo:
Supongamos que replicamos el ApplicationTestCase
varias veces y los anidamos en también una réplica de IntegrationTest
:
Al ser réplicas, en los IntegrationTestN
, @BeforeAll
y @AfterAll
reinician los contenedores pero de nuevo, nos encontramos que el ApplicationContext
para los ApplicationTestCaseN...
es el mismo y por tanto, sólo iniciará la conexión a las infraestructuras la primera vez que además, JUnit no va a ejecutar en un mismo orden, por lo que el segundo bloque de tests ejecutado va a fallar:
Para resolver este problema, tendríamos que limpiar el contexto de Spring para cada ApplicationTestN
, de manera que cada test case creara un nuevo ApplicationContext
con configuración fresca de los puertos activados, marcándolos todos ellos con dirties context:
@SpringBootTest
@DirtiesContext
Esto de nuevo, aunque lógicamente es un corner case forzado para validar posibles flaquezas de la solución, nos lleva a pensar que realmente, lo ideal sería que la gestión del ciclo de vida de los servicios dockerizados fueran gestionados directamente con Spring.
Así, que como guindilla del pastel, continuamos :)
TestContainers sólo gestionados por Spring
Llegados a este punto, sabemos que:
- Los test de integración marcados con
@SpringBootTest
comparten elApplicationContext
a no ser que sean marcados con@DirtiesContext
-
DockerizedInfrastructure
debería ser tratado como un singleton (@Component
) dentro del contexto de Spring para que en todo momento, elApplicationContext
pueda accederlo o regenerarlo en dirties. -
DockerizedInfrastructure
debe reconfigurar las propiedades de conexión de la infraestructura.
⚠️ pero, si hacemos un @Component
, que Spring inicializa, ya no podemos setear las propiedades en System
, dado que la lectura de beans (y propiedades) es anterior a la instanciación.
💡 pero por otro lado es Spring, y siempre tiene una solución a cómo gestionar estos casos: podemos usar el ConfigurableApplicationContext
para refrescar el contexto con las propiedades iniciales tal como nos va a interesar.
En definitiva, para que sea gestionado completamente por Spring, podríamos modificar DockerizedInfrastructure.java
así:
@Component
public class DockerizedInfrastructure {
private static final String MESSAGE_STORE_DB_SERVICE = "message-store-db";
private static final Integer MESSAGE_STORE_DB_PORT = 5432;
private static final String SPRING_MESSAGE_STORE_DB_PORT = "message-store-db.port";
private static final String MQ_SERVER_SERVICE = "mq-server";
private static final Integer MQ_SERVER_PORT = 5672;
private static final String SPRING_MQ_SERVER_PORT = "mq-server.port";
private final DockerComposeContainer dockerServices;
DockerizedInfrastructure(ConfigurableApplicationContext configurableApplicationContext) {
// Inicializamos los servicios de docker-compose
dockerServices = new DockerComposeContainer<>(new File("docker-compose.yml"))
.withLocalCompose(true)
.withExposedService(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)
.withExposedService(MQ_SERVER_SERVICE, MQ_SERVER_PORT);
// Arrancamos los servicios dockerizados
dockerServices.start();
// Refrescamos el contexto de Spring con los puertos designados
TestPropertyValues.of(
format("%s=%s",
SPRING_MESSAGE_STORE_DB_PORT,
dockerServices.getServicePort(MESSAGE_STORE_DB_SERVICE, MESSAGE_STORE_DB_PORT)),
format("%s=%s",
SPRING_MQ_SERVER_PORT,
dockerServices.getServicePort(MQ_SERVER_SERVICE, MQ_SERVER_PORT))
).applyTo(configurableApplicationContext.getEnvironment());
}
@PreDestroy
void preDestroy() {
// Apagamos los servicios dockerizados
dockerServices.stop();
}
}
Fijaros que aquí, DockerizedInfrastructure
no tiene ni el constructor ni ningún método público, dado que ahora no es necesario que ninguna clase de test acceda a ella para manipular su estado. Sí lo es la declaración de la clase, luego veremos por qué :)
Con el @PreDestroy
nos aseguramos de apagar los servicios dockerizados cuando el ApplicationContext
emita la señal de que está siendo destruido, que será o cuando hayan terminado los tests, o cuando un test esté marcado con @DirtiesContext
, antes de la recreación del contexto.
Ésta sería una manera muy elegante de integrar esta DockerizedInfrastructure
dado que no haría falta modificar nada en nuestra configuración de los tests, pero ¿qué pasaría si algún componente del servicio necesitara acceder a la base de datos durante la creación del contexto de Spring?
Para forzar el caso, os pongo este ejemplo:
@Component
public class InventBreaker {
private final MessageMapper messageMapper;
public InventBreaker(MessageMapper messageMapper) {
this.messageMapper = messageMapper;
}
@PostConstruct
void tryToBreakTheTestContext() {
messageMapper.selectOneByText("applicationContext lanzará excepción si no hay conectividad a la BBDD");
}
}
Supongamos que InventBreaker
, por lo que sea, necesita inicializarse con un valor de la BBDD, y si no hacemos nada, va a pasar esto:
Spring, la única instrucción a la que va a hacer caso para que InventBreaker
se espere a que DockerizedInfrastructure
haya hecho la magia es con @DependsOn("dockerizedInfrastructure")
, pero wait! Si InventBreaker
forma parte del código del servicio en lugar del código de test, no podemos hacer esto.
💡 pero de nuevo, Spring ya contempla casi todos los corner cases que nos podamos imaginar, y nos va a permitir sincronizar la creación del contexto requerido para test con el contexto requerido para la aplicación modificando sólo una línea en la declaración de @SpringBootTest
, dejando nuestra abstracción de tests de integración así:
@SpringBootTest(classes = {DockerizedInfrastructure.class, Application.class})
Y listo! Ahora todo se inicializará y se apagará según el orden esperado.
Además, podríamos abstraer esta definición para ser usada en cualquier otra clase de test así:
AbstractIntegrationTest.java
@SpringBootTest(classes = {DockerizedInfrastructure.class, Application.class})
abstract class AbstractIntegrationTest {
}
Y todas las clases de test con un extends AbstractIntegrationTest
dispondrán de los servicios dockerizados para operar con ellos, sin necesidad de que ninguno esté marcado con @DirtiesContext
, aunque marcándolo también funcionaría (pagando el coste de reiniciar el contexto, junto con los servicios dockerizados).
🚀
Conclusión
TestContainers es una herramienta muy potente para evitar que perdamos tiempo simulando / mockeando lo que debería hacer nuestra capa de integración con infraestructura a la hora de desarrollar los tests y es bueno aprovecharse de ello!
Aún así, no siempre hay una manera de implementar las cosas y es mejor adaptarse a la que mejor nos convenga en cada caso con conocimiento de las herramientas que estamos usando, y cuando usamos un framework como Spring, éste dispone para nosotros muchas facilidades para agilizar los desarrollos, tanto de la aplicación, como en este caso de los tests.
Y llegados a este punto, no queda mucho más que decir por mi parte, espero que os haya gustado este viaje a las entrañas de Spring en el contexto de testing, así que si es el caso o si gestionáis el ciclo de vida de los contenedores de test de alguna otra forma y queréis compartirla dejad un comentario, que me gustará leeros.
Y ahora sí, fin :)