← Tous les articles

🧪 Spring Boot – Teste avec une vraie base (et un vrai Kafka) grâce à Testcontainers

Logos Spring Boot et Testcontainers reliés par un signe plus

🪧 Introduction

Tes tests passent tous en local sur H2… et ta prod plante au premier déploiement sur PostgreSQL ? Une requête native qui ne passe pas, une migration Flyway jamais jouée sur le vrai moteur, un comportement de Kafka que ton mock n’avait pas prévu ?

👉 Le problème n’est pas ton code, c’est ce contre quoi tu le testes.

Dans cet article, on va voir :

  • pourquoi H2 et les mocks ne suffisent pas pour des tests d’intégration ;
  • comment lancer un vrai PostgreSQL dans tes tests avec Testcontainers ;
  • le pattern du conteneur singleton pour garder une suite de tests rapide ;
  • comment faire pareil avec MongoDB… et même Kafka ;
  • les pièges à éviter.

Les exemples utilisent Spring Boot 4 et Testcontainers 2, et reprennent la table users de l’article sur Flyway.

1. 💥 Pourquoi H2 et les mocks ne suffisent pas

H2 est pratique : rien à installer, la base démarre en mémoire en quelques millisecondes. Mais H2 n’est pas PostgreSQL, même en mode compatibilité :

  • les types spécifiques (jsonb, uuid, tableaux, BIGSERIAL…) se comportent différemment, ou pas du tout ;
  • tes requêtes natives et tes fonctions SQL peuvent passer sur H2 et casser sur PostgreSQL ;
  • tes migrations Flyway sont écrites pour PostgreSQL : sur H2, tu testes… autre chose ;
  • les contraintes, les index et la gestion des transactions ne sont pas identiques.

Pour Kafka ou MongoDB, c’est pire : on finit souvent par mocker le client. Le test vérifie alors que tu as appelé une méthode, pas que ton message part, arrive et se traite correctement.

👉 Un test d’intégration doit tourner contre les mêmes briques que la prod. C’est exactement ce que fait Testcontainers.

2. 🐳 Testcontainers en deux mots

Testcontainers est une bibliothèque Java qui démarre de vrais services dans des conteneurs Docker, le temps de tes tests :

Test JUnit → Testcontainers → Docker → postgres:17-alpine
                                     → mongo:7.0
                                     → apache/kafka-native

Le conteneur démarre avant tes tests, sur un port aléatoire, et il est supprimé à la fin. Rien à installer à part Docker, rien à nettoyer à la main.

✔️ Même moteur qu’en prod
✔️ Même version, figée par le tag de l’image
✔️ Le même test tourne sur ton poste et en CI

3. ⚙️ Mise en place avec PostgreSQL

Étape 1 – Ajouter les dépendances

Spring Boot gère les versions de Testcontainers : pas besoin de les préciser.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa-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>testcontainers-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-postgresql</artifactId>
    <scope>test</scope>
</dependency>

💡 Important

Avec Testcontainers 2, les modules ont été renommés (testcontainers-postgresql au lieu de postgresql) et les classes ont changé de package (org.testcontainers.postgresql.PostgreSQLContainer). Si tu copies un tutoriel plus ancien, c’est la première chose à corriger.

Étape 2 – Déclarer le conteneur

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@SpringBootTest
@Testcontainers
class UserRepositoryIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldFindUserByEmail() {
        userRepository.save(new User("Hamed", "hamed@example.com"));

        assertThat(userRepository.findByEmail("hamed@example.com")).isPresent();
    }
}

👉 @Container laisse JUnit démarrer et arrêter le conteneur. @ServiceConnection dit à Spring Boot d’y brancher la datasource : pas d’URL, de user ni de mot de passe à écrire, Spring Boot les lit directement sur le conteneur.

🎯 Résultat : ton repository est testé contre un vrai PostgreSQL 17, sur un port aléatoire, sans rien configurer dans application.properties.

4. 🔗 Flyway + Testcontainers : tes migrations enfin testées

Si tu as suivi l’article sur Flyway, tu as déjà tes scripts V1__init.sql et V2__add_email_column.sql. Ajoute le starter Flyway et le module PostgreSQL :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

Au démarrage du contexte de test, Flyway joue toutes tes migrations sur le conteneur. Ajoute une ligne dans la configuration de test :

spring.jpa.hibernate.ddl-auto=validate

👉 Hibernate vérifie alors que tes entités correspondent au schéma créé par Flyway. Une colonne oubliée dans une migration fait échouer le build, pas la prod.

✔️ Chaque migration est jouée sur le vrai moteur à chaque build
✔️ Tes entités et ton schéma ne peuvent plus diverger en silence

5. ⚡ Le conteneur singleton : un seul démarrage pour toute la suite

L’exemple de l’étape 2 a un défaut : chaque classe de test démarre son propre conteneur. Avec 30 classes de tests, tu paies 30 démarrages de PostgreSQL. Et si tu mets ce @Container dans une classe mère, JUnit arrête le conteneur à la fin de chaque classe alors que Spring garde le contexte en cache : la classe suivante récupère un contexte branché sur un conteneur mort.

La solution que j’utilise sur mes projets : un conteneur static, démarré une seule fois, dans une classe abstraite dont héritent tous les tests d’intégration.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class IntegrationTest {

    static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine");

    static {
        POSTGRES.start();
    }

    @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);
    }
}

Puis chaque test hérite simplement de cette classe :

class UserApiIT extends IntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldCreateUser() {
        // ...
    }
}

👉 Le bloc static s’exécute une seule fois par JVM, au premier chargement de la classe. Tous les tests partagent le même conteneur, et comme leur configuration est identique, Spring réutilise le même contexte. Pas de @Container ici : c’est toi qui démarres le conteneur, et Testcontainers le supprime à l’arrêt de la JVM.

