20 microservices. Service A's client breaks after Service B changes its API format. All integration tests pass — why? Because A's tests didn't see B's new format, and B doesn't know A's usage patterns. Contract testing closes that gap. This guide covers consumer-driven contracts and how Pact actually works.
Limits of Microservice Integration Tests
Naive approach:
Service A test:
- actually spin up service B (or stub its deps)
- HTTP call → verify response
Problems:
1. To run B in CI you also need B's deps (B → C → D → DB)
2. Until B's new version deploys, A's tests don't verify the new format
3. 20 services → one test spins up 20 services → minutes long + flaky
4. Inter-service dependency cycles?
→ The "spin up 20 services for one test" cost and unreliability nightmareContracts — A Two-Sided Agreement
Contract = a spec of how one service uses another's API.
Example (Pact):
{
consumer: "service-a",
provider: "service-b",
interactions: [
{
description: "get user by id",
request: {
method: "GET",
path: "/users/42",
headers: {"Accept": "application/json"}
},
response: {
status: 200,
body: {
id: 42,
name: matcher.string("Alice"), // not exact value, type
email: matcher.regex(/.+@.+/)
}
}
}
]
}
→ Stored as JSON. Git-versioned. Shared via Pact Broker (or git).Consumer-Driven Contract Testing
Step 1 — Consumer (A) generates the contract
test("get user from B", () => {
pact.given("user 42 exists")
.uponReceiving("a GET")
.withRequest({method: "GET", path: "/users/42"})
.willRespondWith({status: 200, body: {id: 42, name: "Alice"}});
// Run A's code — Pact's mock server responds per contract
const user = await getUserFromB(42);
expect(user.name).toBe("Alice");
});
→ On pass, the contract JSON is generated → uploaded to Pact Broker
Step 2 — Provider (B) verifies the contract
pact.verifyProvider({
providerName: "service-b",
providerBaseUrl: "http://localhost:8080",
pactBrokerUrl: "...",
stateHandlers: {
"user 42 exists": () => db.users.insert({id: 42, name: "Alice"})
}
});
→ With B actually running, every interaction in the contract is invoked → verified.
→ If B's new changes break the contract → CI fails.
→ Both steps passing = A↔B integration guaranteed (without running both together).What "Consumer-Driven" Means
Not "provider (B) writes the spec and consumer (A) follows" — consumer (A) declares "this is how I use it" and provider (B) must satisfy it.
Benefits:
- B learns which fields are actually used
- B can safely remove unused fields (after verifying no consumer uses them)
- A specs only what it needs — avoids overspecification
vs Schema-first (OpenAPI):
- OpenAPI: B says "I provide this API" → A consumes as it likes
- Pact: A says "this is how I use it" → B satisfies it
- Both valuable — OpenAPI for public APIs, Pact for internal service-to-serviceProvider States — the "given" Clause
Contract test trap — what if "user 42 exists" isn't true at verify time?
Fix: provider state (Pact's given clause):
consumer test:
pact.given("user 42 exists")
.uponReceiving("get user")
...
provider verification:
stateHandlers: {
"user 42 exists": async () => {
await db.clear();
await db.users.insert({id: 42, name: "Alice"});
},
"user 42 does not exist": async () => {
await db.clear();
}
}
→ Provider sets up that state before verifying. Matches consumer's assumption.Matchers — Flexible Verification
Too-strict contracts break when the provider changes timestamps etc.
Matchers verify type/format only, allow value freedom:
response: {
status: 200,
body: {
id: matcher.integer(42), // any integer is OK (42 is an example)
name: matcher.string("Alice"), // any string
created_at: matcher.iso8601DateTime(), // any ISO datetime
email: matcher.regex(/^.+@.+$/),
items: matcher.eachLike({id: integer(), price: decimal()})
}
}
→ Allows provider's exact-value variability (timestamps, ids), preserves structure.Pact Broker — Sharing + Workflow
Self-host or SaaS (PactFlow).
Features:
- Store contract JSON (per consumer/provider)
- Version tags (main, staging, production)
- "can-i-deploy?" query — "Is service A v3 compatible with B in production?"
- Dep graph visualization (which service depends on which)
- Change webhooks (new consumer contract → auto-trigger provider CI)
CI integration:
- Consumer build → publish contract → tag "ready for verification"
- Provider build → pull contract → verify → tag "verified"
- Pre-deploy: can-i-deploy → only ship if both sides compatibleContract vs Integration vs E2E
| What's verified | Method | Cost |
|---|---|---|
| One function / class | Unit | Cheap |
| One service incl. DB | Integration | Medium |
| Service A ↔ Service B contract | Contract (Pact) | Cheap (no need to run both) |
| End-to-end user flow | E2E | Very expensive |
Common Pitfalls
- Too-strict contracts — without matchers, exact-value comparison breaks on small provider changes. Use matchers.
- Missing provider states — without "user 42 exists" preconditions, verifies fail due to DB state.
- Ignoring dependency graph — A→B→C contracts don't cover A→C direct calls. Contract per dep.
- Contracts as the only verification — contracts verify interfaces only. Business logic / performance still need separate tests.
- Operating without a broker — sharing JSON via git works but loses version tags / can-i-deploy workflow.
Wrap-up
Contract testing — "guarantee integration between two services without running both". Consumer-driven means "spec only how I actually use it" → fewer false positives.
Practical — worth adopting at 5+ microservices. Pact for internal service-to-service, OpenAPI for external APIs. The Pact Broker + can-i-deploy workflow is core to safe deploys.