Introducción a TestContainers: Cómo mejorar tus pruebas de integración en Spring Boot

0
1771

0. Índice de contenidos.

  1. Introducción.
  2. Entorno.
  3. Creación proyecto de prueba.
  4. Creación test de integración.
    4.1 Añadiendo soporte para TestContainer.
    4.2 Optimizando la creación de contenedores.
    4.3 Configuración mediante anotaciones y perfiles de Spring.
  5. Conclusiones.

1. Introducción

TestContainers es una librería Java que se integra dentro del ciclo de vida de test con Junit y permite levantar contenedores Docker para ejecutar nuestros tests de integración sin necesidad de recurrir a plugins externos, toda la configuración se realiza desde código Java en las clases de test.

Hasta la aparición de TestContainers las alternativas para lanzar tests de integración contra un servicio externo eran básicamente dos:

  • Arranque manual del servicio externo antes de lanzar los tests, carga de datos si fuese necesario y su posterior parada. Esta solución hace muy complicado ejecutar los tests de forma automática y llevarlos a un entorno de integración continua.
  • Uso de plugins integrados en el ciclo de vida de Maven para arrancar el servicio externo en un contenedor Docker disponible en el tiempo de vida del test, por ejemplo el plugin de Spotify o Fabric8.
    Esta solución automatiza la ejecución de tests en cualquier entorno y se puede utilizar directamente en un entorno de integración continua.
    El problema de alguno de estos plugins es que han sido desarrollados a título personal por gente que ya no da soporte a los mismos y han quedado deprecados. Además es necesaria cierta configuración a nivel de Maven para configurar los contenedores y levantarlos en la fase adecuada del ciclo de vida de Maven.

 

TestContainers, al igual que el uso de plugins dentro de Maven, nos permite ejecutar los tests de forma automática en pipelines de integración continua, con la ventaja de que es una librería con un amplio soporte y su configuración se realiza desde clases Java, algo que muchos desarrolladores agradecerán.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 13′ (Apple M1 Pro, 32GB DDR4).
  • Sistema Operativo: Mac OS Ventura 13.3.1

3. Creación proyecto de prueba

Para ver el funcionando de TestContainers vamos a crear un pequeño proyecto de prueba basado en la última versión liberada hasta el momento de SpringBoot (3.1.0).
Nos vamos a la web de Spring Initializr/ y generamos un proyecto Java con Maven utilizando SpringBoot 3.1.0 y Java 17:
Creación proyecto

Creamos una entidad de dominio muy sencilla y un repositorio SpringDataJpa encargado de gestionarla en base de datos.

@Entity
@SequenceGenerator(name = "sequence_generator", sequenceName = "book_seq", allocationSize = 1)
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequence_generator")
    private Long id;

    private String title;

    private String isbn;

    private Integer year;

    private String author;

    //getters and setters
@Repository
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

}

Debemos de modificar el pom.xml del proyecto para dar soporte a TestContainers, SpringDataJpa, PostgreSql (será la base de datos que utilicemos para los tests) y Flyway (queremos que la base datos ya esté precargada con ciertos datos).

Añadiremos el siguiente dependencyManagement:

     <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.testcontainers</groupId>
                <artifactId>testcontainers-bom</artifactId>
                <version>1.18.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

Y las siguientes dependencias:

       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <scope>test</scope>
        </dependency>

4. Creación test de integración

Vamos a crear un test de integración muy sencillo para asegurarnos que el repositorio accede correctamente a la base de datos.

Utilizaremos Flyway para gestionar los scripts de base de datos:

  • En /src/main/resources/db/migration disponemos del script V01_01__create_book_table.sql para crear la tabla book:
    CREATE SEQUENCE book_seq AS BIGINT START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
    CREATE TABLE book
    (
    id BIGINT PRIMARY KEY,
    title VARCHAR(200),
    isbn VARCHAR(200),
    year INTEGER,
    author VARCHAR(200)
    );
    
  • En /src/test/resources/db/migration disponemos del script V01_02__insert_books.sql para crear un registro en la tabla book de cara a los tests de integración:
    INSERT INTO book(id, title, isbn, year, author)
    VALUES(nextval('book_seq'), 'Book 1','987-6-54-321', 2023, 'Author 1');
    

Implementamos un test que realiza un count para obtener el número inicial de libros en base de datos, posteriormente inserta un libro y realiza un nuevo count y finalmente elimina el libro creado y realiza un último count.

@DataJpaTest
class BookRepositoryTest {

    @Autowired
    private BookRepository sut;

