Testcontainers
Lancer des conteneurs Docker éphémères dans les tests d’intégration Spring Boot — bases de données, files d’attente, caches, serveurs externes.
L’idée : au lieu de mocker les dépendances externes, les tester avec une vraie instance. Un conteneur Docker démarrera au début des tests, sera détruit à la fin.
Dépendances
Maven
<!-- Essentiel : junit-jupiter et spring-boot-test pour la règle -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<!-- Extensions : uniquement celles que l'on utilise -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>redis</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>Gradle
testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
// Seulement si nécessaire
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:redis'Note :
junit-jupiterapporte la règleTestcontainersqui gère le cycle de vie des conteneurs.testcontainersseul fournit les classes de base (GenericContainer, ImagePullPolicy, …).
Règle JUnit 5 : Testcontainers
Déclarer un conteneur statique et gérer leur cycle de vie via la règle JUnit 5 :
import org.testcontainers.containers.PostgreSQLContainer;
import org.junit.jupiter.api.Testcontainers;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@Testcontainers
class MaClasseTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configurerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void requeteFonctionneAvecVraiPostgres() {
// ... utiliser la vraie base
}
}Piège : le conteneur doit être
static(une seule instance pour toute la classe de test) et@Container(la règle JUnit 5 le détecte et le lance/détruit). Un conteneur non static sera détruit après chaque méthode, ce qui annule tout bénéfice.
Piège :
@Testcontainersest obligatoire. Sans cette annotation, la règle JUnit 5 ne scannera pas les champs@Container. Les conteneurs seront alorsnullau runtime.
Exemples de conteneurs
PostgreSQL
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withInitScript("schema.sql"); // exécute un script SQL au premier démarrageVariables d’environnement à injecter via DynamicPropertySource :
| Clé Spring | Valeur registry.add(...) |
|---|---|
spring.datasource.url | postgres::getJdbcUrl |
spring.datasource.username | postgres::getUsername |
spring.datasource.password | postgres::getPassword |
spring.datasource.driver-class-name | constante org.postgresql.Driver |
Redis
GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configurer(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port",
() -> redis.getMappedPort(6379).toString());
}Kafka (KafkaContainer)
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.utility.DockerImageName;
KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void configurer(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}Conteneur générique (GenericContainer)
Utile pour tout service Docker non couvert par une extension :
GenericContainer<?> minio = new GenericContainer<>(
DockerImageName.parse("minio/minio:latest"))
.withExposedPorts(9000)
.withEnv("MINIO_ROOT_USER", "minioadmin")
.withEnv("MINIO_ROOT_PASSWORD", "minioadmin")
.withCommand("server", "/data")
.waitingFor(Wait.forHttp("/minio/health/live"));Intégration Docker Compose
Lancer un fichier docker-compose.yml entier (plusieurs services) plutôt que des conteneurs isolés.
Idéal quand les services dépendent les uns des autres (ex. PostgreSQL + migrateur Flyway, ou ZooKeeper + Kafka).
Dépendance
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>Note : pas de module séparé nécessaire —
DockerComposeContainerfait partie du moduletestcontainersde base.
Utilisation basique
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.utility.DockerImageName;
DockerComposeContainer<?> compose = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose.yml"))
.withExposedService("postgres_1", 5432)
.withExposedService("redis_1", 6379)
.withLogConsumer("postgres_1", new Slf4jLogConsumer(LOGGER));Chaque service exposé est alors accessible via compose.getServiceHost("postgres_1", 5432) et
compose.getServicePort("postgres_1", 5432).
Exemple avec docker-compose.yml
# src/test/resources/docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 2s
retries: 5DockerComposeContainer applique automatiquement les healthcheck définis dans le fichier
pour déterminer quand un service est prêt. Pas besoin de WaitingForStrategy personnalisé dans la plupart des cas.
Exemple complet : tests multi-services
@SpringBootTest
@Testcontainers
class ServiceIntegrationTest {
static DockerComposeContainer<?> compose = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose.yml"))
.withExposedService("postgres_1", 5432)
.withExposedService("redis_1", 6379);
@DynamicPropertySource
static void configurer(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://"
+ compose.getServiceHost("postgres_1", 5432)
+ ":" + compose.getServicePort("postgres_1", 5432)
+ "/testdb");
registry.add("spring.datasource.username", () -> "test");
registry.add("spring.datasource.password", () -> "test");
registry.add("spring.data.redis.host",
() -> compose.getServiceHost("redis_1", 6379));
registry.add("spring.data.redis.port",
() -> compose.getServicePort("redis_1", 6379).toString());
}
@Test
void fluxCompletsAvecVraiPostgresEtRedis() {
// ... tests avec Postgres + Redis opérationnels
}
}Piège : les noms de service dans
withExposedService()correspondent aux clés du fichierdocker-compose.yml, pas les noms d’image. Pourimage: postgres:16-alpinesous la cléservices.postgres, le nom estpostgres, paspostgres_1.
Piège : par défaut,
DockerComposeContainerdétruit les conteneurs à la fin des tests et supprime les volumes. Pour conserver les données entre les exécutions de tests :.withRemoveVolumes(false)À utiliser uniquement en local (dev) — en CI, laisser le comportement par défaut pour éviter l’accumulation de volumes orphelins.
Alternative : GenericContainer + DockerCompose en one-shot
Pour un script de provisioning rapide (ex. bootstrap de base de données au premier lancement) plutôt que dans un contexte de test :
GenericContainer<?> db = new GenericContainer<>("postgres:16-alpine")
.withEnv("POSTGRES_DB", "testdb")
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 1))
.withStartupTimeout(Duration.ofMinutes(2));
db.start();Quand choisir l’un ou l’autre ?
DockerComposeContainer: quand le fichierdocker-compose.ymlexiste déjà et que vous voulez le réutiliser tel quel (cohérence dev / test / CI).GenericContainer: quand la configuration est simple (un seul service) ou quand vous voulez un contrôle programmatique fin sur la stratégie d’attente.
Durée de vie des conteneurs
Statique vs non-static
| Déclencheur | Comportement |
|---|---|
static + @Container | Démarrage au premier test de la classe, arrêt après le dernier test |
Non-static + @Container | Démarrage avant chaque méthode @Test, arrêt après chaque méthode |
@DynamicPropertySource statique | Lancé une fois, avant le premier test de la classe |
Règle : privilégier les conteneurs
staticsauf besoin absolu de réinitialisation par test (ex. données spécifiques par test, validation de reset state).
Parcourir plusieurs conteneurs à la suite
@Testcontainers
class MultiContainerTest {
@Container
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static RedisContainer redis = new RedisContainer("redis:7-alpine");
@DynamicPropertySource
static void configurer(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", db::getJdbcUrl);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port",
() -> redis.getMappedPort(6379).toString());
}
}Les conteneurs partagent le même cycle de vie : tous démarrés au lancement de la classe, tous arrêtés à la fin.
Bonnes pratiques
Logs
Par défaut, les logs de Testcontainers ne s’affichent pas (pour ne pas noyer la sortie). Pour les activer :
<!-- pom.xml -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.16</version>
<scope>test</scope>
</dependency>Piège : les logs de Testcontainers sont filtrés par le framework de logging JVM. Ajouter un backend de log (SLF4J Simple, Logback) dans la dépendance
testpour les voir dans la console.
Ryuk (nettoyage des conteneurs orphelins)
Testcontainers utilise un conteneur Ryuk en arrière-plan pour détruire les conteneurs au cas où le processus Java serait tué (ex. crash, kill -9). Par défaut, Ryuk collecte les ressources pendant 60 secondes avant suppression.
Pour un environnement CI rapide, on peut désactiver Ryuk (à ne faire que si le processus Java s’arrête proprement) :
export TESTCONTAINERS_RYUK_DISABLED=trueAttention : sans Ryuk, les conteneurs lancés manuellement (hors
@Testcontainers) ne seront jamais détruits et pollueront le daemon Docker.
@Testcontainers avec @Nested
Les conteneurs static dans une classe parente sont partagés entre toutes les classes imbriquées. Pour un conteneur isolé à une classe imbriquée :
@SpringBootTest
@Testcontainers
class ServiceTest {
@Nested
@Testcontainers
class AvecPostgres {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("nested")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configurer(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// ...
}
}
}Vérifier la disponibilité de Docker
Testcontainers échoue silencieusement si Docker n’est pas disponible. Pour avoir une erreur claire :
@Test
@DisabledIfEnvironmentVariable(named = "SKIP_DOCKER_TEST", matches = "true")
void skipSiDockerPasDisponible() {
// Test réel
}Intégration avec spring-boot-testcontainers
L’extension spring-boot-testcontainers (disponible depuis Spring Boot 2.4+) simplifie la configuration pour PostgreSQL et MySQL :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>Avec cette extension, Spring Boot détecte automatiquement un conteneur PostgreSQL dans le contexte et injecte la configuration JDBC sans DynamicPropertySource :
@SpringBootTest
@Testcontainers
class ServiceTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
JdbcTemplate jdbc;
@Test
void requeteSpringBootTestcontainers() {
int count = jdbc.queryForObject("SELECT COUNT(*) FROM test", Integer.class);
assertEquals(0, count);
}
}Note :
spring-boot-testcontainersautomatise la configuration pour PostgreSQL et MySQL. Pour les autres services (Redis, Kafka, …), utiliserDynamicPropertySourcemanuellement.
Piège :
spring-boot-testcontainersest disponible depuis Spring Boot 2.4, mais la détection automatique dePostgreSQLContainerne fonctionne qu’avec Spring Boot 3.x. Avec Spring Boot 2.x, la configuration JDBC reste manuelle viaDynamicPropertySource.
Résumé des pièges
| Piège | Impact | Correctif |
|---|---|---|
Oublier @Testcontainers | Conteneurs null au runtime | Ajouter l’annotation au niveau de la classe de test |
Conteneur non static | Démarrage/arrêt à chaque méthode | Déclarer static + @Container |
| Pas de backend de log | Logs Testcontainers invisibles | Ajouter slf4j-simple ou logback en test |
| Port éphémère non géré | Conflits entre tests | Utiliser getMappedPort() à la place d’un port fixe |
spring-boot-testcontainers sur Boot 2.x | Configuration JDBC manuelle nécessaire | Utiliser DynamicPropertySource manuellement |