Tests de integración con Spring Boot y Testcontainers

Índice

  1. Introduciendo Testcontainers
  2. Spring Boot y Testcontainers
  3. ¡Vamos al código!
  4. Preparación
  5. Testeando
  6. Siguientes pasos
  7. Ejecutando los tests en CI
  8. Resumen

En el desarrollo de aplicaciones Spring Boot es fundamental asegurar que todo funcione correctamente. Los tests unitarios son esenciales para validar el comportamiento de componentes de manera aislada. Sin embargo, para verificar que el sistema en su conjunto opera como se espera, los tests de integración juegan un papel crucial, ya que permiten comprobar la interacción entre los distintos componentes de la aplicación.

Este tipo de tests validan el correcto funcionamiento de las distintas partes de una aplicación: API, servicios externos, bases de datos, sistemas de mensajería,… Tradicionalmente escribir tests de integración se ha considerado complejo porque el setup necesario para configurar todos esos componentes externos como la base de datos o el sistema de mensajería era complicado. Además, hay que tener en cuenta que trabajamos en equipo por lo que debemos poder replicar ese setup para todos los miembros del mismo. Y por si eso no fuera poco, debemos poder ejecutar esos tests también en nuestro entorno de integración continua (CI).

Introduciendo Testcontainers

Testcontainers es una librería que facilita enormemente la ejecución de contenedores Docker en nuestros test de integración. Permite levantar bases de datos (Postgresql, Mysql,…), servicios de mensajería (RabbitMQ, Kafka,…) y cualquier servicio que podamos ejecutar en un contenedor Docker. Con esto nos aseguramos de que los tests de integración son reales, reproducibles y aislados sin tener que depender de nada más que Docker para conseguirlo.

Spring Boot y Testcontainers

La integración entre Spring Boot y Testcontainers es muy buena y permite que como desarrolladores no nos tengamos que preocupar prácticamente de nada más que de escribir nuestros tests. Veámoslo con un ejemplo.

¡Vamos al código!

Vamos a crear una aplicación usando el [Spring Initializr] con Spring MVC, Spring Data JPA, Flyway, Postgresql y Testcontainers.

Después de descargar el projecto e importarlo en nuestro IDE favorito podemos ver cómo de buena es la integración entre ambos proyectos. En lugar de arrancar la aplicación usando la clase `DemoApplication` de `src/main/java` utilizaremos la clase `TestDemoApplication` de `src/main/test`, cuyo contenido es:

java

public class TestDemoApplication {

  public static void main(String[] args) {

    SpringApplication.from(DemoApplication::main)

        .with(TestcontainersConfiguration.class)

        .run(args);

  }

}

Esta clase delega en la clase principal `DemoApplication` pero además la «aumenta» con la clase `TestcontainersConfiguration`:

java

@TestConfiguration(proxyBeanMethods = false)

class TestcontainersConfiguration {

  @Bean

  @ServiceConnection

  PostgreSQLContainer<?> postgresContainer() {

    return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"));

  }

}

Esta clase es la encargada de levantar un contenedor Postgres y con la anotación `@ServiceConnection` configurar automáticamente Spring Boot para que se conecte a la base de datos sin tener que añadir nada a nuestro archivo de configuración `application.yml`. Así, si arrancamos la aplicación automáticamente se ejecuta Testcontainers, se hará pull del container de `postgresl:latest`, se arrancará y la aplicación se autoconfigurará para conectarse al postgres. Como recomendación, en lugar de usar `latest` deberíamos fijar la versión que estemos utilizando en producción para evitar problemas y sorpresas.

o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)

...