    @Test
    void crud_book_ok() {
        assertEquals(1, sut.count());

        final Book book = new Book();
        book.setIsbn("123456789");
        book.setAuthor("Author 2");
        book.setTitle("Title 2");
        book.setYear(2023);
        sut.save(book);

        assertEquals(2, sut.count());

        sut.delete(book);

        assertEquals(1, sut.count());
    }
}

Si intentamos ejecutarlo se produce un error ya que no es capaz de conectarse a la base de datos, necesitaríamos arrancar una base de datos de forma manual o mediante algún plugin conectado al ciclo de vida de Maven y configurar el datasource en el fichero properties del contexto de test.

4.1 Añadiendo soporte para TestContainer

Vamos a modificar la clase de test para añadirle soporte para TestContainer y configurar el contenedor de base de datos que se debe levantar antes de ejecutar nuestro test:

@DataJpaTest
@Testcontainers
@ContextConfiguration(initializers = BookRepositoryTest.DataSourceInitializer.class)
class BookRepositoryTest {

    @Autowired
    private BookRepository sut;

    @Container
    private static final PostgreSQLContainer database = new PostgreSQLContainer("postgres:15.2-alpine3.17");

    public static class DataSourceInitializer implements ApplicationContextInitializer {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.test.database.replace=none",
                    "spring.datasource.url=" + database.getJdbcUrl(),
                    "spring.datasource.username=" + database.getUsername(),
                    "spring.datasource.password=" + database.getPassword()
            );
        }
    }

    @Test
    void crud_book_ok() {
        assertEquals(1, sut.count());

        final Book book = new Book();
        book.setIsbn("123456789");
        book.setAuthor("Author 2");
        book.setTitle("Title 2");
        book.setYear(2023);
        sut.save(book);

        assertEquals(2, sut.count());

        sut.delete(book);

        assertEquals(1, sut.count());
    }
}

Hay varios detalles a tener en cuenta:

  • La anotacion @Testcontainers indica que vamos a hacer uso de TestContainers en esta clase y podemos instanciar contenedores.
  • Con @ContextConfiguration se le indica la clase que contiene la configuración que se debe tomar para arrancar los contenedores.
  • Con @Container indicamos que la variable database será un contenedor del tipo base de datos PostgreSQL gestionado por TestContainer.
    Cabe destacar que TestContainer ofrece implementaciones para la mayoría de imágenes Docker que podemos necesitar (bases de datos, broker de mensajería, Nginx…)
    En caso de que necesitasemos una imagen no soportada directamente, nos ofrece la posibilidad de definir sus características a través de GenericContainer (https://www.testcontainers.org/features/creating_container/)
  • Finalmente, la clase DataSourceInitializer es la responsable de indicar los datos de arranque del contenedor PostgreSQL

4.2 Optimizando la creación de contenedores

Al declarar la configuración del contenedor a nivel de clase, cada vez que se ejecute la clase de test se levanta un contenedor Docker con la base de datos PostgreSQL. En un proyecto donde tenemos varios tests interactuando con el mismo contenedor este comportamiento no es óptimo, queremos tener un único contenedor levantado con la base de datos y que todos los tests interactúen con él.

Crearemos una clase padre donde se defina el uso de TestContainers y se instancien los contenedores a utilizar en los tests:

@Testcontainers
@ContextConfiguration(initializers = TestContainersDatabaseConfig.DataSourceInitializer.class)
class TestContainersDatabaseConfig {
    @Container
    private static final PostgreSQLContainer database = new PostgreSQLContainer("postgres:15.2-alpine3.17");

    public static class DataSourceInitializer implements ApplicationContextInitializer {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.test.database.replace=none",
                    "spring.datasource.url=" + database.getJdbcUrl(),
                    "spring.datasource.username=" + database.getUsername(),
                    "spring.datasource.password=" + database.getPassword()
            );
        }
    }

}

Por otro lado, cada uno de los tests en los que queremos hacer uso del contenedor de base de datos deben de extender de la clase padre que contiene la configuración.

@DataJpaTest
class BookRepositoryTest extends TestContainersDatabaseConfig {

    @Autowired
    private BookRepository sut;

    @Test
    void crud_book_ok() {
        assertEquals(1, sut.count());

        final Book book = new Book();
        book.setIsbn("123456789");
        book.setAuthor("Author 2");
        book.setTitle("Title 2");
        book.setYear(2023);
        sut.save(book);

        assertEquals(2, sut.count());

        sut.delete(book);

        assertEquals(1, sut.count());
    }
}

Esta solución nos puede servir en proyectos donde sólo hay depencias con uno o pocos contenedores, pero puede ser un dolor de cabeza si necesitamos tests que levanten distintos subconjuntos de contenedores, ya que en Java no disponemos de herencia múltiple y perdemos granularidad a nivel de configuración de contenedores.

