Testcontainers avec Spring Boot : tests d'intégration sans douleur

Guide complet pour configurer Testcontainers avec Spring Boot 3.4. PostgreSQL, Redis, Kafka en containers Docker pour des tests d'intégration fiables et reproductibles.

Tests d'intégration Spring Boot avec Testcontainers et PostgreSQL, Redis, Kafka

Les tests d'intégration représentent un défi majeur dans le développement d'applications Spring Boot. Tester contre une vraie base de données PostgreSQL ou un broker Kafka nécessite une infrastructure lourde à maintenir. Testcontainers résout ce problème en démarrant des containers Docker à la demande pendant les tests, garantissant des environnements isolés et reproductibles.

Spring Boot 3.4 et Testcontainers

Spring Boot 3.4 intègre un support natif de Testcontainers avec auto-configuration. Les dépendances spring-boot-testcontainers simplifient drastiquement la configuration et permettent de réutiliser les containers entre les tests.

Comprendre Testcontainers et son intégration Spring Boot

Testcontainers fournit une API Java pour démarrer des containers Docker pendant l'exécution des tests. Au lieu de mocker les dépendances externes ou de maintenir des bases de données de test partagées, chaque exécution de test dispose de sa propre instance isolée.

L'architecture repose sur trois composants principaux : la bibliothèque Testcontainers qui pilote Docker, les modules spécialisés pour chaque technologie (PostgreSQL, Redis, Kafka), et l'intégration Spring Boot qui injecte automatiquement les paramètres de connexion.

xml
<!-- pom.xml -->
<dependencies>
    <!-- Dépendance principale Testcontainers -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Module PostgreSQL -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Support JUnit 5 -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <!-- BOM Testcontainers pour gérer les versions -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.20.4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Le BOM Testcontainers garantit la cohérence des versions entre tous les modules utilisés dans le projet.

Configuration de base avec PostgreSQL

Le cas d'usage le plus courant concerne les tests avec une vraie base de données PostgreSQL. Spring Boot 3.4 offre deux approches : l'annotation @ServiceConnection pour l'auto-configuration, ou la configuration manuelle via @DynamicPropertySource.

UserRepositoryIntegrationTest.javajava
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class UserRepositoryIntegrationTest {

    // Démarre un container PostgreSQL avant les tests
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    );

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldSaveAndRetrieveUser() {
        // Given : un utilisateur à persister
        User user = new User();
        user.setEmail("test@example.com");
        user.setName("Test User");

        // When : sauvegarde et récupération
        User saved = userRepository.save(user);
        Optional<User> found = userRepository.findById(saved.getId());

        // Then : l'utilisateur est correctement persisté
        assertThat(found).isPresent();
        assertThat(found.get().getEmail()).isEqualTo("test@example.com");
    }

    @Test
    void shouldFindUserByEmail() {
        // Given : un utilisateur en base
        User user = new User();
        user.setEmail("search@example.com");
        user.setName("Search User");
        userRepository.save(user);

        // When : recherche par email
        Optional<User> found = userRepository.findByEmail("search@example.com");

        // Then : l'utilisateur est trouvé
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Search User");
    }
}

L'annotation @ServiceConnection détecte automatiquement le type de container et configure les propriétés Spring correspondantes (spring.datasource.url, spring.datasource.username, etc.). Cette approche élimine le code de configuration boilerplate.

Container lifecycle

Avec @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. Pour un container par test, utiliser un champ d'instance non-static.

Configuration manuelle avec @DynamicPropertySource

Certains scénarios nécessitent un contrôle plus fin sur les propriétés injectées. L'annotation @DynamicPropertySource permet de définir explicitement les valeurs de configuration.

OrderRepositoryIntegrationTest.javajava
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    )
        // Configuration spécifique du container
        .withDatabaseName("orders_test")
        .withUsername("test_user")
        .withPassword("test_password")
        // Script d'initialisation SQL
        .withInitScript("db/init-orders.sql");

    // Injection manuelle des propriétés dynamiques
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // URL JDBC générée dynamiquement avec le port mappé
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        // Propriétés additionnelles si nécessaire
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
    }

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private EntityManager entityManager;

    @Test
    void shouldPersistOrderWithItems() {
        // Given : une commande avec des items
        Order order = new Order();
        order.setOrderNumber("ORD-2026-001");
        order.setStatus(OrderStatus.PENDING);

        OrderItem item = new OrderItem();
        item.setProductId(1L);
        item.setQuantity(2);
        item.setUnitPrice(BigDecimal.valueOf(29.99));
        order.addItem(item);

        // When : sauvegarde de la commande
        Order saved = orderRepository.save(order);
        entityManager.flush();
        entityManager.clear();

        // Then : la commande et ses items sont persistés
        Order found = orderRepository.findById(saved.getId()).orElseThrow();
        assertThat(found.getItems()).hasSize(1);
        assertThat(found.getItems().get(0).getQuantity()).isEqualTo(2);
    }
}

