Contract Testing for Resilience Boundaries
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.