Introducción
Cuando estamos desarrollando algún proyecto es muy común que necesitemos una dependencia externa al propio proyecto que estamos realizando como pueda ser una base de datos, un sistema de mensajería, etc. Nuestra aplicación está pensada para que sea desplegada en un entorno que no sea la máquina donde estamos programando para que usuarios finales puedan usarla. Podría ser “staging” y que sea entonces para usuarios que se dedicaran a probarla. También podría ser “production” y que sea entonces para usuarios finales, que probablemente paguen por el producto que desarrollamos. Sin embargo, no debemos olvidar que nosotros, como desarrolladores, también somos usuarios de alguna forma cuando se trata del entorno “local”.
En este post mi idea es exponer como a partir de la versión 3.1 de Spring Boot podemos simplificar la forma en la que ejecutamos nuestro proyecto en local.
Caso típico
En mi paso por distintos proyectos he visto que lo más común cuando se trata de ejecutar la aplicación en el entorno local sea tener un archivo docker-compose.yml el cual tiene la definición de como se debe levantar nuestra aplicación y el resto de servicios colaboradores, como una base de datos, en contenedores sobre nuestro sistema. Veamos un ejemplo:
docker-compose.yml
version: '3.8'
services:
db:
image: postgres:latest
environment:
POSTGRES_DB: songs
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
app:
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "8080:8080"
depends_on:
- db
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/songs
En el archivo se define una base de datos Postgres además de nuestra aplicación Spring Boot. En el Dockerfile que crea la imagen en la que se basa la definición del contenedor de la aplicación se copia el Jar generado en el contenedor y se ejecuta:
Dockerfile
FROM openjdk:21-jdk-slim
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
Este planteamiento es perfectamente válido, pero podemos optimizarlo porque lo más probable es que ya estemos usando Testcontainers para levantar los servicios externos que necesitan nuestros test de integración, y nos vamos a valer de esa configuración para mejorar nuestra experiencia. Veamos primero como podría ser una configuración del Testcontainers:
@TestConfiguration
class PostgresTestContainerConfiguration {
companion object {
private val container: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:latest").apply() {
start()
}
@DynamicPropertySource
@JvmStatic
private fun configureDatasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", container::getJdbcUrl)
registry.add("spring.datasource.username", container::getUsername)
registry.add("spring.datasource.password", container::getPassword)
}
}
}
Tenemos una clase que se encarga de crear un contenedor Postgres y sobreescribir en el contexto de Spring la configuración relacionada con la base de datos, como la URL.
Ya hemos visto el ejemplo más común que te puedes encontrar. Vamos a ver ahora como exprimir lo que nos ofrece Spring Boot a partir de su versión 3.1 para mejorar toda esta configuración.
Autoconfiguración de la conexión
En el último ejemplo del apartado anterior hemos visto que las propiedades de conexión de la base de datos se definían de manera manual sobreescribiendo el contexto de Spring. Veamos como podemos hacer lo mismo pero delegando ese trabajo al framework:
PostgresTestContainerConfiguration.kt (Antes)
@TestConfiguration
class PostgresTestContainerConfiguration {
companion object {
private val container: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:latest").apply() {
start()
}
@DynamicPropertySource
@JvmStatic
private fun configureDatasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", container::getJdbcUrl)
registry.add("spring.datasource.username", container::getUsername)
registry.add("spring.datasource.password", container::getPassword)
}
}
}
PostgresTestContainerConfiguration.kt (Después)
@TestConfiguration(proxyBeanMethods = false)
class PostgresTestContainerConfiguration {
@Bean
@ServiceConnection
fun postgresContainer() = PostgreSQLContainer("postgres:13")
}
Usando la combinación @Bean junto con @ServiceConnection, Spring es capaz de autoinyectar las configuraciones de la base de datos en el contexto sin necesidad de que lo hagamos nosotros.
Ejecutando aplicación en local usando Testcontainers
Nos podemos quitar la responsabilidad de mantener los archivos de Docker que mencionamos anteriormente y usar la misma configuración que tenemos para los test que usan Testcontainers. Solo tenemos que crear un método main en la carpeta de test, el cual usaremos para ejecutar nuestra aplicación desde el IDE de preferencia (en mi caso es IntelliJ):
LocalApp.kt
fun main(args: Array<String>) {
fromApplication<TestcontainersLocalDevelopmentApplication>()
.with(PostgresTestContainerConfiguration::class.java)
.run(*args)
}
Lo que hacemos es usar el método fromApplication de Spring Boot para elegir qué clase es la que contiene el main del proyecto, apuntamos a la clase que está contenida en el código productivo, y además añadimos que tiene que cargar la configuración del contenedor que hemos visto anteriormente para que así levante los contenedores necesarios para los servicios externos de los que depende nuestra aplicación.
Casos límite
Contenedor genérico
Testcontainers tiene bastantes módulos que simplifican la integración de contenedores para nuestros tests. En el código de antes vimos como levantamos un contenedor de PostgreSQL aprovechando la pre configuración que nos hace al ser un módulo de Testcontainers que podemos importar en nuestro proyecto.
Dicho esto, tampoco te será tan raro encontrarte en una situación en la que Testcontainers oficialmente no tenga una integración para una tecnología que quieres levantar en un contenedor para tus test. Sin embargo, aún es posible que saquemos provecho de usar Testcontainers para lanzar la aplicación en local.
Vamos a crear la configuración del contenedor:
UnofficialModuleTestContainerConfiguration.kt
@TestConfiguration(proxyBeanMethods = false)
class UnofficialModuleTestContainerConfiguration {
@Bean
fun genericContainer(dynamicPropertyRegistry: DynamicPropertyRegistry): GenericContainer<*> {
dynamicPropertyRegistry.add("some.property") { "some-value" }
return GenericContainer("image-without-testcontainers-support")
}
}
Como vemos, la diferencia con el otro contenedor que habíamos configurado es que este no tiene la anotación @ServiceConnection, la cual establecía automáticamente las propiedades de conexión del contenedor en el contexto de Spring. Como no existe módulo oficial en Testcontainers para la tecnología que queremos levantar el contendor, tenemos que usar DynamicPropertyRegistry para establecer nosotros manualmente esas propiedades en el contexto.
Conclusión
Nos ha quedado una forma muy sencilla de usar nuestra aplicación en local sin mayor necesidad que darle al botón de play en el IDE. Para revisar el código de ejemplo que usé, por aquí tienes el repositorio que hice para guiar el blog:
https://github.com/raulpadilladelgado/testcontainers-for-local-development
En la rama docker-compose-for-the-local-environment tienes la forma tradicional de levantar la aplicación apoyándonos en archivos Docker. En la otra rama testcontainers-for-the-local-environment tienes las mejoras de las que hemos hablado.
Eso es todo, espero que este post te haya servido. ¡Muchas gracias por leer!