Le script d'initialisation withInitScript permet de préparer le schéma ou d'insérer des données de référence avant l'exécution des tests.

Tests d'intégration avec Spring Boot complet

Pour tester l'application dans son ensemble avec tous les composants chargés, @SpringBootTest remplace @DataJpaTest. Cette configuration démarre le contexte Spring complet avec le container PostgreSQL.

UserServiceIntegrationTest.javajava
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserServiceIntegrationTest {

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

    @Autowired
    private UserService userService;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUserViaApi() {
        // Given : une requête de création
        CreateUserRequest request = new CreateUserRequest(
            "api@example.com",
            "API User",
            "securePassword123"
        );

        // When : appel de l'API REST
        ResponseEntity<UserResponse> response = restTemplate.postForEntity(
            "/api/users",
            request,
            UserResponse.class
        );

        // Then : l'utilisateur est créé avec succès
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().email()).isEqualTo("api@example.com");
    }

    @Test
    void shouldRetrieveUserById() {
        // Given : un utilisateur existant
        UserResponse created = userService.createUser(
            new CreateUserRequest("retrieve@example.com", "Retrieve User", "password")
        );

        // When : récupération par ID
        ResponseEntity<UserResponse> response = restTemplate.getForEntity(
            "/api/users/" + created.id(),
            UserResponse.class
        );

        // Then : l'utilisateur est retourné
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().name()).isEqualTo("Retrieve User");
    }
}

Le TestRestTemplate automatiquement configuré pointe vers le serveur démarré sur un port aléatoire, évitant les conflits de ports entre tests parallèles.

Prêt à réussir tes entretiens Spring Boot ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Réutilisation des containers entre tests

Le démarrage d'un container Docker prend plusieurs secondes. Pour accélérer l'exécution des tests, Spring Boot 3.4 permet de réutiliser les containers via une configuration centralisée.

TestcontainersConfiguration.javajava
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    // Bean container réutilisable entre tous les tests
    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
            .withReuse(true)
            .withLabel("reuse.UUID", "e06d7a87-7d7d-472e-a047-7c2c6d4b5f7a");
    }

    @Bean
    @ServiceConnection
    RedisContainer redisContainer() {
        return new RedisContainer(DockerImageName.parse("redis:7-alpine"))
            .withReuse(true)
            .withLabel("reuse.UUID", "b3c8f9d2-4a5e-4c8d-9f2a-1b3c5d7e9f0a");
    }
}

Les tests importent cette configuration pour partager les mêmes containers.

ProductServiceIntegrationTest.javajava
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class ProductServiceIntegrationTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldCacheProductDetails() {
        // Given : un produit en base
        Product product = new Product();
        product.setName("Cached Product");
        product.setPrice(BigDecimal.valueOf(99.99));
        productRepository.save(product);

        // When : deux appels successifs
        ProductDto first = productService.getProductById(product.getId());
        ProductDto second = productService.getProductById(product.getId());

        // Then : le deuxième appel utilise le cache
        assertThat(first).isEqualTo(second);
    }
}

Pour activer la réutilisation des containers, ajouter la configuration dans ~/.testcontainers.properties :

properties
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
Nettoyage des données

Avec la réutilisation des containers, les données persistent entre les exécutions. Utiliser @Sql ou @BeforeEach pour nettoyer les tables avant chaque test, ou configurer un schéma différent par classe de test.

Tests avec Redis et cache distribué

Testcontainers supporte Redis pour tester les fonctionnalités de cache. Le module Redis fournit un container préconfigured prêt à l'emploi.

CacheServiceIntegrationTest.javajava
@SpringBootTest
@Testcontainers
class CacheServiceIntegrationTest {

    @Container
    @ServiceConnection
    static RedisContainer redis = new RedisContainer(
        DockerImageName.parse("redis:7-alpine")
    );

    @Autowired
    private CacheService cacheService;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Test
    void shouldStoreAndRetrieveFromCache() {
        // Given : une valeur à mettre en cache
        String key = "user:123";
        String value = "{\"id\":123,\"name\":\"Cached User\"}";

        // When : stockage en cache
        cacheService.put(key, value, Duration.ofMinutes(10));

        // Then : la valeur est récupérable
        String cached = cacheService.get(key);
        assertThat(cached).isEqualTo(value);
    }

    @Test
    void shouldExpireAfterTtl() throws InterruptedException {
        // Given : une valeur avec TTL court
        String key = "expiring:key";
        cacheService.put(key, "temporary", Duration.ofSeconds(1));

        // When : attente de l'expiration
        Thread.sleep(1500);

        // Then : la clé a expiré
        String cached = cacheService.get(key);
        assertThat(cached).isNull();
    }

