Testeando una Spring Boot App dockerizada

Roger Viñas Alcon - May 20 '21 - - Dev Community

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!")
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`() {
     // ...
  }
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

5. Test source root adicional

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")
Enter fullscreen mode Exit fullscreen mode

6. Añadimos una base de datos

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`() {
     // ...
  }
Enter fullscreen mode Exit fullscreen mode

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 el dependsOn en el container de la app para que desde éste último el hostname mypostgres 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 entorno DB_HOSTNAME y el valor mypostgres. En el entorno real db.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")
    }
}
Enter fullscreen mode Exit fullscreen mode

9. ¡Tests en verde!

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")
}
Enter fullscreen mode Exit fullscreen mode

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}")
}
Enter fullscreen mode Exit fullscreen mode

Y la usamos en el test:

@Container
var app: KGenericContainer = KGenericContainer(System.getProperty("docker.image"))
Enter fullscreen mode Exit fullscreen mode

Y eso es todo! Un saludiño de parte de @danieldios & @rogervinas

Alt Text

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player