Développement

Utilisation de TestContainers dans une application Spring Boot

Testcontainers est une bibliothèque Java permettant d'exécuter des conteneurs Docker pour les tests d'intégration. Son principal avantage est d'offrir un environnement de test isolé, reproductible et proche des conditions réelles de production. Dans cet article, nous allons explorer deux cas d'usage concrets de Testcontainers pour une application Spring Boot.

Par

Intro

Testcontainers est une bibliothèque Java permettant d'exécuter des conteneurs Docker pour les tests d'intégration. Son principal avantage est d'offrir un environnement de test isolé, reproductible et proche des conditions réelles de production. Dans cet article, nous allons explorer deux cas d'usage concrets de Testcontainers pour une application Spring Boot :

  1. Améliorer l'expérience développeur (DevEx) : en automatisant la mise en place des services nécessaires au bon fonctionnement de l'application, Testcontainers permet de simplifier l'environnement de développement et de réduire les écarts entre le local et la production.
  2. Écrire des tests d'intégration robustes : en fournissant des conteneurs éphémères pour PostgreSQL, Pulsar et Elasticsearch, Testcontainers facilite la validation du bon fonctionnement des interactions entre ces services et assure des tests fiables et reproductibles.

À travers ces deux cas d'usage, nous verrons comment intégrer Testcontainers dans un projet Spring Boot afin de fluidifier le cycle de développement et garantir la qualité du code avant sa mise en production.

Intégration de Testcontainers dans une application Spring Boot

Dans un projet Spring Boot, l'expérience développeur (DX) est un facteur clé pour accélérer le développement et garantir un environnement cohérent entre les équipes. L'utilisation de Testcontainers permet d'automatiser le lancement des services externes (comme les bases de données) sans dépendre d'une installation manuelle, évitant ainsi les écarts entre les environnements de développement et de production.

L'exemple ci-dessous illustre comment initialiser un conteneur PostgreSQL et un conteneur Redis automatiquement lorsque l'application est exécutée avec le profil local. Grâce à Testcontainers, ces services sont démarrés dynamiquement avec des configurations spécifiques, garantissant que chaque développeur dispose d'un environnement identique sans configuration manuelle.

@Configuration
@Profile("local")
public class DevServices {
    static {
        PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:latest")
                .withCreateContainerCmdModifier(cmd -> cmd.withName("my-custom-name"))
                .withDatabaseName("dev")
                .withUsername("dev")
                .withPassword("dev")
                .withExposedPorts(5432)
                .waitingFor(Wait.forListeningPort());

        postgreSQLContainer.setPortBindings(List.of("5432:5432"));
        RedisContainer redisContainer = new RedisContainer("redis:latest");

        Startables.deepStart(redisContainer, postgreSQLContainer).join();

        System.setProperty("spring.datasource.url", postgreSQLContainer.getJdbcUrl());
        System.setProperty("spring.datasource.username", postgreSQLContainer.getUsername());
        System.setProperty("spring.datasource.password", postgreSQLContainer.getPassword());
    }
}

Cette configuration offre plusieurs avantages en termes de DX :

  • Automatisation : Plus besoin d’installer manuellement PostgreSQL ou Redis sur chaque machine de développement.
  • Cohérence : Tous les développeurs utilisent exactement la même version des services.
  • Facilité d'intégration : Le code peut être exécuté sans dépendances externes, ce qui simplifie les tests locaux et améliore la reproductibilité des bugs.


Tester une API Spring Boot avec Testcontainers

Le deuxième cas d’usage de Testcontainers est d’écrire des tests d’intégration en démarrant programmatiquement des containers.

On définit plusieurs services tels que PostgreSQL, Apache Pulsar et Elasticsearch et nous allons tester leur intégration en simulant l'ajout d'un utilisateur en base, l'envoi d'un événement Pulsar et son indexation dans Elasticsearch.

package com.example.demo.data;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.pulsar.client.api.Schema;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.pulsar.annotation.PulsarListener;
import org.springframework.pulsar.core.PulsarTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.stream.Stream;

@Controller
@RequestMapping("/user")
public class UserController {

    private final ElasticsearchClient client;

    @Autowired
    private UserRepository userReposity;

    @Autowired
    private PulsarTemplate<User> pulsarTemplate;

    public UserController(
            @Value("${elasticsearch.url}") String serverUrl,
            @Value("${spring.elasticsearch.username}") String username,
            @Value("${spring.elasticsearch.password}") String password) {
        RestClient restClient = RestClient.builder(HttpHost.create(serverUrl)).setHttpClientConfigCallback(
                httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(new BasicCredentialsProvider())
        ).build();
        ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
        this.client = new ElasticsearchClient(transport);
    }

    @PulsarListener(subscriptionName = "user-creation-sub", topics = "user-creation")
    void listen(User user) throws IOException {
        client.index(i -> i.index("user").id(user.getId().toString()).document(user));
    }

    @PostMapping(path="/add")
    public @ResponseBody String addNewUser(@RequestParam String name, @RequestParam String email) {
        User n = new User(name, email);
        userReposity.save(n);
        pulsarTemplate.send("user-creation", n, Schema.JSON(User.class));
        return "Saved";
    }

    @GetMapping("")
    public @ResponseBody Iterable<User> findAll(){
        return userReposity.findAll();
    }