o.t.d.DockerClientProviderStrategy       : Found Docker environment with local Unix socket (unix:///var/run/docker.sock)

org.testcontainers.DockerClientFactory   : Docker host IP address is localhost

org.testcontainers.DockerClientFactory   : Connected to docker: 

  Server Version: 27.3.1

  API Version: 1.47

  Operating System: Linux Mint 22

  Total Memory: 31819 MB

tc.postgres:latest                       : Creating container for image: postgres:latest

tc.testcontainers/ryuk:0.7.0             : Creating container for image: testcontainers/ryuk:0.7.0

tc.testcontainers/ryuk:0.7.0             : Container testcontainers/ryuk:0.7.0 is starting: 510205d6f74fe050d570682993b37476b0620e35f01425d8191b0083e0541d35

tc.testcontainers/ryuk:0.7.0             : Container testcontainers/ryuk:0.7.0 started in PT0.212311121S

tc.postgres:latest                       : Container postgres:latest is starting: a7ed99b5fbbc38454ed9991d0cb231abf69d5128527390e3275d0dd3b921e6f9

tc.postgres:latest                       : Container postgres:latest started in PT1.030499496S

tc.postgres:latest                       : Container is started (JDBC URL: jdbc:postgresql://localhost:32773/test?loggerLevel=OFF)

o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path '/'

c.sirviendocodigo.demo.DemoApplication   : Started DemoApplication in 2.053 seconds (process running for 2.306)

Preparación

Vamos a crear una clase `User` muy sencilla para persistir usuarios en la base de datos y un `UserRepository` de Spring Data JPA para ello.

java

@Entity

@Table(name = "users")

public class User {

  @Id

  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq")

  private Long id;

  private String name;

  public User() {

  }

  // getters y setters

}
java

public interface UserRepository extends ListCrudRepository<User, Long> {

  Optional<User> findByName(String name);

}

Y adicionalmente crearemos la siguiente migración de Flyway para que cree el esquema en la base datos:

sql

CREATE TABLE users(

    id SERIAL PRIMARY KEY,

    name VARCHAR(255) NOT NULL

);

CREATE SEQUENCE users_seq increment by 50;

Testeando

Para poner todo en práctica vamos a escribir un test de integración en el que vamos a guardar un usuario en la base de datos y lo vamos a buscar para comprobar que todo ha funcionado correctamente.

java

@DisplayName("Given a UserRepository")

@Import(TestcontainersConfiguration.class) // <1>

@SpringBootTest(webEnvironment = WebEnvironment.NONE) // <2>

class UserRepositoryTest {

  @Autowired

  private UserRepository userRepository; // <3>

  @DisplayName("When saving and retrieving a user then it is saved and retrieved properly")

  @Test

  void saveUser() {

    User user = new User("Iván");

    userRepository.save(user); // <4>

    assertThat(user.getId()).isNotNull();

    Optional<User> optUser = userRepository.findByName(user.getName()); // <5>

    assertThat(optUser)

        .isPresent()

        .get()

        .extracting(User::getId, User::getName)

        .containsExactly(user.getId(), user.getName());

  }

}

<1> Importamos la clase en dónde se crea el contenedor de Postgres usando Testcontainers. Esto podría estar en una clase padre de la que heredamos en todos nuestros test de integración  

<2> Anotamos el test con `@SpringBootTest` para levantar el contexto, pero sin entorno web porque no lo necesitamos  

<3> Inyectamos el repository  

<4> Salvamos el usuario  

<5> Lo buscamos y comprobamos que es correcto

Cuando ejecutamos el test, en el log de la aplicación vemos como ésta arranca, se conecta a la base de datos, ejecuta las migraciones de Flyway y finalmente se para. Por supuesto el test pasa sin problemas 🙂

c.s.demo.UserRepositoryTest              : Starting UserRepositoryTest using Java 21.0.4 with PID 29049 (started by ivanlm in /home/ivanlm/workspaces/misc/sirviendocodigo)

...

o.f.c.i.s.JdbcTableSchemaHistory         : Schema history table "public"."flyway_schema_history" does not exist yet

o.f.core.internal.command.DbValidate     : Successfully validated 1 migration (execution time 00:00.009s)

o.f.c.i.s.JdbcTableSchemaHistory         : Creating Schema History table "public"."flyway_schema_history" ...

o.f.core.internal.command.DbMigrate      : Current version of schema "public": << Empty Schema >>

o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "001 - Initial"

o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v001 (execution time 00:00.003s)

com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...

com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@75f4d8a8

com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.

o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]

org.hibernate.Version                    : HHH000412: Hibernate ORM core version 6.5.3.Final

o.h.c.internal.RegionFactoryInitiator    : HHH000026: Second-level cache disabled

o.s.o.j.p.SpringPersistenceUnitInfo      : No LoadTimeWeaver setup: ignoring JPA class transformer

o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)

j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'

Si durante la ejecución del test ejecutamos en nuestro terminal `docker ps` podemos ver cómo se han creado los containers automáticamente y después de que termine el test éstos son parados y destruidos:

bash

$ docker ps

CONTAINER ID   IMAGE                       COMMAND                  CREATED         STATUS         PORTS                                           NAMES

7f89081d908b   postgres:latest             "docker-entrypoint.s…"   2 seconds ago   Up 2 seconds   0.0.0.0:32793->5432/tcp, [::]:32793->5432/tcp   sad_babbage

