Contract Testing: Verifying Service Interactions Without E2E Tests
Service A returns a user object. Service B expects that object to have a name field. Team A renames it to fullName. Their tests pass. Team B’s tests pass (they mock Service A’s response). In production, Service B crashes with a null pointer because name doesn’t exist anymore.
End-to-end tests should catch this, right? Maybe, if they’re up to date, if they cover this path, if they run in a shared environment. In practice, E2E tests are slow, flaky, and rarely comprehensive. You need something between unit tests (too isolated) and E2E tests (too expensive).
Consumer-Driven Contracts#
The consumer defines what it expects from the provider. This expectation is the contract.
// Consumer (Service B) defines its contract:
// "I call GET /users/42 and expect:
// - status 200
// - body has 'name' (string) and 'email' (string)"
@Pact(consumer = "ServiceB", provider = "ServiceA")
public RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("user 42 exists")
.uponReceiving("get user by id")
.path("/users/42")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("name", "Alice")
.stringType("email", "alice@example.com"))
.toPact();
}
The contract file is shared with the provider. The provider runs the contract against its actual implementation. If the provider’s response doesn’t match the contract, the provider’s build fails.
Contract Tests vs Integration Tests#
Integration tests verify “does the whole thing work together?” They need running instances of all services. Slow, brittle, environment-dependent.
Contract tests verify “do we agree on the interface?” Each side runs independently. Consumer tests run with a mock provider. Provider tests run with a mock consumer request. Fast, stable, no shared environment needed.
The trade-off: contract tests don’t verify business logic across services. They verify the shape of the communication. You still need some integration tests, just far fewer.
Schema Evolution#
Adding a field? Safe. The contract didn’t mention it, so consumers won’t break. Removing a field? Dangerous. Any consumer expecting it will fail. Renaming a field? That’s a remove plus an add, treat it as a breaking change.
This connects to database migrations without downtime. The expand-contract pattern works for APIs too: add the new field (expand), migrate consumers, remove the old field (contract). Never remove before all consumers have migrated.
When Contracts Break Down#
Contract testing works best for synchronous request/response APIs. For event-driven architectures, you need message contracts: “the consumer expects events with these fields.” Same concept, different transport. For services with dozens of consumers, maintaining contracts becomes its own overhead. At some point you need schema registries (like Avro schemas) that enforce backward compatibility at the infrastructure level.
At Salesforce, we learned this the hard way with the code generation system. 4,000+ service configurations defined service interfaces. When a team changed their service schema, downstream code generation could silently produce incorrect clients. No errors, just wrong code. We built a validation framework that acted as contract enforcement: each service’s schema was validated against its consumers’ expectations before code generation ran. Schema changes that would break any consumer were flagged immediately. That validation framework drove the 80% reduction in review cycles because reviewers no longer had to manually verify cross-service compatibility. The framework did it automatically.
What I’m Learning#
Contract testing fills the gap between “my code works in isolation” and “everything works in production.” It’s not a replacement for integration tests, it’s a faster, more targeted check that catches the most common microservice failure: interface mismatch. The invest-once payoff is large: every time a provider changes their API, every consumer’s contract runs automatically. No coordination meetings, no “did you update the mock?”, just a failed build telling you exactly what broke.
How does your team catch API breaking changes before they reach production?