Guided Walkthrough — Component, Contract, Integration Tests

13 min read

The project brief gave you the system and the deliverables. This lesson walks you through the implementation of the six most technically dense pieces: the Order Service component test suite, the Pact contract tests, the Docker Compose integration environment, the saga compensation tests, the circuit breaker chaos test, and the CI pipeline configuration. Each section shows production-quality code you can adapt directly — not toy examples.

Part 1 — Order Service component test suite

The Order Service depends on three synchronous downstream services and publishes to Kafka. Your component test starts all four infrastructure dependencies — three WireMock HTTP servers, one PostgreSQL container, one Kafka container — then fires real HTTP requests at a fully booted Spring Boot application.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceComponentTest {
 
    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:15").withDatabaseName("orders_test");
 
    @Container
    static KafkaContainer kafka =
        new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
 
    @Autowired private TestRestTemplate restTemplate;
    @Autowired private JdbcTemplate jdbc;
 
    private WireMockServer userService;
    private WireMockServer productService;
    private WireMockServer paymentService;
 
    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", postgres::getJdbcUrl);
        r.add("spring.datasource.username", postgres::getUsername);
        r.add("spring.datasource.password", postgres::getPassword);
        r.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }
 
    @BeforeEach
    void setup() {
        userService    = new WireMockServer(wireMockConfig().dynamicPort()); userService.start();
        productService = new WireMockServer(wireMockConfig().dynamicPort()); productService.start();
        paymentService = new WireMockServer(wireMockConfig().dynamicPort()); paymentService.start();
        // inject ports into Spring context via reflection or @DynamicPropertySource-compatible mechanism
    }
 
    @AfterEach
    void teardown() {
        userService.stop(); productService.stop(); paymentService.stop();
    }
 
    @Test
    void shouldCreateOrderAndPublishEvent() throws Exception {
        userService.stubFor(get("/users/10").willReturn(okJson(
            """{"id":10,"name":"Alice","email":"alice@example.com","status":"ACTIVE"}""")));
        productService.stubFor(get("/products/50").willReturn(okJson(
            """{"id":50,"name":"Laptop","price":999.99,"stock":10}""")));
        productService.stubFor(post("/products/50/reserve").willReturn(okJson(
            """{"reserved":true,"remainingStock":9}""")));
        paymentService.stubFor(post("/charges").willReturn(
            aResponse().withStatus(201).withHeader("Content-Type","application/json")
                .withBody("""{"chargeId":"ch_test_001","status":"SUCCEEDED"}""")));
 
        var response = restTemplate.postForEntity("/orders",
            new CreateOrderRequest(10L, 50L, 1), OrderResponse.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getStatus()).isEqualTo("PENDING");
 
        // Verify database persistence
        var count = jdbc.queryForObject(
            "SELECT COUNT(*) FROM orders WHERE user_id=10 AND status='PENDING'", Integer.class);
        assertThat(count).isEqualTo(1);
    }
 
    @Test
    void shouldCancelOrderAndReleaseInventory() {
        // ... stub and create order first, then DELETE /orders/{id}
        productService.stubFor(delete("/products/50/reserve/ORDER-123").willReturn(ok()));
 
        restTemplate.delete("/orders/ORDER-123");
 
        productService.verify(deleteRequestedFor(urlEqualTo("/products/50/reserve/ORDER-123")));
        var status = jdbc.queryForObject(
            "SELECT status FROM orders WHERE id='ORDER-123'", String.class);
        assertThat(status).isEqualTo("CANCELLED");
    }
 
    @Test
    void shouldReturnPaymentRequiredWhenPaymentFails() {
        userService.stubFor(get("/users/10").willReturn(okJson(
            """{"id":10,"name":"Alice","email":"alice@example.com","status":"ACTIVE"}""")));
        productService.stubFor(get("/products/50").willReturn(okJson(
            """{"id":50,"name":"Laptop","price":999.99,"stock":10}""")));
        productService.stubFor(post("/products/50/reserve").willReturn(okJson(
            """{"reserved":true,"remainingStock":9}""")));
        paymentService.stubFor(post("/charges")
            .willReturn(aResponse().withStatus(402).withBody("""{"error":"insufficient_funds"}""")));
        // Payment fails → inventory must be released
        productService.stubFor(delete(urlMatching("/products/50/reserve/.*")).willReturn(ok()));
 
        var response = restTemplate.postForEntity("/orders",
            new CreateOrderRequest(10L, 50L, 1), ErrorResponse.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED);
        productService.verify(deleteRequestedFor(urlMatching("/products/50/reserve/.*")));
    }
}

Part 2 — Pact contract tests

Order Service is the consumer. It defines what it needs from User Service. User Service verifies that it can fulfil those needs.

Consumer test (in Order Service codebase):

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService", pactVersion = PactSpecVersion.V4)
class OrderToUserServicePactTest {
 