1fdc4f525092   testcontainers/ryuk:0.7.0   "/bin/ryuk"              2 seconds ago   Up 2 seconds   0.0.0.0:32792->8080/tcp, [::]:32792->8080/tcp   testcontainers-ryuk-573cf72c-e5c7-4c92-9fdc-4597b00f95ca

Todo esto se ejecuta en menos de un segundo y sin que tengamos que configurar nada. Si nos paramos a pensarlo con calma estamos arrancando una instancia de Postgresql en Docker, se está auto-configurando la aplicación para conectarse a esa instancia temporal, se está ejecutando el test, y finalmente se paran tanto la aplicación como la base de datos. Y todo ello en menos de un segundo, ¡impresionante!

Siguientes pasos

Esto es sólo una pequeña introducción de todo lo que podemos hacer con Testcontainers y las posibilidades que tenemos a la hora de testear correctamente nuestra aplicación. Imaginad que estamos usando tipos nativos de Postgresql como `jsonb`, con este tipo de test estamos completamente seguros de que la serialización y deserialización funciona correctamente y no vamos a tener sorpresas en producción. Lo mismo ocurre si escribimos queries más complejas con múltiples joins y condiciones, podremos escribir todos los tests necesarios para comprobar el correcto funcionamiento de nuestro código.

Esta misma aproximación se puede hacer para escribir tests end-to-end (E2E) una vez que tengamos un endpoint rest para crear usuarios y otro para poder buscarlos. Así estamos seguros de que nuestra API REST funciona correctamente.

Ejecutando los tests en CI

Anteriormente comentamos que queremos que los tests sean reproducibles en todos los entornos, incluido nuestro entorno de CI. Si por ejemplo utilizamos GitHub todo es transparente para nosotros y todo funcionará sin problemas.

Creamos el archivo `.github/workflow/gradle.yml` con el siguiente contenido:

yaml

name: Java CI with Gradle

on:

  push:

    branches: [ "main" ]

  pull_request:

    branches: [ "main" ]

jobs:

  test:

    runs-on: ubuntu-latest

    permissions:

      contents: write

    steps:

    - uses: actions/checkout@v4

    - name: Set up JDK 21

      uses: actions/setup-java@v4

      with:

        java-version: '21'

        distribution: 'temurin'

    - name: Run the tests

      run: ./gradlew test

Lo único que vamos a hacer es ejecutar los tests con `./gradlew test`. Después de pushear nuestros cambios veremos que el test se ejecuta y termina correctamente. Esto nos confirma que el test de integración es válido y podemos desplegar en producción.

Podemos analizar la ejecución aquí.

Resumen

Hemos visto lo fácil que es escribir test de integración en nuestras aplicaciones Spring Boot gracias a Testcontainers y a la magnífica integración de ambos. Ya no tenemos ninguna excusa para no escribirlos.

Todo el código de este proyecto está disponible en [este repo] de GitHub para que puedas probar todo sin problema.

Iván López
Iván López
Desarrollador JVM que trabajo como Staff Software Engineer en VMware y anteriormente fui committer en Micronaut. Descubrí Grails y Spring hace mucho tiempo y desde entonces desarrollo casi exclusivamente utilizando Java y Groovy. Soy un pasionado del OpenSource y he dedicado parte de mi carrera a trabajar en ello. Me encantan los tests y soy Testcontainers Community Champion. Coordinador del Grupo de Usuarios Groovy de Madrid (@madridgug) y ponente habitual en conferencias como Devoxx, Codemotion, GeeCon, Spring IO, Riga DevDays, JavaCro, SpringOne 2GX, GR8Conf, y más...

Otros artículos que te pueden interesar

El día en el que comencé a desarrollar todas mis webs con Python

0
En diciembre de 2022 apareció un nuevo framework llamado Pynecone, en su versión 0.1.8 Alpha. Cero ruido. Ya en julio de 2023, con su cambio de nombre a Reflex, y el lanzamiento de su versión 0.2.0, descubrí por primera vez este framework. Y la comunidad comenzó a hablar de él.
Java 23, claves y datos necesarios de la última versión de java

Java 23: ¿dónde estamos y cómo hemos llegado hasta aquí?

0
Java 23 ya está disponible desde el 17 de septiembre de 2024. Como siempre que se lanza una nueva versión, es útil conocer las novedades que incluye nuestro lenguaje favorito.

Formularios seguros: una guía esencial

0
Los formularios en línea son una herramienta esencial para la captura de datos, pero su desarrollo debe priorizar la seguridad y las medidas antifraude, especialmente cuando manejan información sensible.