Una alternativa para solucionarlo es crear los distintos contenedores como singletons e instanciarlos en los tests donde sean necesarios, por ejemplo este sería el singleton para un contenedor PostgreSQL:

public class CommonPostgresqlContainer extends PostgreSQLContainer<CommonPostgresqlContainer> {
    private static final String VERSION = "postgres:15.2-alpine3.17";

    private static CommonPostgresqlContainer container;

    private CommonPostgresqlContainer() {
        super(VERSION);
    }

    public static CommonPostgresqlContainer getInstance() {
        if (container == null) {
            container = new CommonPostgresqlContainer();
        }
        return container;
    }

    @Override
    public void start() {
        super.start();
        System.setProperty("DB_URL", container.getJdbcUrl());
        System.setProperty("DB_USERNAME", container.getUsername());
        System.setProperty("DB_PASSWORD", container.getPassword());
    }

    @Override
    public void stop() {
        //do nothing, JVM handles shut down
    }
}

Nuestra clase de test quedaría de esta forma:

@DataJpaTest
class BookRepositoryTest {

    @Autowired
    private BookRepository sut;

    public static PostgreSQLContainer postgreSQLContainer = CommonPostgresqlContainer.getInstance();

    @Test
    void crud_book_ok() {
        assertEquals(1, sut.count());

        final Book book = new Book();
        book.setIsbn("123456789");
        book.setAuthor("Author 2");
        book.setTitle("Title 2");
        book.setYear(2023);
        sut.save(book);

        assertEquals(2, sut.count());

        sut.delete(book);

        assertEquals(1, sut.count());
    }
}

En este caso no es necesario utilizar las anotaciones @TestContainers ni @Container, vamos a hacer uso de la integración de TestContainer con JDBC (https://www.testcontainers.org/modules/databases/jdbc/) para indicarle el datasource de la base de datos de tests en el fichero application.properties:

spring.datasource.url=jdbc:tc:postgresql:15.2-alpine3.17:///spring_boot_testcontainers

4.3 Configuración mediante anotaciones y perfiles de Spring

En el caso de trabajar únicamente con un contenedor de base de datos podemos optar por una alternativa más sencilla, hacer uso de las anotaciones de Spring para activar un perfil donde se define el datasource integrado con TestContainers.

@DataJpaTest
@ActiveProfiles("postgres")
class BookRepositoryTest {

    @Autowired
    private BookRepository sut;

    @Test
    void crud_book_ok() {
        assertEquals(1, sut.count());

        final Book book = new Book();
        book.setIsbn("123456789");
        book.setAuthor("Author 2");
        book.setTitle("Title 2");
        book.setYear(2023);
        sut.save(book);

        assertEquals(2, sut.count());

        sut.delete(book);

        assertEquals(1, sut.count());
    }
}

En este caso no es necesario instanciar la variable con el contenedor a utilizar, simplemente activamos el perfil postgres y en el fichero application-postgres.properties declaramos el datasource a utilizar integrado con TestContainers:

spring.datasource.url=jdbc:tc:postgresql:15.2-alpine3.17:///spring_boot_testcontainers

Todos los tests que utilicen el mismo perfil compartirán el contenedor de base de datos.

Podríamos ir un paso más allá y crearnos nuestras propias anotaciones para testing activando el perfil correspondiente. En este caso nos creamos la anotacion @MyPostgreSqlTest que se encarga de activar el perfil de Spring postgres del ejemplo anterior:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@DataJpaTest
@ActiveProfiles("postgres")
public @interface MyPostgreSqlTest {
}

Y nuestro test quedaría de esta forma:

@MyPostgreSqlTest
class BookRepository21Test {

    @Autowired
    private BookRepository sut;

    @Test
    void crud_book_ok() {
        assertEquals(1, sut.count());

        final Book book = new Book();
        book.setIsbn("123456789");
        book.setAuthor("Author 2");
        book.setTitle("Title 2");
        book.setYear(2023);
        sut.save(book);

        assertEquals(2, sut.count());

        sut.delete(book);

        assertEquals(1, sut.count());
    }
}

5. Conclusiones

TestContainers es una librería suficientemente madura que facilita el uso de contenedores en nuestros tests de integración sin necesidad de recurrir a soluciones externas. Hemos visto que permite distintos modos de uso en función de las necesidades de nuestro proyecto, un contenedor por test o reutilizar el mismo contenedor en varios tests para ser más eficientes. Al gestionarse su configuración desde clases Java podemos hacer uso de patrones y de todas las ventajas que nos ofrece la POO para jugar con la creación y configuración de los contenedores.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad