Tests End-to-End pour Spring Boot : Testcontainers, WireMock et Stratégies CI
Guide complet tests E2E Spring Boot 2026 : Testcontainers, WireMock, stratégies CI/CD. Code testé, performances, best practices.
Les tests end-to-end (E2E) sont le dernier rempart avant la production. Pourtant, dans 70% des projets Spring Boot, les tests E2E sont soit absents, soit si lents qu'ils sont désactivés en CI.
Le problème ? Dépendances externes complexes : bases de données, queues, APIs tierces, storage... Résultat : tests flaky, environnements de test fragiles, bugs en production.
2026 marque un tournant : Testcontainers devient le standard, WireMock automatise les mocks HTTP, et les pipelines CI exécutent des tests E2E en moins de 5 minutes.
Dans cet article, nous construisons une suite de tests E2E complète pour une application Spring Boot moderne, avec :
- Testcontainers pour PostgreSQL, Redis, Kafka
- WireMock pour mocker les APIs tierces
- Stratégies CI optimisées (GitHub Actions)
- Métriques de qualité (couverture, performances)
- Nous testons une application e-commerce simplifiée :
- Stack technique :
- Spring Boot 3.2
- PostgreSQL 16
- Redis 7.2
- Kafka 3.6
- WireMock 3.3
- Avantages de
withReuse(true): - ✅ Containers réutilisés entre exécutions de tests
- ✅ Temps de démarrage : 15s → 2s après premier lancement
- ✅ Nécessite Testcontainers daemon :
~/.testcontainers.properties - Temps d'exécution typiques :
- Configuration parallélisation :
- 1. Isoler les données entre tests
- 2. Utiliser des builders pour les fixtures
- 3. Tester les cas limites
- 1. Ne pas partager l'état entre tests
- 2. Ne pas hardcoder les ports
- 3. Ne pas ignorer les timeouts
- Les tests E2E avec Testcontainers + WireMock offrent :
- ✅ Environnement reproductible (Docker)
- ✅ Tests réalistes (vraies dépendances)
- ✅ CI rapide (<3 minutes avec optimisations)
- ✅ Confiance avant production
- Checklist implémentation :
- [ ] Testcontainers configuré avec
reuse=true - [ ] WireMock pour toutes les APIs externes
- [ ] Tests parallèles activés
- [ ] Couverture > 80%
- [ ] Pipeline CI < 5 minutes
- Maillage interne :
- TDD Assisté par IA : Guide Complet
- CI/CD avec GitHub Actions : Pipeline Moderne
- Déployer Spring Boot sur Kubernetes
- WebFlux et Programmation Réactive
Conclusion
// ❌ MAUVAIS
kafkaLatch.await(); // Bloque indéfiniment// ✅ BON
boolean received = kafkaLatch.await(10, TimeUnit.SECONDS);
// ❌ MAUVAIS
@Value("8080")
private int port;// ✅ BON
@LocalServerPort
// ❌ MAUVAIS
static Order sharedOrder; // State partagé// ✅ BON
❌ À ÉVITER
@ParameterizedTest
@ValueSource(ints = {-1, 0, 1, 1000, Integer.MAX_VALUE})
void shouldValidateQuantity(int quantity) {
// Test validation pour toutes les valeurs
public class OrderFixtures {
public static Order.OrderBuilder defaultOrder() {
return Order.builder()
.customerId("customer_test")
.status(OrderStatus.PENDING)
.totalAmount(BigDecimal.valueOf(100.0));
}
@BeforeEach
void cleanupDatabase() {
orderRepository.deleteAll();
productRepository.deleteAll();
✅ À FAIRE
Best Practices et Pièges à Éviter
application-test.yml
management:
metrics:
export:
prometheus:
enabled: true
endpoints:
web:
exposure:
include: prometheus,health,metricsspring:
test:
mockmvc:
Intégration Prometheus
@ExtendWith(TestResultLogger.class)
class OrderE2ETest extends AbstractIntegrationTest {
// Tests...
}class TestResultLogger implements TestWatcher {
private static final Map metrics = new ConcurrentHashMap<>(); @Override
public void testSuccessful(ExtensionContext context) {
recordMetric(context, "SUCCESS");
} @Override
public void testFailed(ExtensionContext context, Throwable cause) {
recordMetric(context, "FAILED");
} private void recordMetric(ExtensionContext context, String status) {
String testName = context.getDisplayName();
long duration = context.getExecutionException()
.map(e -> System.currentTimeMillis())
.orElse(0L); metrics.put(testName, TestMetrics.builder()
.name(testName)
.status(status)
.duration(duration)
.timestamp(Instant.now())
.build());
} @AfterAll
static void publishMetrics() {
// Publier vers système de monitoring
MetricsPublisher.publish(metrics);
}
Dashboard de Qualité
Métriques et Reporting
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Execution(ExecutionMode.CONCURRENT)
class ParallelE2ETest extends AbstractIntegrationTest {
// Tests exécutés en parallèle
// Attention : isolation des données nécessaire
| Stratégie | Temps total | Détail |
| ----------- | ------------- | -------- |
| Sans optimisation | 12 min | Démarrage containers à chaque test |
| Avec reuse | 4 min | Containers réutilisés |
| Avec reuse + parallel | 2.5 min | 4 threads parallèles |
Optimisation des Performances
e2e-tests
org.apache.maven.plugins
maven-failsafe-plugin
3.2.3
/*E2ETest.java
/*IntegrationTest.java
classes
4
true
integration-test
verify
org.jacoco
jacoco-maven-plugin
0.8.11
prepare-agent
report
verify
report
check
check
PACKAGE
LINE
COVEREDRATIO
0.80
Profil Maven pour Tests E2E
.github/workflows/tests.yml
name: E2E Testson:
pull_request:
branches: [main, develop]
push:
branches: [main]jobs:
e2e-tests:
runs-on: ubuntu-latest services:
# Docker-in-Docker pour Testcontainers
docker:
image: docker:24-dind
options: --privileged steps:
- name: Checkout code
uses: actions/checkout@v4 - name: Setup Java 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'maven' - name: Cache Testcontainers images
uses: actions/cache@v4
with:
path: ~/.testcontainers
key: ${{ runner.os }}-testcontainers-${{ hashFiles('/pom.xml') }}
restore-keys: |
${{ runner.os }}-testcontainers- - name: Enable Testcontainers reuse
run: |
mkdir -p ~/.testcontainers
echo "testcontainers.reuse.enable=true" > ~/.testcontainers/testcontainers.properties - name: Run E2E tests
run: mvn verify -P e2e-tests
env:
TESTCONTAINERS_RYUK_DISABLED: false
DOCKER_HOST: unix:///var/run/docker.sock - name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: |
/target/surefire-reports/*.xml
/target/failsafe-reports/*.xml - name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./target/site/jacoco/jacoco.xml
flags: e2e-tests - name: Archive test logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-logs
path: |
/target/surefire-reports
/logs/*.log
Pipeline GitHub Actions Optimisé
Stratégies CI/CD
@BeforeEach
void setupRealisticPaymentMocks() {
// Succès (90% des cas)
stubFor(post(urlEqualTo("/payments"))
.withRequestBody(matchingJsonPath("$.amount", matching("^[0-9]{1,3}\\.[0-9]{2}$")))
.willReturn(aResponse()
.withStatus(200)
.withTransformers("response-template")
.withBody("""
{
"paymentId": "pay_{{randomValue type='UUID'}}",
"status": "APPROVED",
"transactionDate": "{{now format='yyyy-MM-dd HH:mm:ss'}}",
"processingTime": {{randomValue type='NUMBER' lower=100 upper=500}}
}
"""))); // Échec carte refusée (5%)
stubFor(post(urlEqualTo("/payments"))
.withRequestBody(matchingJsonPath("$.customerId", containing("fail")))
.willReturn(aResponse()
.withStatus(402)
.withBody("""
{
"error": "CARD_DECLINED",
"message": "Insufficient funds"
}
"""))); // Timeout (5%)
stubFor(post(urlEqualTo("/payments"))
.withRequestBody(matchingJsonPath("$.customerId", containing("slow")))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(5000)));
Mock de Réponses Réalistes
@Test
@DisplayName("Should retry payment on temporary failures")
void shouldRetryPaymentOnTemporaryFailures() {
// GIVEN: Scénario WireMock : 2 échecs puis succès
stubFor(post(urlEqualTo("/payments"))
.inScenario("Payment Retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503)) // Service unavailable
.willSetStateTo("First Failure")); stubFor(post(urlEqualTo("/payments"))
.inScenario("Payment Retry")
.whenScenarioStateIs("First Failure")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("Second Failure")); stubFor(post(urlEqualTo("/payments"))
.inScenario("Payment Retry")
.whenScenarioStateIs("Second Failure")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"paymentId\": \"pay_retry_success\", \"status\": \"APPROVED\"}"))); // WHEN: Créer commande (avec retry configuré dans l'app)
given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_retry",
"items": [{"productId": "product_123", "quantity": 1, "unitPrice": 49.99}],
"paymentMethod": "CREDIT_CARD"
}
""")
.when()
.post("/orders")
.then()
.statusCode(201)
.body("status", equalTo("CONFIRMED")); // THEN: 3 tentatives effectuées
verify(exactly(3), postRequestedFor(urlEqualTo("/payments")));
Simulation d'API avec États
WireMock Avancé : Scénarios et Stateful
@Test
@DisplayName("Should prevent overselling with concurrent orders")
void shouldPreventOversellingWithConcurrentOrders() throws InterruptedException {
// GIVEN: Stock limité (5 unités)
productRepository.save(Product.builder()
.productId("limited_product")
.name("Limited Edition Item")
.stock(5)
.price(99.99)
.build()); stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"paymentId\": \"pay_concurrent\", \"status\": \"APPROVED\"}"))); // WHEN: 10 commandes concurrentes (chacune pour 1 unité)
int concurrentOrders = 10;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(concurrentOrders);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0); for (int i = 0; i < concurrentOrders; i++) {
new Thread(() -> {
try {
startLatch.await(); // Attendre signal de départ int statusCode = given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_%s",
"items": [{"productId": "limited_product", "quantity": 1, "unitPrice": 99.99}],
"paymentMethod": "CREDIT_CARD"
}
""".formatted(UUID.randomUUID()))
.when()
.post("/orders")
.then()
.extract()
.statusCode(); if (statusCode == 201) {
successCount.incrementAndGet();
} else if (statusCode == 409) { // OUT_OF_STOCK
failureCount.incrementAndGet();
}
} catch (Exception e) {
failureCount.incrementAndGet();
} finally {
doneLatch.countDown();
}
}).start();
} // Démarrer toutes les threads en même temps
startLatch.countDown();
doneLatch.await(30, TimeUnit.SECONDS); // THEN: Seulement 5 commandes acceptées
assertThat(successCount.get()).isEqualTo(5);
assertThat(failureCount.get()).isEqualTo(5); // Vérifier stock final
Product product = productRepository.findById("limited_product").orElseThrow();
assertThat(product.getStock()).isZero();
Test 2 : Gestion du Stock avec Transactions
package com.example.ecommerce.orders;import com.example.ecommerce.AbstractIntegrationTest;
import com.github.tomakehurst.wiremock.WireMockServer;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.test.context.EmbeddedKafka;import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.*;@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderE2ETest extends AbstractIntegrationTest { @Autowired
private OrderRepository orderRepository; private static WireMockServer wireMockServer;
private static CountDownLatch kafkaLatch; @BeforeAll
static void setupWireMock() {
wireMockServer = new WireMockServer(8090);
wireMockServer.start();
configureFor("localhost", 8090);
} @AfterAll
static void teardownWireMock() {
wireMockServer.stop();
} @BeforeEach
void setup() {
RestAssured.port = port;
RestAssured.basePath = "/api"; // Reset WireMock stubs
wireMockServer.resetAll(); // Reset Kafka latch
kafkaLatch = new CountDownLatch(1);
} @Test
@Order(1)
@DisplayName("Should create order with successful payment")
void shouldCreateOrderWithSuccessfulPayment() {
// GIVEN: Mock payment API (succès)
stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"paymentId": "pay_123456",
"status": "APPROVED",
"transactionDate": "2026-02-10T10:30:00Z"
}
"""))); // WHEN: Créer commande
String orderId = given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_001",
"items": [
{
"productId": "product_123",
"quantity": 2,
"unitPrice": 49.99
},
{
"productId": "product_456",
"quantity": 1,
"unitPrice": 29.99
}
],
"paymentMethod": "CREDIT_CARD",
"shippingAddress": {
"street": "123 Main St",
"city": "Paris",
"zipCode": "75001",
"country": "FR"
}
}
""")
.when()
.post("/orders")
.then()
.statusCode(201)
.body("orderId", notNullValue())
.body("status", equalTo("CONFIRMED"))
.body("totalAmount", equalTo(129.97f))
.extract()
.path("orderId"); // THEN: Vérifier état base de données
Order order = orderRepository.findById(orderId).orElseThrow();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
assertThat(order.getPaymentId()).isEqualTo("pay_123456");
assertThat(order.getItems()).hasSize(2); // Vérifier appel Payment API
verify(exactly(1), postRequestedFor(urlEqualTo("/payments"))
.withHeader("Content-Type", equalTo("application/json"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("129.97")))
.withRequestBody(matchingJsonPath("$.currency", equalTo("EUR"))));
} @Test
@Order(2)
@DisplayName("Should reject order when payment fails")
void shouldRejectOrderWhenPaymentFails() {
// GIVEN: Mock payment API (échec)
stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(402)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"error": "INSUFFICIENT_FUNDS",
"message": "Card declined"
}
"""))); // WHEN: Créer commande
given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_002",
"items": [
{
"productId": "product_789",
"quantity": 1,
"unitPrice": 999.99
}
],
"paymentMethod": "CREDIT_CARD"
}
""")
.when()
.post("/orders")
.then()
.statusCode(402)
.body("error", equalTo("PAYMENT_FAILED"))
.body("details", containsString("Card declined")); // THEN: Aucune commande créée
assertThat(orderRepository.count()).isZero();
} @Test
@Order(3)
@DisplayName("Should handle payment API timeout gracefully")
void shouldHandlePaymentTimeout() {
// GIVEN: Mock payment API avec délai
stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(5000) // 5 secondes (> timeout app)
.withBody("{\"status\": \"APPROVED\"}"))); // WHEN: Créer commande
given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_003",
"items": [{"productId": "product_123", "quantity": 1, "unitPrice": 49.99}],
"paymentMethod": "CREDIT_CARD"
}
""")
.when()
.post("/orders")
.then()
.statusCode(504)
.body("error", equalTo("PAYMENT_TIMEOUT"))
.body("message", containsString("Payment service timeout"));
} @Test
@Order(4)
@DisplayName("Should publish OrderCreated event to Kafka")
void shouldPublishOrderCreatedEvent() throws InterruptedException {
// GIVEN: Listener Kafka (injecté via @KafkaListener dans classe interne)
stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"paymentId\": \"pay_789\", \"status\": \"APPROVED\"}"))); // WHEN: Créer commande
String orderId = given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_004",
"items": [{"productId": "product_123", "quantity": 1, "unitPrice": 49.99}],
"paymentMethod": "CREDIT_CARD"
}
""")
.when()
.post("/orders")
.then()
.statusCode(201)
.extract()
.path("orderId"); // THEN: Vérifier event Kafka reçu
boolean eventReceived = kafkaLatch.await(10, TimeUnit.SECONDS);
assertThat(eventReceived).isTrue();
} // Listener Kafka pour tests
@KafkaListener(topics = "order-events", groupId = "test-group")
public void handleOrderEvent(String message) {
kafkaLatch.countDown();
} @Test
@Order(5)
@DisplayName("Should cache order in Redis after creation")
void shouldCacheOrderInRedis() {
// GIVEN: Mock payment
stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"paymentId\": \"pay_999\", \"status\": \"APPROVED\"}"))); // WHEN: Créer commande
String orderId = given()
.contentType(ContentType.JSON)
.body("""
{
"customerId": "customer_005",
"items": [{"productId": "product_123", "quantity": 1, "unitPrice": 49.99}],
"paymentMethod": "CREDIT_CARD"
}
""")
.when()
.post("/orders")
.then()
.statusCode(201)
.extract()
.path("orderId"); // THEN: Premier GET (devrait lire depuis cache)
given()
.when()
.get("/orders/" + orderId)
.then()
.statusCode(200)
.header("X-Cache-Hit", equalTo("true"))
.body("orderId", equalTo(orderId)); // Mesurer performances cache
long start = System.currentTimeMillis();
given()
.when()
.get("/orders/" + orderId)
.then()
.statusCode(200);
long duration = System.currentTimeMillis() - start; // Cache devrait répondre en <10ms
assertThat(duration).isLessThan(10);
}
Test 1 : Création de Commande Complète
Tests E2E avec Testcontainers
~/.testcontainers.properties
package com.example.ecommerce;import org.junit.jupiter.api.BeforeAll;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest { @LocalServerPort
protected int port; // PostgreSQL container
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
.withDatabaseName("ecommerce_test")
.withUsername("test")
.withPassword("test")
.withReuse(true); // Réutiliser entre tests (gain de temps) // Redis container
@Container
static GenericContainer redis = new GenericContainer<>(
DockerImageName.parse("redis:7.2-alpine")
)
.withExposedPorts(6379)
.withReuse(true); // Kafka container
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.3")
)
.withReuse(true); // Configuration dynamique des propriétés Spring
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// PostgreSQL
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword); // Redis
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", redis::getFirstMappedPort); // Kafka
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
} @BeforeAll
static void setup() {
// Vérifier que les containers sont démarrés
if (!postgres.isRunning()) {
throw new RuntimeException("PostgreSQL container is not running");
}
if (!redis.isRunning()) {
throw new RuntimeException("Redis container is not running");
}
if (!kafka.isRunning()) {
throw new RuntimeException("Kafka container is not running");
}
}
Configuration de Base Testcontainers
org.springframework.boot
spring-boot-starter-test
test
org.testcontainers
testcontainers
1.19.4
test
org.testcontainers
postgresql
1.19.4
test
org.testcontainers
kafka
1.19.4
test
org.wiremock
wiremock-standalone
3.3.1
test
io.rest-assured
rest-assured
5.4.0
test
Configuration Maven
Setup Testcontainers
┌─────────────────────────────────────────────────────┐
│ Spring Boot E-Commerce API │
├─────────────────────────────────────────────────────┤
│ │
│ POST /orders │
│ ├─ Valide commande │
│ ├─ Check stock (PostgreSQL) │
│ ├─ Appelle Payment API externe (WireMock) │
│ ├─ Publie OrderCreated event (Kafka) │
│ └─ Cache résultat (Redis) │
│ │
│ GET /orders/{id} │
│ ├─ Lit depuis cache (Redis) │
│ └─ Fallback PostgreSQL │
│ │
└─────────────────────────────────────────────────────┘
↓ ↓ ↓
PostgreSQL Redis Kafka
Architecture de l'Application Test
Les tests E2E ne sont plus un luxe en 2026 : ils sont la garantie de qualité avant chaque déploiement.
Et vous, quelle est votre stratégie de tests ?