    @Pact(consumer = "OrderService")
    public V4Pact getUserById(PactDslWithProvider builder) {
        return builder
            .given("user 10 exists and is active")
            .uponReceiving("a request for user 10")
                .method("GET").path("/users/10")
            .willRespondWith()
                .status(200)
                .body(LambdaDsl.newJsonBody(body -> body
                    .numberType("id", 10)
                    .stringType("name", "Alice")
                    .stringMatcher("email", ".+@.+\\..+", "alice@example.com")
                    .stringType("status", "ACTIVE")
                ).build())
            .toPact(V4Pact.class);
    }
 
    @Test
    @PactTestFor(pactMethod = "getUserById")
    void shouldRetrieveUserForOrder(MockServer mockServer) {
        var client = new UserServiceClient(mockServer.getUrl());
        var user = client.getUser(10L);
        assertThat(user.getId()).isEqualTo(10L);
        assertThat(user.getStatus()).isEqualTo("ACTIVE");
    }
}

Provider verification (in User Service codebase):

@Provider("UserService")
@PactBroker(url = "${PACT_BROKER_URL}", authentication =
    @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}"))
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserServicePactVerificationTest {
 
    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }
 
    @State("user 10 exists and is active")
    void setupActiveUser() {
        userRepository.save(new User(10L, "Alice", "alice@example.com", "ACTIVE"));
    }
 
    @BeforeEach
    void before(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }
}

Publish pacts and run can-i-deploy in CI:

./mvnw pact:publish -Dpact.broker.url=$PACT_BROKER_URL -Dpact.broker.token=$PACT_BROKER_TOKEN
pact-broker can-i-deploy \
  --pacticipant OrderService --version $GIT_SHA \
  --to-environment production \
  --broker-base-url $PACT_BROKER_URL

Part 3 — Docker Compose integration environment

# docker-compose.test.yml
services:
  postgres-orders:
    image: postgres:15
    environment: { POSTGRES_DB: orders, POSTGRES_PASSWORD: test }
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5
 
  kafka:
    image: confluentinc/cp-kafka:7.4.0
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
    depends_on:
      zookeeper: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"]
      interval: 10s
      retries: 5
 
  order-service:
    build: ./order-service
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-orders:5432/orders
      USER_SERVICE_URL: http://user-service:8080
      PRODUCT_SERVICE_URL: http://product-service:8080
      PAYMENT_SERVICE_URL: http://payment-service:8080
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      postgres-orders: { condition: service_healthy }
      kafka: { condition: service_healthy }
      user-service: { condition: service_healthy }
      product-service: { condition: service_healthy }
      payment-service: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      retries: 10

Integration test using ComposeContainer:

@Testcontainers
class PlaceOrderIntegrationTest {
 
    @Container
    static ComposeContainer env = new ComposeContainer(new File("docker-compose.test.yml"))
        .withExposedService("order-service", 8080, Wait.forHttp("/actuator/health").forStatusCode(200))
        .withExposedService("product-service", 8080, Wait.forHttp("/actuator/health").forStatusCode(200));
 
    @Test
    void shouldPlaceOrderEndToEnd() {
        var orderServiceUrl = "http://" + env.getServiceHost("order-service", 8080)
            + ":" + env.getServicePort("order-service", 8080);
 
        var response = RestAssured.given()
            .baseUri(orderServiceUrl)
            .contentType("application/json")
            .body("""{"userId":1,"productId":1,"quantity":1}""")
            .post("/orders")
            .then().statusCode(201).extract().response();
 
        String orderId = response.jsonPath().getString("id");
 
        await().atMost(15, SECONDS).pollInterval(500, MILLISECONDS).untilAsserted(() -> {
            var status = RestAssured.given().baseUri(orderServiceUrl)
                .get("/orders/" + orderId)
                .then().statusCode(200).extract().jsonPath().getString("status");
            assertThat(status).isEqualTo("CONFIRMED");
        });
    }
}

Step 1 of 6

Component tests

Order Service boots with WireMock + Postgres + Kafka containers. 20 tests run in ~45 seconds. Zero network calls leave the JVM.

Part 4 — Saga compensation test

@Test
void shouldReleaseInventoryWhenPaymentFails() {
    // Arrange: reserve will succeed, payment will fail
    productService.stubFor(post("/products/50/reserve")
        .willReturn(okJson("""{"reserved":true,"remainingStock":9}""")));
    paymentService.stubFor(post("/charges")
        .willReturn(aResponse().withStatus(402).withBody("""{"error":"card_declined"}""")));
    // Compensation: Order Service must release inventory on payment failure
    productService.stubFor(delete(urlMatching("/products/50/reserve/.*")).willReturn(ok()));
 
    restTemplate.postForEntity("/orders", new CreateOrderRequest(10L, 50L, 1), OrderResponse.class);
 
    // Verify compensation was called
    productService.verify(1, deleteRequestedFor(urlMatching("/products/50/reserve/.*")));
    // Verify order reached terminal cancelled state
    var status = jdbc.queryForObject("SELECT status FROM orders WHERE user_id=10", String.class);
    assertThat(status).isEqualTo("CANCELLED");
}
 
