Tests d'intégration avec Testcontainers dans Spring Boot 3

1. Vue d’ensemble

Les tests d’intégration exécutés sur H2 donnent souvent un résultat “vert” sur votre machine mais échouent quand même en production avec PostgreSQL. La raison en est que la syntaxe SQL, les types de données et le comportement des transactions diffèrent entre les deux systèmes de gestion. Testcontainers résout ce problème en démarrant une véritable base de données dans un conteneur Docker dès que le test s’exécute.

Dans cet article, vous allez configurer Testcontainers pour une application Spring Boot 3.x et utiliser @ServiceConnection pour connecter le container à Spring. Ensuite, vous allez partager un container entre plusieurs classes de test et comprendre les erreurs courantes.

2. Préparation

2.1. Dépendances

Vous avez besoin de Docker qui fonctionne sur votre machine, ou d’un daemon Docker compatible. Ensuite, ajoutez les dépendances suivantes à pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-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>
</dependencies>

Le parent Spring Boot gère déjà la version de Testcontainers, vous n’avez donc pas besoin de déclarer <version>. L’artifact spring-boot-testcontainers est disponible depuis Spring Boot 3.1 et fournit l’annotation @ServiceConnection.

2.2. Code source à tester

Nous utilisons une entité Order simple:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    public Order(String customerEmail, BigDecimal total, OrderStatus status) {
        this.customerEmail = customerEmail;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }

Le repository contient une requête native qui utilise ILIKE de PostgreSQL:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select * from orders where customer_email ilike :pattern",
           nativeQuery = true)
    List<Order> searchByEmail(@Param("pattern") String pattern);

    List<Order> findByStatus(OrderStatus status);
}

Les requêtes natives sont le type de code qui fonctionne sur H2 mais qui pète sur Postgres, ou l’inverse. C’est exactement l’endroit où vous devez tester sur une véritable base de données.

3. Tester le repository avec @ServiceConnection

3.1. Déclarer le container

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
        orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
        orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));

        List<Order> result = orderRepository.searchByEmail("%an.nguyen%");

        assertThat(result)
            .extracting(Order::getCustomerEmail)
            .containsExactly("An.Nguyen@example.com");
    }
}

Chaque élément fonctionne comme suit:

  • @Testcontainers active l’extension JUnit 5. Cette extension trouve les champs avec @Container et gère leur cycle de vie.
  • @Container sur un champ static: le container démarre une fois avant tous les tests de la classe et s’arrête après le dernier test. Si le champ n’est pas static, le container sera recréé pour chaque méthode de test.
  • @ServiceConnection: Spring Boot lit le host, le port aléatoire, le nom d’utilisateur et le mot de passe du container, puis crée un bean JdbcConnectionDetails. Grâce à cela, vous n’avez pas à configurer vous-même spring.datasource.url.
  • @AutoConfigureTestDatabase(replace = NONE): par défaut, @DataJpaTest peut remplacer la DataSource par une base de données embarquée. À partir de Spring Boot 3.4, le framework reconnaît automatiquement une DataSource provenant de @ServiceConnection. Néanmoins, le déclarer explicitement aide le code à fonctionner correctement sur chaque version 3.x.
  • ddl-auto=create-drop: avec une base de données non embarquée, Spring Boot ne crée pas automatiquement le schéma par défaut. Dans un vrai projet, vous devriez utiliser Flyway ou Liquibase pour que le test corresponde exactement à la migration de la production.

Lors de l’exécution du test, le log ressemblera à:

Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...

La première exécution sera plus lente car Docker doit pull l’image. Les exécutions suivantes prennent généralement quelques secondes.

3.2. L’ancienne méthode: @DynamicPropertySource

Avant Spring Boot 3.1, ou avec des containers qui ne sont pas supportés par @ServiceConnection, vous déployez manuellement les propriétés:

@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

Cette approche fonctionne toujours. Cependant, avec PostgreSQL, MySQL, Redis, Kafka, MongoDB et de nombreux autres services populaires, @ServiceConnection est plus court et moins sujet aux erreurs de typage des noms de propriétés.

4. Tester toute l’application avec @SpringBootTest

Les tests de repository chargent uniquement la couche JPA. Pour vérifier le flux HTTP → service → base de données, vous devez charger le contexte entier. Supposons le contrôleur suivant:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public record CreateOrderRequest(String customerEmail, BigDecimal total) {}

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order saved = orderRepository.save(
            new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable Long id) {
        return ResponseEntity.of(orderRepository.findById(id));
    }
}

4.1. Partager le container avec @TestConfiguration

Au lieu de déclarer le container dans chaque classe de test, vous le placez sous forme de bean:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

Ici, le container est géré par Spring et non par JUnit. Spring démarre le container avant de créer la DataSource et l’arrête quand le contexte est fermé. Spring TestContext met en cache le contexte entre les classes de test ayant la même configuration, donc ces classes utilisent un seul et même container.

4.2. Écrire le test

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void cleanDatabase() {
        orderRepository.deleteAll();
    }

    @Test
    void whenCreateOrder_thenItIsPersistedAndReadable() {
        var request = new OrderController.CreateOrderRequest(
            "chi@example.com", new BigDecimal("250000"));

        ResponseEntity<Order> created =
            restTemplate.postForEntity("/orders", request, Order.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = created.getBody().getId();

        Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);

        assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
        assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
        assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
    }
}

