Nuestro compañero Alex Castells (@alextremp) en su post Integrando Testcontainers en el contexto de Spring en nuestros tests nos explicaba diferentes maneras para usar Testcontainers en un test de Spring Boot.
Con Daniel Dios (@danieldios) queríamos llevarlo un paso más allá y usar Testcontainers para testear una Spring Boot Application una vez dockerizada.
Por eso hicimos un live coding que puedes ver en 💜Twitch o ❤️Youtube
Salvo algunos sustos 😰 ¡¡conseguimos nuestro objetivo 🤩!!
Puedes consultar el código en AdevintaSpain/spring-boot-docker y te resumimos los pasos a continuación:
1. Crear una Spring Boot Application con un simple RestController
Usamos Spring Initialzr para crear una Spring Boot Application y empezamos con un test del controlador que queremos implementar:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ApplicationTests {
@LocalServerPort
private var port: Int = 0
@Test
internal fun `should say hello`() {
val webClient = WebClient.builder()
.baseUrl("http://localhost:$port")
.build()
val actual = webClient
.get()
.uri("/hello")
.exchangeToMono { it.bodyToMono(String::class.java) }
.block()
assertThat(actual).isEqualTo("hello Dani&Roger!")
}
}
2. Dockerizar la Spring Boot App
Existen varias alternativas que puedes consultar en Spring Boot with Docker y en más detalle en Topical Guide on Docker.
Podríamos simplemente crear un Dockerfile
y hacer docker build
pero optamos por usar la tarea bootBuildImage
integrada en la Spring Boot gradle plugin.
Por lo que entendemos esta tarea ya construye la docker image con el orden correcto de capas para asegurar que los elementos que cambian menos frecuentemente se incluyan en las primeras capas, minimizando así el tamaño y el tiempo de build.
./gradlew bootBuildImage
3. Testear la Spring Boot App dockerizada
Usamos Testcontainers con un generic container y las anotaciones de JUnit5:
class KGenericContainer(imageName: String) : GenericContainer<KGenericContainer>(imageName)
@Testcontainers
class ContainerTest {
companion object {
private const val appPort = 8080
@Container
var app: KGenericContainer = KGenericContainer("spring-boot-docker:0.0.1-SNAPSHOT")
.withExposedPorts(appPort)
}
@Test
internal fun `should say hello`() {
// ...
}
4. Ejecutar bootBuildImage antes del test
Para que siempre testeemos la última versión, necesitamos generar la docker image antes de ejecutar el test, lo conseguimos con el dependsOn
de gradle:
tasks.withType<Test> {
useJUnitPlatform()
dependsOn("bootBuildImage")
}
Bajo test
deberíamos tener únicamente tests unitarios o máximo slice tests de Spring. Para nada tener tests que dependan de docker. Una manera de crear un test source root adicional para nuestros tests con docker es usar la gradle plugin org.unbroken-dome.test-sets:
plugins {
id("org.unbroken-dome.test-sets") version "4.0.0"
}
testSets {
create("container-test")
}
tasks["container-test"].dependsOn("bootBuildImage")
Para complicar un poco nuestra Spring Boot App añadimos una base de datos postgres y hacemos que nuestro HelloController
ejecute una sencilla query. Como siempre empezamos por el test:
@Testcontainers
class ContainerTest {
companion object {
private const val postgresAlias = "mypostgres"
private const val appPort = 8080
private val network: Network = Network.newNetwork()
@Container
var postgres: KGenericContainer = KGenericContainer("postgres:13")
.withNetwork(network)
.withNetworkAliases(postgresAlias)
.withEnv("POSTGRES_USER", "myuser")
.withEnv("POSTGRES_PASSWORD", "mypassword")
.withEnv("POSTGRES_DB", "mydb")
@Container
var app: KGenericContainer = KGenericContainer(System.getProperty("docker.image"))
.withNetwork(network)
.dependsOn(postgres)
.withEnv("DB_HOST", postgresAlias)
.withExposedPorts(appPort)
}
@Test
internal fun `should say hello`() {
// ...
}
Puntos importantes:
- Ambos containers deben compartir la misma network porque queremos que nuestra app tenga conectividad interna con la base de datos.
- Añadimos un network alias
mypostgres
al container de la base de datos y eldependsOn
en el container de la app para que desde éste último el hostnamemypostgres
se resuelva correctamente. - Necesitamos exponer el puerto
8080
del container de la app porque queremos ejecutar una request desde el test, que estará corriendo fuera de docker. - No necesitamos exponer ningún puerto del container de la base de datos.
- Sobreescribimos la propiedad
db.hostname
que usamos en nuestra Spring Boot App con la variable de entornoDB_HOSTNAME
y el valormypostgres
. En el entorno realdb.hostname
tomará el valor correspondiente a la base de datos real.
6. Convertimos el SpringBootTest original en un WebMvcTest
Queremos testear únicamente el HelloController
, sin que requiera conexión con la base de datos.
7. WebMvcTest no sirve ¡necesitamos WebFluxTest!
Unos momentos de pánico 😨 hasta que nos damos cuenta que estamos usando WebFlux con lo que el slice test que debemos usar es @WebFluxTest
y no @WebMvcTest
🤦♂️ ...
8. WebTestClient y Kotlin ¡madre mía!
Pánico total 😱 no sabemos como implementar el expectBody
con WebTestClient
y Kotlin, al final lo salvamos con este workaround sacado de internet ... 😳:
@Test
internal fun `should say hello`() {
val version = "Dummy 1.0"
doReturn(version).`when`(helloRepository).getVersion()
webClient
.get().uri("/hello")
.exchange()
.expectStatus().is2xxSuccessful
.expectBodyList(String::class.java)
.consumeWith<WebTestClient.ListBodySpec<String>> {
assertThat(it.responseBody?.get(0) ?: "xx").isEqualTo("Hello $version")
}
}
Bueno al menos todos los tests están en verde 💚
Y ya fuera de cámaras, con más tranquilidad ...
10. Así se usa el WebTestClient
Tan fácil como esto, ¿en qué lío nos metimos? 😅
@Test
internal fun `should say hello`() {
val version = "Dummy 1.0"
doReturn(version).`when`(helloRepository).getVersion()
webClient
.get().uri("/hello")
.exchange()
.expectStatus().isOk
.expectBody<String>()
.isEqualTo("Hello $version")
}
11. Versión de la docker image
Para no tener fija la versión de la docker image de la app en el test de container, la pasamos como system property desde gradle:
testSets {
val containerTest = create("container-test")
containerTest.systemProperty("docker.image", "${project.name}:${project.version}")
}
Y la usamos en el test:
@Container
var app: KGenericContainer = KGenericContainer(System.getProperty("docker.image"))
Y eso es todo! Un saludiño de parte de @danieldios & @rogervinas