Week 22: Contract Testing & Advanced Test Strategy

Week 8's test suite proves your service is correct in isolation — unit tests, slice tests, and Testcontainers integration tests all exercise your code against a real database. None of them prove that a change to your API doesn't silently break every other service calling it, which is exactly the risk Weeks 17–18 introduced once real services started depending on each other. This week adds the two things a strong test suite is still missing: proof that a service and its consumers agree on a contract, and proof that your existing tests actually catch bugs rather than just executing code.

Module 19 of 22 Week 22 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a consumer-driven contract test with Pact and verify it on the provider side
  • Run mutation testing to check whether your test suite actually catches real bugs
  • Test resilience behavior — timeouts, retries, circuit breakers — under deliberate fault injection

1. Consumer-Driven Contract Testing with Pact

Week 18's saga assumed inventory-service would keep publishing InventoryReserved events in the shape order-service expects. Nothing in either service's own test suite verifies that assumption — each service's tests only prove it's internally correct, in isolation. A full end-to-end environment with every service running would catch a mismatch, but it's slow, flaky, and requires every team to coordinate a deploy just to run a test. Contract testing verifies the same boundary without either extreme.

In consumer-driven contract testing, the consumer writes a test expressing exactly what it expects from the provider — this generates a machine-readable "pact" file — and the provider later replays that exact expectation against its real implementation to confirm it still holds.

order-service (the consumer) — defining the expected contract
@ExtendWith(PactConsumerTestExt.class)
class InventoryServiceContractTest {

    @Pact(consumer = "order-service", provider = "inventory-service")
    RequestResponsePact reserveInventoryPact(PactDslWithProvider builder) {
        return builder
            .given("SKU ABC123 has stock available")
            .uponReceiving("a request to reserve inventory")
            .path("/api/inventory/reserve")
            .method("POST")
            .body("""
                {"sku": "ABC123", "quantity": 2}
                """)
            .willRespondWith()
            .status(200)
            .body(newJsonBody(o -> {
                o.stringType("reservationId");
                o.stringValue("status", "RESERVED");
            }).build())
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "reserveInventoryPact")
    void reservesInventorySuccessfully(MockServer mockServer) {
        var response = inventoryClient(mockServer.getUrl()).reserve("ABC123", 2);
        assertThat(response.status()).isEqualTo("RESERVED");
    }
}

Running this test produces a pact file — a JSON document literally describing "when I send this request, I expect this response shape back." That file is published to a shared Pact Broker, which inventory-service's own build then downloads and replays:

inventory-service (the provider) — verifying the same contract
@Provider("inventory-service")
@PactBroker(url = "https://pact-broker.acme.internal")
class InventoryServiceProviderTest {

    @State("SKU ABC123 has stock available")
    void setupStockAvailable() {
        inventoryRepository.save(new Inventory("ABC123", 10));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }
}

If inventory-service ever changes its response shape in a way that breaks what order-service actually depends on, this provider-side verification fails in inventory-service's own CI pipeline — before it's ever deployed, and without order-service needing to be running anywhere nearby.

Consumer-driven, not provider-driven, is the key idea

The contract is written from the consumer's actual usage, not the provider's guess at what might be needed — this keeps the contract minimal and focused on real dependencies rather than testing every field the provider happens to expose. It's also the mechanism that lets a provider safely add new fields nobody depends on yet without breaking any contract, since consumer-driven contracts only assert on what's actually consumed.

2. Mutation Testing

Code coverage answers "did my tests execute this line?" It says nothing about whether those tests would actually notice if that line's logic were wrong — a test that calls a method and asserts nothing meaningful about its result achieves 100% coverage of that method while catching zero bugs in it. Mutation testing answers the sharper question directly: it automatically introduces small, deliberate bugs ("mutants") into your code — flipping a > to >=, changing a + to -, negating a boolean — and reruns your test suite against each mutated version.

pom.xml — PIT (pitest), the standard JVM mutation testing tool
<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.16.1</version>
    <configuration>
        <targetClasses>
            <param>com.codeverse.week22.*</param>
        </targetClasses>
    </configuration>
</plugin>

If a test suite catches the mutant — some test fails because the mutated behavior differs from what was asserted — the mutant is killed, a good outcome. If every test still passes despite the introduced bug, the mutant survives — a concrete, specific sign that no test actually verifies that piece of logic correctly, no matter what the coverage report claims.

a surviving mutant, made concrete
// original
boolean isEligibleForDiscount(int orderTotal) {
    return orderTotal > 100;
}

// PIT's mutant: flips > to >=
boolean isEligibleForDiscount(int orderTotal) {
    return orderTotal >= 100;
}

// If this test suite's only test is isEligibleForDiscount(150) -> true,
// BOTH the original and the mutant pass it. The mutant SURVIVES --
// revealing that the boundary condition (orderTotal == 100) is
// completely untested, something 100% line coverage would never show.