    @Test
    void shouldIncrementCounter() {
        // Given : une clé de compteur
        String counterKey = "page:views:homepage";

        // When : incrémentations multiples
        Long first = redisTemplate.opsForValue().increment(counterKey);
        Long second = redisTemplate.opsForValue().increment(counterKey);
        Long third = redisTemplate.opsForValue().increment(counterKey);

        // Then : le compteur s'incrémente correctement
        assertThat(first).isEqualTo(1);
        assertThat(second).isEqualTo(2);
        assertThat(third).isEqualTo(3);
    }
}

Spring Boot détecte automatiquement le RedisContainer grâce à @ServiceConnection et configure spring.data.redis.host et spring.data.redis.port.

Tests avec Kafka et messaging asynchrone

Les applications event-driven nécessitent des tests avec un broker Kafka réel. Testcontainers fournit un module Kafka qui démarre un cluster single-node adapté aux tests.

OrderEventIntegrationTest.javajava
@SpringBootTest
@Testcontainers
@EmbeddedKafka(partitions = 1, topics = {"order-events"})
class OrderEventIntegrationTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
    );

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Autowired
    private OrderEventConsumer orderEventConsumer;

    @Test
    void shouldPublishAndConsumeOrderEvent() throws Exception {
        // Given : un événement de commande
        OrderEvent event = new OrderEvent(
            "ORD-2026-100",
            OrderEventType.CREATED,
            LocalDateTime.now()
        );

        // When : publication sur Kafka
        kafkaTemplate.send("order-events", event.orderId(), event).get();

        // Then : l'événement est consommé (avec timeout)
        await()
            .atMost(Duration.ofSeconds(10))
            .untilAsserted(() -> {
                assertThat(orderEventConsumer.getReceivedEvents())
                    .hasSize(1)
                    .first()
                    .extracting(OrderEvent::orderId)
                    .isEqualTo("ORD-2026-100");
            });
    }
}
OrderEventConsumer.javajava
@Component
public class OrderEventConsumer {

    private final List<OrderEvent> receivedEvents = new CopyOnWriteArrayList<>();

    @KafkaListener(topics = "order-events", groupId = "test-group")
    public void consume(OrderEvent event) {
        receivedEvents.add(event);
    }

    public List<OrderEvent> getReceivedEvents() {
        return List.copyOf(receivedEvents);
    }

    public void clear() {
        receivedEvents.clear();
    }
}

La bibliothèque Awaitility gère les assertions asynchrones avec timeout, évitant les Thread.sleep fragiles dans les tests.

Configuration multi-container avec Docker Compose

Pour les applications complexes nécessitant plusieurs services interdépendants, Testcontainers supporte les fichiers Docker Compose.

yaml
# src/test/resources/docker-compose-test.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    ports:
      - "5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379"

  localstack:
    image: localstack/localstack:3.0
    environment:
      SERVICES: s3,sqs
      DEFAULT_REGION: eu-west-1
    ports:
      - "4566"
FullStackIntegrationTest.javajava
@SpringBootTest
@Testcontainers
class FullStackIntegrationTest {

    @Container
    static DockerComposeContainer<?> environment = new DockerComposeContainer<>(
        new File("src/test/resources/docker-compose-test.yml")
    )
        .withExposedService("postgres", 5432)
        .withExposedService("redis", 6379)
        .withExposedService("localstack", 4566)
        .waitingFor("postgres", Wait.forListeningPort())
        .waitingFor("redis", Wait.forListeningPort())
        .waitingFor("localstack", Wait.forLogMessage(".*Ready\\.$", 1));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // Configuration PostgreSQL
        String postgresHost = environment.getServiceHost("postgres", 5432);
        Integer postgresPort = environment.getServicePort("postgres", 5432);
        registry.add("spring.datasource.url",
            () -> "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb");
        registry.add("spring.datasource.username", () -> "testuser");
        registry.add("spring.datasource.password", () -> "testpass");

        // Configuration Redis
        String redisHost = environment.getServiceHost("redis", 6379);
        Integer redisPort = environment.getServicePort("redis", 6379);
        registry.add("spring.data.redis.host", () -> redisHost);
        registry.add("spring.data.redis.port", () -> redisPort);

