Índice
- Introduciendo Testcontainers
- Spring Boot y Testcontainers
- ¡Vamos al código!
- Preparación
- Testeando
- Siguientes pasos
- Ejecutando los tests en CI
- 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.