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.

Tests End-to-End pour Spring Boot : Testcontainers, WireMock et Stratégies CI

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égieTemps totalDétail
--------------------------------
Sans optimisation12 minDémarrage containers à chaque test
Avec reuse4 minContainers réutilisés
Avec reuse + parallel2.5 min4 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 ?