Skip to main content
resilience patterns in production

Contract Testing for Resilience Boundaries

3 min read Chapter 30 of 40

Contract Testing for Resilience Boundaries

The payment service has a fallback for the fraud detection service. The fallback returns a default FraudScore with decision PERMIT. This fallback was written based on the fraud score schema at the time of implementation. When the fraud detection team adds a new field (riskFactors: List<String>) and the payment service does not update its deserialization, the fallback works but the normal path breaks. Or worse: the normal path works because the HTTP client ignores unknown fields, but the circuit breaker test uses a hardcoded JSON response that does not match the new schema.

Contract tests verify that the consumer (payment service) and producer (fraud detection service) agree on the response format for both success and failure cases.

Contracts for Error Responses

The fraud detection service defines contracts for its error responses:

// PRODUCTION - Contract: fraud service returns 503 when overloaded
Contract.make {
    description "Fraud service returns 503 when overloaded"
    request {
        method POST()
        url "/fraud/score"
        headers {
            contentType applicationJson()
        }
        body([
            paymentId: "PAY-001",
            accountId: "ACC-123",
            amount: 150.00
        ])
    }
    response {
        status 503
        headers {
            header("Retry-After", "30")
        }
        body([
            error: "SERVICE_OVERLOADED",
            message: "Fraud scoring temporarily unavailable"
        ])
    }
}

The payment service’s consumer test verifies that the circuit breaker handles a 503 response correctly:

// PRODUCTION - Consumer-side contract test
@SpringBootTest
@AutoConfigureStubRunner(
        ids = "com.example:fraud-service:+:stubs:8090",
        stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class FraudServiceContractTest {

    @Autowired
    private FraudDetectionService fraudService;

    @Autowired
    private CircuitBreakerRegistry cbRegistry;

    @Test
    void handles503WithRetryAfterHeader() {
        // The stub runner automatically serves the 503 contract
        // from the fraud-service stubs

        // This call should be recorded as a failure by the circuit breaker
        FraudScore result = fraudService.checkFraud(samplePayment());

        // Fallback should have activated
        assertThat(result.isDefault()).isTrue();

        // Circuit breaker should record the failure
        CircuitBreaker cb = cbRegistry.circuitBreaker("fraudDetection");
        assertThat(cb.getMetrics().getNumberOfFailedCalls())
                .isGreaterThan(0);
    }
}

Contracts for Slow Responses

// PRODUCTION - Contract: fraud service responds slowly
Contract.make {
    description "Fraud service responds with high latency"
    request {
        method POST()
        url "/fraud/score"
        headers {
            contentType applicationJson()
        }
        body([
            paymentId: $(anyNonBlankString()),
            accountId: $(anyNonBlankString()),
            amount: $(anyDouble())
        ])
    }
    response {
        status 200
        fixedDelayMilliseconds 3000  // Slower than the TimeLimiter
        body([
            score: 0.1,
            decision: "PERMIT",
            factors: []
        ])
    }
}

This contract verifies that the payment service handles a response that arrives after the TimeLimiter timeout. The test confirms that the fallback activates and the response is not partially deserialized.

Keeping Contracts and Fallbacks in Sync

The contract is the source of truth for the response schema. When the fraud detection team changes the response format, they update the contract. The payment service’s CI pipeline detects the contract change and runs the consumer tests. If the fallback’s hardcoded response does not match the new schema, the test fails.

// PRODUCTION - Verify fallback response matches contract schema
@Test
void fallbackResponseMatchesContractSchema() {
    FraudScore fallback = FraudScore.defaultPermit(samplePayment());

    // Serialize and deserialize through the same ObjectMapper
    // used by the HTTP client
    ObjectMapper mapper = new ObjectMapper();
    String json = mapper.writeValueAsString(fallback);
    FraudScore deserialized = mapper.readValue(json, FraudScore.class);

    // All fields that the fraud service contract specifies must be present
    assertThat(deserialized.score()).isNotNull();
    assertThat(deserialized.decision()).isNotNull();
    // If the contract adds riskFactors, this test should verify it too
}

Contract testing closes the gap between resilience pattern testing (does the circuit breaker open?) and service compatibility testing (does the fallback produce a valid response?). Without contracts, resilience tests can pass while the system is broken: the circuit breaker opens correctly, the fallback returns a value, but the value is incompatible with the downstream consumer.