Contract Testing for Microservices
- Shawn West
- Apr 22
- 6 min read
Updated: Aug 18
Contract testing is sold as the thing that replaces the end-to-end suite. It replaces a specific slice of it — and only the slice your contract is precise enough to express. Most contracts, written the default way, forbid almost nothing.
Tolvern Freight had contract tests between its booking UI and its rates service. Broker set up, consumer pact published, provider verifying in CI, can-i-deploy wired into the pipeline. Everything a contract-testing rollout is supposed to have.
Then the rates team added "economy_plus" to the carrier_tier field. Their tests passed. The consumer's contract verification passed. can-i-deploy said yes. They deployed, and the booking UI started rendering shipments with a blank price — its switch on carrier_tier had no branch for the new value and fell through to an empty string.
Nobody had done anything wrong by the rules they'd been given. The contract said carrier_tier was a string. "economy_plus" is a string.
A contract is worth exactly the futures it forbids
The useful way to read a contract is not "what does it describe" but "which changes to the provider would make this fail?" That set is the entire value of the artifact. Everything outside it is a change you will discover in production.
Provider changes come in two shapes, and they are not symmetric:
Narrowing — removing a field, making an optional field required, tightening a type, dropping a status code. The provider is now giving less than before.
Widening — adding a field, adding an enum value, making a required field optional, adding a new status code. The provider is giving more.
A type-based contract catches narrowing beautifully. Delete estimated_delivery and the verification fails immediately, exactly as advertised.
It is close to blind to widening. And widening is the harder problem, because it feels safe. Nobody circulates a design doc to add an enum value. Narrowing changes get caught by review, deprecation policy, and ordinary caution long before they get caught by a contract — widening changes get caught by nothing, which is why they are the ones that reach customers.
The test you can run: take your most important pact and ask what the provider could add tomorrow, in good conscience, that would break the consumer and still verify green. If you can name something in under a minute, the contract is describing the API rather than protecting the consumer.
What the default contract catches, and what it doesn't
Provider change | Type-matcher pact | What would catch it | Who finds it today |
Field removed | ✅ fails | — | CI, immediately |
Type changed (string → int) | ✅ fails | — | CI, immediately |
Required field becomes optional | ⚠️ only if the example omits it | Explicit presence requirement | Usually production |
New enum value added | ❌ passes | Value-set constraint in the contract | Production |
New status code returned | ❌ passes | Declared status set | Production |
Field's meaning changes | ❌ passes | Nothing — this is not a contract problem | Production |
Number's unit changes (cents → dollars) | ❌ passes | Nothing in the contract; a name change would | Production, expensively |
The bottom four rows are where the outages live, and three of them are invisible to the contract no matter how carefully you write it. That is not an argument against contract testing. It is an argument for knowing which slice you bought.
The test: for each row in your own contract, write the change that would break it. Any field where you can't name one is decoration.
Constrain the value set where the consumer branches on it
The fix for Tolvern's failure is small and specific, and it is not "make the contract stricter everywhere". A contract that pins exact values for every field fails on every harmless change, teams start rerunning it, and you've traded a blind spot for an ignored alarm.
The rule that survives contact: constrain the value set for exactly those fields the consumer branches on.
// Before — passes when "economy_plus" appears
carrier_tier: like("standard")
// After — fails the moment the provider serves a value the consumer can't handle
carrier_tier: term({ matcher: "^(standard|express|freight)$", generate: "standard" })
That single line converts the contract from a description into a constraint. When rates adds economy_plus, verification now fails on the provider's CI — which is the correct place, because the provider is the one who chose to change.
The consumer's switch statement is the specification. If it has three branches, the contract should permit three values. When those two drift apart, something is wrong regardless of which one you fix.
The test: grep your consumer for switch, match, and if x == against fields that come from another service. Every one of those is a value-set dependency. Count how many appear as constrained values in your contract.
"Consumer-driven" is really a question about who absorbs the widening
The consumer-driven vs provider-driven distinction is usually explained as a matter of authorship. That framing doesn't help you choose. The decision that matters is who is forced to do work when the provider wants to widen.
Consumer-driven with constrained values: the provider's build breaks. They must either coordinate with the consumer or hold the change. The cost lands on the party making the change, which is where it belongs — and it is also friction, deliberately.
Provider-driven: the provider publishes what it offers, consumers verify they can handle it. The provider ships freely; the consumer discovers the gap on its own schedule, which in practice means after the deploy.
Consumer-driven is the right default for the reason people rarely state: it makes widening visible at the moment of the decision, instead of at the moment of the incident.
It also has a real failure mode. With many consumers, every widening becomes a negotiation with all of them, and providers start routing around the contract entirely. If your provider has a dozen consumers, constrain values only for the handful of fields where a wrong branch actually costs something, and let the rest stay loose on purpose.
The test: ask your provider team when they last held a change back because a contract failed. If the answer is never, either nothing has widened or the contracts aren't constraining anything.
Where this is the wrong instrument entirely
Contract testing verifies that two services agree about the shape of a message. Three common failures are outside that entirely, and reaching for a contract to catch them wastes real effort:
Semantic drift. The provider keeps returning weight as a number and quietly switches from kilograms to pounds. Every contract on earth passes. Only a shared test fixture with a known expected value, or a unit in the field name, catches this.
Sequencing and state. "You must call /quote before /book, and the quote expires in 15 minutes." A contract describes single interactions; it cannot express an ordering constraint.
Whether the thing is deployed and reachable at all. A contract verifies compatibility, not connectivity. Config errors, wrong URLs, and expired credentials all pass contract verification and fail in staging.
That last one is the honest case for keeping a thin end-to-end suite. Not because contracts don't work, but because "compatible" and "actually talking to each other" are different claims, and only one of them is a contract's job.
The test: for your last cross-service incident, ask whether a perfect contract would have caught it. Track the answer over a few incidents. That ratio, not a general argument, tells you how much contract testing is worth in your system.
What to change this week
Pick your single most important consumer-provider pair. In the consumer, find every place it branches on a value from the provider. For those fields — and only those — replace the type matcher with a value-set constraint.
Then run the provider's verification. If it still passes, ask the provider team to add a plausible new enum value locally and run it again. It should now fail. That failure is the thing you actually bought; until you've seen it, you own the tooling and not the protection.
The step-by-step build — consumer pact, broker, provider verification, can-i-deploy, and deliberately widening an enum to watch the contract catch it — is in Tutorial 10: API Contract Testing. If what you need is confidence that a single service does what it says rather than that two agree, that's a different set of surfaces: API Testing: A Practical Walkthrough.
Tolvern's booking UI branches on four fields. Four lines of the contract changed. The next widening broke the rates team's build on a Tuesday afternoon, which cost them a conversation instead of a customer.