Quelques points à noter:

  • RANDOM_PORT démarre un véritable serveur. TestRestTemplate pointe automatiquement sur ce port.
  • Ce test n’a pas @Transactional, car la requête HTTP s’exécute sur un thread différent du test. Les données sont commitées en vrai dans Postgres, vous devez donc les nettoyer dans @BeforeEach.
  • isEqualByComparingTo compare les BigDecimal par valeur. Cela évite l’erreur où 250000 diffère de 250000.00 en raison d’une échelle différente.
  • La classe Order doit être désérialisée par Jackson. Si vous rencontrez une erreur de désérialisation, vérifiez le constructeur sans arguments et les getters, ou utilisez un DTO record séparé pour la réponse.

4.3. Réutiliser la configuration lors de l’exécution locale de l’application

TestcontainersConfig a un autre usage que peu de gens connaissent: exécuter l’application localement sans avoir besoin d’installer Postgres. Créez la classe suivante dans src/test/java:

public class TestOrderApplication {

    public static void main(String[] args) {
        SpringApplication.from(OrderApplication::main)
            .with(TestcontainersConfig.class)
            .run(args);
    }
}

Ensuite, exécutez la commande:

./mvnw spring-boot:test-run

L’application démarre avec un container Postgres. Ce container s’arrête quand vous fermez l’application.

5. Erreurs courantes

5.1. Static @Container dans plusieurs classes utilisant le même contexte

C’est l’erreur la plus agaçante. Supposons que vous ayez une classe de base abstraite contenant @Container static avec @ServiceConnection, et plusieurs classes de test l’héritant:

OrderControllerIT  -> le container démarre sur le port 55001, le contexte est mis en cache
CustomerControllerIT -> JUnit arrête l'ancien container, en démarre un nouveau sur le port 55017
                    -> Spring réutilise le contexte cache, qui pointe toujours vers 55001
                    -> Connection refused / HikariPool timeout

JUnit arrête le container après chaque classe, mais Spring conserve l’ancien contexte en cache. Vous avez deux façons de corriger cela:

  1. Utiliser un bean container comme à la section 4.1. Dans ce cas, le cycle de vie du container est lié au contexte. C’est la méthode recommandée.
  2. Utiliser un container singleton: supprimez @Container et @Testcontainers, puis démarrez-le manuellement dans un bloc static. Ryuk nettoiera le container quand la JVM se termine.
public abstract class AbstractIntegrationTest {

    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");

    static {
        POSTGRES.start();
    }
}

5.2. Utiliser le tag latest

Attention: Épinglez toujours la version de l’image (postgres:16-alpine), et de préférence, correspondez à celle en production. Le tag latest peut faire changer le comportement du test de façon inattendue quand une nouvelle image est publiée.

5.3. Les données fuient entre les tests

@DataJpaTest annule automatiquement après chaque test, mais @SpringBootTest avec RANDOM_PORT ne le fait pas. Quand le container est partagé, les données du test précédent restent dans la base de données. Nettoyez les données dans @BeforeEach, ou utilisez @Sql pour exécuter un script TRUNCATE. N’écrivez pas de tests qui dépendent de l’ordre d’exécution.

5.4. L’environnement CI n’a pas Docker

Si Docker n’est pas en cours d’exécution, le test rapportera une erreur:

java.lang.IllegalStateException: Could not find a valid Docker environment.

Vous devez vous assurer que le runner CI a Docker. Si vous voulez ignorer le test quand Docker est absent au lieu de faire échouer, utilisez @Testcontainers(disabledWithoutDocker = true). Réfléchissez bien avant d’activer cette option, car les tests ignorés silencieusement sont très faciles à oublier.

5.5. Accélérer localement avec réutilisation

Pour garder le container actif entre les exécutions du test localement, activez la réutilisation dans le fichier ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Ensuite, appelez .withReuse(true) au moment de créer le container. N’activez pas cette option sur CI, car le container ne sera pas nettoyé automatiquement.

6. Quand ne pas utiliser Testcontainers

Testcontainers rend les tests plus fiables, mais chaque test sera plus lent et nécessitera Docker. Il y a certains cas où vous ne devriez pas les utiliser:

  • Logique pure comme le calcul de prix, la validation ou le mapping: écrivez des unit tests avec des mocks ou des vrais objets, c’est suffisant. Les unit tests s’exécutent des centaines de fois plus vite.
  • Chaque classe de test avec une configuration de contexte différente: chaque configuration crée un nouveau contexte. Si le container est lié au contexte, vous aurez plusieurs containers en cours d’exécution simultanément. Regroupez les tests en quelques configurations communes.
  • Services tiers via HTTP: vous devriez généralement utiliser WireMock au lieu d’essayer d’exécuter le service réel dans un container.

Un bon ratio est: la plupart des tests sont des unit tests, tandis que les tests d’intégration avec Testcontainers se concentrent sur les zones qui touchent à l’infrastructure, comme les repositories, les requêtes natives, les migrations et les message brokers.

7. Conclusion

Dans cet article, vous avez utilisé Testcontainers pour exécuter des tests d’intégration sur PostgreSQL réel. @ServiceConnection vous aide à éliminer entièrement la configuration manuelle de la datasource. Placer le container sous forme de bean dans @TestConfiguration vous permet à la fois de partager un container entre plusieurs classes de test et d’exécuter l’application localement avec spring-boot:test-run.

Les points à retenir:

  • Épinglez la version de l’image pour qu’elle correspond à celle de la production.
  • Ne mélangez pas les static @Container avec la mise en cache du contexte de Spring.
  • Nettoyez vous-même les données quand le test ne s’exécute pas dans une transaction.
  • Utilisez Testcontainers uniquement aux endroits où vous avez vraiment besoin d’une base de données réelle.
spring-boottestcontainersintegration-testjunit5postgresql