    @PostMapping(path="/search")
    public @ResponseBody Stream<User> search(@RequestParam String keyword) throws IOException {
        SearchResponse<User> response = client.search(s -> s.index("user").query(q -> q.match(t -> t.field("name").query(keyword))), User.class);
        return response.hits().hits().stream().map(hit -> hit.source());
    }
}

Nous avons ainsi testé l'intégration entre PostgreSQL, Pulsar et Elasticsearch en simulant un flux de données complet dans notre API.

Avant d'ajouter les tests, voici les dépendances à inclure dans le pom.xml :

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>pulsar</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>elasticsearch</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>

1. Utilisation de Conteneurs manuels

L'approche classique de Testcontainers consiste à démarrer et configurer chaque service individuellement au sein des tests. Cela permet d’avoir une gestion fine des conteneurs, en spécifiant des stratégies de démarrage, des ports et des variables d’environnement adaptées.

Voici comment nous pourrions configurer chaque service manuellement :

@Testcontainers
class PublicResourcesAuthorizationsTest {

	@Container
	public static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:latest")
	        .withDatabaseName("testdb")
	        .withUsername("postgres")
	        .withPassword("postgres");
	
	@Container
	public static GenericContainer<?> pulsar = new GenericContainer<>("apachepulsar/pulsar:latest")
	        .withExposedPorts(6650)
	        .withCommand("bin/pulsar", "standalone");
	
	@Container
	public static GenericContainer<?> elasticsearch = new GenericContainer<>("elasticsearch:8.6.2")
	        .withExposedPorts(9200)
	        .waitingFor(Wait.forListeningPort());
}

En utilisant cette approche, chaque service démarre indépendamment, et nous pouvons les configurer dynamiquement dans nos tests grâce à @DynamicPropertySource.

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) throws IOException {
    var toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort());
    proxy = toxiproxyClient.createProxy("elastic", "0.0.0.0:8666", "elastic:9200");

    registry.add("spring.datasource.url", postgre::getJdbcUrl);
    registry.add("spring.datasource.username", postgre::getUsername);
    registry.add("spring.datasource.password", postgre::getPassword);
    registry.add("elasticsearch.url", () -> elasticsearch::getHttpHostAddress);
    registry.add("spring.pulsar.client.service-url", () -> "pulsar://localhost:" + pulsar.getFirstMappedPort());
}

2. Utilisation de Docker Compose

Si notre projet dépend de plusieurs services, Docker Compose permet de tout orchestrer en une seule configuration. Testcontainers offre un support natif pour Docker Compose, ce qui facilite la gestion des services externes dans un test d'intégration.

Si nous avons un fichier compose.yml similaire à celui ci-dessous.

version: '3.8'

services:
  pulsar:
    image: apachepulsar/pulsar:2.10.0
    command: ["bin/pulsar", "standalone"]
    ports:
      - "6650:6650"

  postgres:
    image: postgres:latest
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: testdb
    ports:
      - "5432:5432"

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
    environment:
      - ELASTIC_USERNAME=elastic
      - ELASTIC_PASSWORD=changeme
      - "discovery.type=single-node"
      - "xpack.security.transport.ssl.enabled=false"
      - "xpack.security.http.ssl.enabled=false"
    ports:
      - "9200:9200"
    restart: always

Avec cette approche, nous utilisons un fichier compose.yml qui définit PostgreSQL, Elasticsearch et Pulsar, et Testcontainers le démarre automatiquement grâce à la classe DockerComposeContainer:

@Container
public static DockerComposeContainer<?> compose = new DockerComposeContainer<>(new File("compose.yml"))
        .withExposedService("pulsar", 6650, Wait.forListeningPort())
        .withExposedService("postgres", 5432, Wait.forListeningPort())
        .withExposedService("elasticsearch", 9200, Wait.forListeningPort());

@DynamicPropertySource
static  void  configureProperties(DynamicPropertyRegistry registry) throws IOException {

    String postgresHost = compose.getServiceHost("postgres", 5432);
    Integer postgresPort = compose.getServicePort("postgres", 5432);
    String postgreJDBCUrl = "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb";
    registry.add("spring.datasource.url", () -> postgreJDBCUrl);
    registry.add("spring.datasource.username", () -> "postgres");
    registry.add("spring.datasource.password", () -> "postgres");
    
    registry.add("spring.pulsar.client.service-url", () -> "pulsar://localhost:" + compose.getServicePort("pulsar", 6650));
    
    String elasticsearchHost = compose.getServiceHost("elasticsearch", 9200);
    Integer elastisearchPort = compose.getServicePort("elasticsearch", 9200);
    registry.add("elasticsearch.url", () -> "http://" + elasticsearchHost + ":" + elastisearchPort);
}

L’avantage est que tous les services sont démarrés et arrêtés ensemble, réduisant ainsi la complexité de gestion. Cela facilite l'intégration avec une stack existante en développement tout en garantissant un environnement cohérent entre les développeurs.


Pour aller plus loin…

Si vous voulez en savoir plus sur les Testcontainers, vous pouvez visionner le replay du BBL qui s'est tenu le 21 novembre chez Vaduo. Il a été animé par Quentin Rosar, développeur back-end Java chez Vaduo, et Emmanuel Demey, développeur web freelance et Google Developer Expert.

Au programme : Nos experts vous guident pas à pas à travers une session de live coding où ils intègrent Testcontainers dans une application Spring Boot. Leur objectif ? Vous montrer comment adopter cette solution dès le lendemain dans vos projets, qu’ils soient internes ou réalisés pour vos clients.

Les autres articles à explorer