That surviving mutant points to exactly the missing test: an assertion at orderTotal = 100 itself, the boundary the two operators actually disagree on. Mutation testing doesn't replace writing good tests — it's a tool for finding the specific gaps a coverage report can't see.

Run mutation testing on the logic that actually matters, not the whole codebase

PIT is genuinely slow — it reruns the full test suite once per mutant, which multiplies quickly across a large codebase. Scope targetClasses to business-critical logic (pricing, eligibility rules, the saga compensation logic from Week 18) rather than running it against getters, DTOs, and configuration classes where a surviving mutant tells you nothing useful.

3. Testing Resilience Under Fault Injection

Week 9's Resilience4j circuit breaker and retry configuration has never actually been tested against a real failure — it's been trusted to work because the configuration looks correct. WireMock, already familiar from mocking HTTP dependencies, can simulate the specific failure modes those resilience patterns exist to handle, proving the behavior rather than assuming it.

simulating a timeout to test the configured fallback
@Test
void fallsBackWhenInventoryServiceTimesOut() {
    wireMock.stubFor(post(urlEqualTo("/api/inventory/reserve"))
        .willReturn(aResponse()
            .withFixedDelay(5000)   // exceeds the configured timeout
            .withStatus(200)));

    ReservationResult result = inventoryClient.reserve("ABC123", 2);

    assertThat(result.status()).isEqualTo(ReservationResult.Status.FALLBACK_UNAVAILABLE);
}

@Test
void circuitOpensAfterConsecutiveFailures() {
    wireMock.stubFor(post(urlEqualTo("/api/inventory/reserve"))
        .willReturn(aResponse().withStatus(500)));

    for (int i = 0; i < 10; i++) {
        inventoryClient.reserve("ABC123", 1);   // trip the circuit breaker
    }

    // the circuit should now be OPEN -- calls fail fast without even
    // hitting WireMock, which we can verify directly:
    assertThat(circuitBreakerRegistry.circuitBreaker("inventoryService").getState())
        .isEqualTo(CircuitBreaker.State.OPEN);
}

These tests aren't checking that your business logic is correct — they're checking that the resilience configuration itself behaves as designed: that a slow dependency actually triggers the fallback instead of hanging the caller, and that enough consecutive failures actually trip the circuit breaker rather than the threshold being silently misconfigured.

An untested resilience config is a config you're hoping works

It's easy to write @CircuitBreaker/@Retry annotations, see the app start successfully, and assume the behavior is correct. Nothing about a clean startup proves the threshold, timeout, or fallback actually fire under the conditions they're designed for — a resilience pattern that's never been tested against a simulated failure is, in practice, no more trustworthy than one that isn't there at all.

4. Hands-on Exercise

Hands-on

Write a contract test, find a real mutation-testing gap, and prove resilience under fault

Apply all three practices to the services from Weeks 9–10 and 18.

Requirements:

  1. Write a Pact consumer test in one service expressing exactly what it expects from a real call to the other, and confirm the generated pact file matches the interaction.
  2. Write the corresponding provider verification test in the second service, and confirm it passes against the real implementation.
  3. Deliberately break the provider's response shape (rename a field) and confirm the provider verification test fails, catching the break before any deploy.
  4. Run PIT mutation testing against one class containing real business logic (not a DTO or getter), find at least one surviving mutant, and write the missing test that kills it.
  5. Write a WireMock-based test proving your Week 9 circuit breaker actually opens after the configured number of consecutive failures.
Hint

PIT's HTML report (generated under target/pit-reports) highlights every surviving mutant directly on the source line it mutated — read that report rather than the console summary, since the console only gives you an aggregate percentage with none of the specific, actionable detail.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why does the consumer, not the provider, write the pact in consumer-driven contract testing?

Writing the contract from the consumer's real usage keeps it scoped to exactly what's actually depended on, rather than every field the provider happens to expose. It also means a provider can freely add new fields or capabilities that no consumer uses yet without breaking any contract — only changes to what's genuinely consumed cause a verification failure.

Q2

Why can a method have 100% line coverage and still have a surviving mutant?

Line coverage only measures whether a line of code executed during a test run, not whether any assertion actually depended on that line's specific behavior being correct. A test can call a method and execute every line without asserting anything precise enough to notice if that line's logic were subtly wrong — which is exactly the gap a surviving mutant exposes.

Q3

Why doesn't a clean application startup prove a configured circuit breaker or retry policy actually works?

Startup only confirms the annotations and configuration are syntactically valid and the application context loads — it never exercises the failure path the resilience pattern is designed to handle. Whether the timeout actually triggers a fallback, or the circuit actually opens after enough failures, can only be verified by deliberately simulating that failure, which is what the fault-injection tests in Section 3 do.