@Test
void shouldCompleteOrderWhenNotificationServiceIsDown() {
    // Notification is non-critical — order must still confirm
    // (Notification Service is not even started in this component test)
    userService.stubFor(get("/users/10").willReturn(okJson(userJson)));
    productService.stubFor(get("/products/50").willReturn(okJson(productJson)));
    productService.stubFor(post("/products/50/reserve").willReturn(okJson(reservedJson)));
    paymentService.stubFor(post("/charges").willReturn(aResponse().withStatus(201).withBody(chargeJson)));
 
    var response = restTemplate.postForEntity("/orders",
        new CreateOrderRequest(10L, 50L, 1), OrderResponse.class);
 
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    // Order reaches PENDING (async Kafka will drive it to CONFIRMED separately)
    assertThat(response.getBody().getStatus()).isEqualTo("PENDING");
}

Part 5 — Circuit breaker chaos test

@Test
void shouldOpenCircuitBreakerWhenPaymentServiceSlow() throws Exception {
    ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0")
        .withNetwork(testNetwork);
    toxiproxy.start();
 
    var paymentProxy = toxiproxy.getProxy(paymentServiceContainer, 8080);
    // Inject 3-second latency — exceeds the 1-second circuit breaker timeout
    paymentProxy.toxics().latency("payment-slow", ToxicDirection.DOWNSTREAM, 3000);
 
    // First three calls time out (circuit breaker counting failures)
    for (int i = 0; i < 3; i++) {
        var resp = restTemplate.postForEntity("/orders", validRequest, OrderResponse.class);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
    }
 
    // Fourth call: circuit is OPEN — Order Service should NOT call Payment Service
    long start = System.currentTimeMillis();
    var fastFail = restTemplate.postForEntity("/orders", validRequest, OrderResponse.class);
    long elapsed = System.currentTimeMillis() - start;
 
    assertThat(fastFail.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
    assertThat(elapsed).isLessThan(200); // fast-fail, no timeout wait
 
    // Remove toxic, wait for half-open window, verify circuit closes
    paymentProxy.toxics().get("payment-slow").remove();
    Thread.sleep(circuitBreakerWaitDurationMs + 500);
 
    var recovered = restTemplate.postForEntity("/orders", validRequest, OrderResponse.class);
    assertThat(recovered.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}

Part 6 — CI pipeline

# .github/workflows/test.yml
name: Test Suite
 
on: [push, pull_request]
 
jobs:
  fast-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: ./mvnw test -Dtest="**/*ComponentTest,**/*PactTest" -pl order-service,user-service,product-service
      - name: can-i-deploy
        run: |
          pact-broker can-i-deploy \
            --pacticipant OrderService --version ${{ github.sha }} \
            --to-environment staging \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }}
 
  integration-tests:
    runs-on: ubuntu-latest
    needs: fast-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: ./mvnw test -Dtest="**/*IntegrationTest" -pl integration-tests
 
  chaos-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: ./mvnw test -Dtest="**/*ChaosTest" -pl resilience-tests
 
  e2e-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci && npx playwright test
        working-directory: e2e

⚠️ Common mistakes

  • Writing integration tests before component tests pass. Integration tests against Docker Compose are slow to debug — a 15-minute feedback loop per failed assertion. Always make your component tests comprehensive first. If the component tests pass and the integration test fails, you know the bug is in service coordination, not in your service's own logic. That dramatically narrows the search space.
  • Not resetting WireMock between test methods. In the component test suite above, each @BeforeEach creates new WireMock servers. If you reuse a shared server without calling resetAll(), a stub configured for test A can match a request in test B. The symptom is a test that passes when run alone but fails in suite — the classic intermittent CI failure.
  • Asserting only on the first service in the saga. The payment failure test must assert both that the order reached CANCELLED and that the inventory release call was made. Asserting only on order status proves the saga ended correctly; asserting on the WireMock verify proves compensation actually ran. Both are required. A bug could mark the order as CANCELLED without ever calling the release endpoint.

🎯 Practice task

Implement the full component test suite for Order Service — 90 minutes.

  1. Set up the @SpringBootTest class with PostgreSQLContainer, KafkaContainer, and three WireMock servers. Verify the application context loads cleanly before writing any tests.
  2. Implement the happy-path test exactly as shown. Run it. Fix any missing stubs the stack trace reveals (WireMock logs every unmatched request — read the log carefully).
  3. Implement the payment failure saga test. Assert both the order status and the inventory release WireMock verification.
  4. Implement the notification-service-down test. Because Notification Service is async (Kafka), you need to verify the order reaches PENDING via the HTTP response — not CONFIRMED, which happens asynchronously. Confirm your assertion matches the synchronous response contract, not the eventual state.
  5. Run the full class with ./mvnw test -Dtest=OrderServiceComponentTest. All three tests should pass in under 60 seconds (after the first Docker image pull). If any test is flaky, check for port conflicts (use dynamicPort()), missing resetAll() calls, or race conditions in container startup.

// tip to track lessons you complete and pick up where you left off across devices.