        // Configuration LocalStack S3
        String localstackHost = environment.getServiceHost("localstack", 4566);
        Integer localstackPort = environment.getServicePort("localstack", 4566);
        registry.add("aws.s3.endpoint",
            () -> "http://" + localstackHost + ":" + localstackPort);
    }

    @Autowired
    private FileStorageService fileStorageService;

    @Test
    void shouldUploadFileToS3() {
        // Given : un fichier à uploader
        byte[] content = "Test file content".getBytes();
        String fileName = "test-file.txt";

        // When : upload vers S3 via LocalStack
        String url = fileStorageService.upload(fileName, content);

        // Then : le fichier est accessible
        assertThat(url).contains(fileName);
        byte[] downloaded = fileStorageService.download(fileName);
        assertThat(downloaded).isEqualTo(content);
    }
}

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Bonnes pratiques et optimisation des performances

L'exécution efficace des tests Testcontainers nécessite quelques optimisations pour réduire les temps de build.

AbstractIntegrationTest.javajava
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {

    // Container partagé entre toutes les classes héritant
    @Container
    @ServiceConnection
    protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    )
        .withReuse(true);

    @Autowired
    protected JdbcTemplate jdbcTemplate;

    @BeforeEach
    void cleanDatabase() {
        // Nettoyage des tables dans l'ordre pour respecter les FK
        jdbcTemplate.execute("TRUNCATE TABLE order_items CASCADE");
        jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE");
        jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
    }
}
UserIntegrationTest.javajava
class UserIntegrationTest extends AbstractIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldCreateUser() {
        // Le container PostgreSQL est déjà démarré via la classe parent
        User user = new User();
        user.setEmail("inherited@test.com");
        user.setName("Inherited Test");

        User saved = userRepository.save(user);

        assertThat(saved.getId()).isNotNull();
    }
}

La classe abstraite centralise la configuration des containers et le nettoyage de la base de données, évitant la duplication de code.

properties
# src/test/resources/application-test.properties
# Configuration spécifique aux tests
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false

# Désactiver Flyway/Liquibase si utilisation de ddl-auto
spring.flyway.enabled=false

# Pool de connexions réduit pour les tests
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2
Images Docker légères

Préférer les images Alpine (postgres:16-alpine, redis:7-alpine) qui sont plus légères et démarrent plus rapidement. Pour les tests, la différence de fonctionnalités avec les images complètes est négligeable.

Tests de migration de base de données

Testcontainers excelle pour tester les migrations Flyway ou Liquibase contre une vraie base de données.

FlywayMigrationTest.javajava
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class FlywayMigrationTest {

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

    @Autowired
    private Flyway flyway;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldApplyAllMigrations() {
        // Given : migrations appliquées au démarrage

        // When : vérification de l'état
        MigrationInfoService info = flyway.info();

        // Then : toutes les migrations sont appliquées
        assertThat(info.pending()).isEmpty();
        assertThat(info.applied()).isNotEmpty();
    }

    @Test
    void shouldCreateExpectedTables() {
        // Given : migrations appliquées

        // When : requête des tables système
        List<String> tables = jdbcTemplate.queryForList(
            "SELECT table_name FROM information_schema.tables " +
            "WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
            String.class
        );

        // Then : les tables attendues existent
        assertThat(tables).contains("users", "orders", "order_items", "products");
    }

    @Test
    void shouldHaveCorrectColumnTypes() {
        // Given : table users créée

        // When : vérification du schéma
        List<Map<String, Object>> columns = jdbcTemplate.queryForList(
            "SELECT column_name, data_type, is_nullable " +
            "FROM information_schema.columns " +
            "WHERE table_name = 'users'"
        );

        // Then : les colonnes ont les bons types
        assertThat(columns)
            .extracting(c -> c.get("column_name"))
            .contains("id", "email", "name", "created_at");
    }
}

Ces tests garantissent que les migrations SQL fonctionnent correctement avant le déploiement en production.

Conclusion

Testcontainers transforme les tests d'intégration Spring Boot en les rendant fiables, reproductibles et indépendants de l'environnement local. Le support natif de Spring Boot 3.4 avec @ServiceConnection simplifie considérablement la configuration, tandis que la réutilisation des containers optimise les temps d'exécution.

Checklist Testcontainers Spring Boot :

  • ✅ Utiliser spring-boot-testcontainers pour l'auto-configuration native
  • ✅ Préférer @ServiceConnection à @DynamicPropertySource quand possible
  • ✅ Activer withReuse(true) pour accélérer les exécutions successives
  • ✅ Centraliser la configuration dans une classe abstraite ou @TestConfiguration
  • ✅ Nettoyer les données entre tests avec @BeforeEach ou @Sql
  • ✅ Utiliser des images Alpine pour des démarrages plus rapides
  • ✅ Tester les migrations Flyway/Liquibase contre un vrai PostgreSQL
  • ✅ Exploiter Docker Compose pour les environnements multi-services

Tags

#testcontainers
#spring boot
#tests intégration
#docker
#postgresql

Partager

Articles similaires