Tu peux aussi remplacer le @DynamicPropertySource par un @ServiceConnection posé sur le champ static : Spring Boot va chercher ces champs jusque dans les classes mères. J’aime garder le @DynamicPropertySource quand je veux voir noir sur blanc quelles propriétés sont surchargées.

🎯 Résultat : un seul démarrage de PostgreSQL et un seul contexte Spring pour toute la suite. Plus ta suite grossit, plus le gain se compte en minutes.

6. 🍃 Pas que PostgreSQL : MongoDB

Le principe est le même pour n’importe quel service. Avec MongoDB, ajoute testcontainers-mongodb et déclare le conteneur dans ta classe mère :

import org.testcontainers.mongodb.MongoDBContainer;

@ServiceConnection
static final MongoDBContainer MONGO = new MongoDBContainer("mongo:7.0").withReplicaSet();

static {
    MONGO.start();
}

👉 Le withReplicaSet() est important : il démarre MongoDB en replica set, ce qui te permet de tester tes transactions MongoDB (MongoTransactionManager). Sans lui, MongoDB tourne en instance seule et la première transaction échoue avec This MongoDB deployment does not support retryable writes.

💡 Important

Avec Testcontainers 1.x, MongoDBContainer démarrait déjà en replica set. Depuis Testcontainers 2, il faut le demander explicitement avec withReplicaSet(). Si tu migres un projet existant, c’est un piège silencieux : une fois les imports mis à jour, tout compile, et seuls les tests qui utilisent des transactions cassent.

7. 📨 Aller plus loin : un vrai Kafka dans tes tests

Tu fais de l’event-driven ? Kafka se lance de la même façon. Ajoute testcontainers-kafka et spring-boot-starter-kafka-test, puis complète ta classe mère :

import org.testcontainers.kafka.KafkaContainer;

@ServiceConnection
static final KafkaContainer KAFKA = new KafkaContainer("apache/kafka-native:3.8.0");

static {
    KAFKA.start();
}

Imaginons un listener qui crée un utilisateur à la réception d’un événement :

@Component
public class UserCreatedListener {

    private final UserRepository userRepository;

    public UserCreatedListener(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @KafkaListener(topics = "users.created", groupId = "user-service")
    void onUserCreated(String email) {
        userRepository.save(new User("Nouvel utilisateur", email));
    }
}

Le test envoie un vrai message et vérifie le résultat en base :

class UserCreatedListenerIT extends IntegrationTest {

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldCreateUserWhenEventIsReceived() {
        kafkaTemplate.send("users.created", "awa@example.com");

        await().atMost(Duration.ofSeconds(10))
               .untilAsserted(() -> assertThat(userRepository.findByEmail("awa@example.com")).isPresent());
    }
}

👉 Le traitement est asynchrone : on ne peut pas vérifier la base juste après le send. Awaitility (dépendance org.awaitility:awaitility, version gérée par Spring Boot) réessaie l’assertion jusqu’à ce qu’elle passe, ou échoue après 10 secondes.

💡 Important

Ajoute spring.kafka.consumer.auto-offset-reset=earliest dans ta configuration de test. Sinon, si le message part avant que le listener soit abonné au topic, il ne le lira jamais et ton test échouera… une fois sur trois.

🎯 Résultat : la sérialisation, le topic, le listener et l’écriture en base sont testés de bout en bout, sans un seul mock.

8. 🧑‍💻 Bonus : lancer l’appli en local avec Testcontainers

Les mêmes conteneurs peuvent servir à lancer ton application en local. Crée une classe dans src/test/java :

@TestConfiguration(proxyBeanMethods = false)
class LocalContainers {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer("postgres:17-alpine");
    }
}

public class TestApplication {

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

👉 Lance TestApplication depuis ton IDE : PostgreSQL démarre, Flyway joue les migrations, l’appli se branche dessus. Plus besoin de maintenir un docker-compose.yml juste pour le développement.

9. ⚠️ Pièges et limites

Les données qui fuient d’un test à l’autre. Avec un conteneur partagé, la base garde les données des tests précédents. Remets un état connu avant chaque test, par exemple avec un @BeforeEach dans la classe mère qui vide les tables (TRUNCATE users RESTART IDENTITY CASCADE) et insère tes données de référence.

Le @Transactional qui ne protège plus rien. Un test @Transactional est annulé à la fin… sauf si tu appelles ton API en HTTP réel (RANDOM_PORT) : la requête est traitée dans un autre thread, avec sa propre transaction, qui est bien validée.

L’image non figée. postgres:latest changera un jour sans prévenir. Fixe toujours le tag, et aligne-le sur la version de prod.

Docker obligatoire. Il faut Docker sur le poste et en CI. Sur les runners ubuntu-latest de GitHub Actions, il est déjà installé : ./mvnw verify suffit.

Le temps de démarrage. Quelques secondes par conteneur au premier lancement, le temps de télécharger l’image. Avec le pattern singleton, tu ne les paies qu’une fois par build.

🎯 Conclusion

Testcontainers remplace les bases embarquées et les mocks d’infrastructure par les vraies briques de ta prod : PostgreSQL, MongoDB, Kafka… Avec @ServiceConnection, la configuration tient en une annotation, et avec le conteneur singleton, ta suite reste rapide.

👉 Si tu ne dois retenir qu’une chose : une classe mère, un conteneur static, démarré une fois.

Dans un prochain article, on s’attaquera au multi-tenant : comment isoler les données de plusieurs clients dans une même application… et comment prouver, avec Testcontainers, qu’aucune donnée ne fuit d’un tenant à l’autre.

📚 Sources & lectures recommandées