Por Raul Padilla
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 dedicarán 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 cómo a partir de la versión 3.1 de Spring Boot podemos simplificar la forma en la que ejecutamos nuestro proyecto en local.
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 es tener un archivo docker-compose.yml el cual tiene la definición de cómo 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 sobrescribir 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 cómo exprimir lo que nos ofrece Spring Boot a partir de su versión 3.1 para mejorar toda esta configuració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 sobrescribiendo el contexto de Spring. Veamos cómo 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.
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.
Testcontainers tiene bastantes módulos que simplifican la integración de contenedores para nuestros tests. En el código de antes vimos cómo levantamos un contenedor de PostgreSQL, aprovechando la preconfiguració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 un módulo oficial en Testcontainers para la tecnología que queremos levantar el contenedor, tenemos que usar DynamicPropertyRegistry para establecer nosotros manualmente esas propiedades en el contexto.
Nos ha quedado una forma muy sencilla de usar nuestra aplicación en local sin más 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! .
¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.
Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?
Pero espera 🖐 que tenemos un conflicto interno. A nosotros las newsletter nos parecen 💩👎👹 Por eso hemos creado la LEAN LISTA, la primera lista zen, disfrutona y que suena a rock y reggaeton del sector de la programación. Todos hemos recibido newsletters por encima de nuestras posibilidades 😅 por eso este es el compromiso de la Lean Lista