Contract Testing: Keeping Services Honest
- Shawn West
- Jul 17, 2025
- 10 min read
Updated: Aug 10
At 3:00 AM, checkout was returning zeroes. Every order total rendered as $0.00, the payment step refused to advance, and the on-call engineer had no error to grep for — nothing had thrown. The services were all green. Health checks passed. The dashboards were calm.
The cause had shipped seventeen hours earlier. On Tuesday afternoon, Team A — who own the Orders service — renamed a response field. What used to come back as total was now amount_total, a tidy cleanup that matched their internal naming. Their tests passed, because their tests asserted on the new name. They deployed.
Team B own the checkout UI. Their parser read response.total, got undefined, coerced it to 0, and rendered $0.00. No exception. No log line. Just a silent wrong number that walked straight past every test both teams had, because neither team's tests knew the other existed.
This is the failure that contract testing exists to prevent. Not by adding another slow end-to-end suite that runs after everything is deployed, but by making one specific test go red in Team A's own CI, on the pull request that renamed the field — before the merge, before the deploy, before 3 AM. This article is about how that works, and how to write contracts that catch not just renamed fields but the subtler mismatches that a shape-only check sails past.
This piece is part of the Software Testing Foundations path. It builds on the layered approach in 'Testing Strategies That Scale', which names the contract layer but doesn't take it apart — that's the job here.
In a monolith, the compiler is your contract
Inside a single codebase, this class of bug barely exists. If Team A renames total to amountTotal on a class Team B calls, the build breaks. The compiler walks every call site and refuses to produce an artifact until the mismatch is resolved. The "contract" between the two teams is enforced mechanically, on every build, for free.
Split those two teams into separate services communicating over HTTP or a message queue, and the compiler goes blind. Orders serializes an object to JSON; checkout deserializes JSON into its own object. Neither side shares a type. The rename compiles cleanly on both sides because, to each service in isolation, nothing is wrong. The guarantee you got for free in the monolith is now something you have to manufacture deliberately.
That's the whole problem statement: distributed systems removed the compile-time check that used to catch interface mismatches, and most teams never replaced it. Integration tests don't replace it either, because they only run when both services are stood up together — which, in a real org with independent deploy pipelines, is later and rarer than the moment the breaking change merges.
The runnable takeaway: list every service your team consumes and ask, for each, what tells us if they break the shape we depend on, and when? If the honest answer is "production" or "a Slack message from the other team," you have an uncaught contract.
What a contract actually is — and the discovery move that scopes it
A contract is the agreement between a provider (the service that produces data) and a consumer (the service that uses it): the shape of the response, which fields are required, their types, and what the status codes mean.
The move that makes contract testing tractable — and the one most teams miss — is a discovery question, not a coding technique:
What does the consumer actually READ?
Not "what does the provider return." What does this consumer read. The Orders service might return fifty fields on an order object — line items, tax breakdowns, shipping estimates, loyalty metadata, internal audit timestamps. The checkout UI reads three of them: id, status, and the total. The contract covers those three. The other forty-seven are none of checkout's business, and — this is the point — the provider stays free to change all forty-seven without breaking anyone.
This is consumer-driven scoping, and it's the same discovery-first instinct this path keeps returning to: failures come from unvalidated assumptions about what a dependency actually needs, not from the code itself. A contract that mirrors the provider's entire API is worse than no contract — it turns every internal refactor into a false alarm and trains teams to ignore the check. A contract scoped to what the consumer reads fails only when something the consumer truly depends on changes. Signal, not noise.
The runnable takeaway: for one downstream dependency, open your code and grep for where you read its response. Write down the exact fields you touch. That list — not the provider's OpenAPI spec — is your contract's scope.
How consumer-driven contract testing works
The mechanism has two sides and one artifact in between. The best-known implementation is Pact, an open-source framework built specifically around consumer-driven contracts, so the walk-through below uses its model.
On the consumer side, you write a test that declares the interaction you expect: "when I GET /orders/123, I expect back an object with these fields and these types." Running that test produces a pact — a JSON file describing the expected request and response. The pact is a static artifact. It doesn't require the provider to be running.
On the provider side, a verification step replays every published pact against the real running provider. It issues the request each consumer said it would make and checks the real response against what the consumer said it needed. Match: the contract holds. Mismatch: the provider's build knows — in the provider's own CI, before merge — that a change would break a named consumer.
The two sides never run at the same time. The pact carries the consumer's expectation across the gap. That decoupling is what lets it run inside a fast pipeline instead of a slow, everything-deployed integration environment.
A pact, walked end to end
(Developed example — composite scenario. The mechanics mirror how Pact works; the specific services are illustrative.)
Take the exact bug from the top of this article and follow it through the contract.
Step 1 — The consumer writes down what it reads. Checkout depends on three fields from an order. Its Pact test declares them:
// checkout-service — consumer test
provider
.given('order 123 exists')
.uponReceiving('a request for order 123')
.withRequest({ method: 'GET', path: '/orders/123' })
.willRespondWith({
status: 200,
body: {
id: like(123), // integer
status: like('confirmed'), // string
amount_total: decimal(49.99) // number, 2-decimal currency
}
});
Running this test produces checkout-orders.json and publishes it to the shared broker. Note what's not here: no line items, no tax rows, no shipping estimate. Checkout doesn't read them, so they aren't in the contract, and Orders can change them freely.
Step 2 — The provider verifies against every pact. In the Orders service's CI, the verification step boots the real service and replays each published pact:
// orders-service — provider verification (runs in Orders' CI)
await verifier.verifyProvider({
provider: 'orders-service',
pactBrokerUrl: BROKER_URL,
providerBaseUrl: 'http://localhost:8080',
stateHandlers: {
'order 123 exists': () => seedOrder({ id: 123, amount_total: 49.99 })
}
});
Green today. The real response contains id, status, and amount_total, all matching. Merge away.
Step 3 — The rename. Now replay Tuesday. An Orders engineer renames the field back the other direction — amount_total → total — for their own consistency. Their unit tests assert on total, so they pass. In a world without contract testing, this merges, deploys, and surfaces at 3 AM.
With the pact in place, the provider verification step re-runs as part of that same pull request and fails:
Verifying pact "checkout-orders" between checkout-service and orders-service
a request for order 123
returns a response which
has a matching body
$.amount_total — expected 'amount_total' but it was missing
(found unexpected key 'total')
FAILED — 1 interaction failed
Provider verification failed. This change breaks: checkout-service.
The build goes red on the Orders engineer's own pull request. They see, by name, that they are about to break checkout. They haven't merged. Nobody has been paged. The fix is a conversation on the PR — coordinate the rename, or keep both fields during a migration window — instead of an incident. That is the entire value proposition, delivered at the cheapest possible moment to catch a bug.
Shape isn't enough: write the semantics into the contract
Here is where naive contract testing quietly fails, and it's the most important thing to take from this piece.
The pact above checks that amount_total is a number. But suppose Orders never renames the field — instead, a refactor changes it from dollars (49.99) to cents (4999), the integer representation their new billing library prefers. The type is still "number." The field name is unchanged. A shape-only contract stays green. And checkout renders $4,999.00 for a fifty-dollar order.
Same class of trap with rounding (49.999 truncated to 49.99 on one side, rounded to 50.00 on the other) and with null handling (the consumer assumes the field is always present; the provider starts omitting it for a new order status). None of these change the shape. All of them break the consumer.
The lesson — drawn straight from the semantic-contract point in 'Testing Strategies That Scale' — is that a contract must encode meaning, not just structure. Write the units, the precision, and the nullability into the contract as assertions the verification will actually check:
willRespondWith({
status: 200,
body: {
id: integer(123),
status: term({ matcher: 'confirmed|pending|cancelled', generate: 'confirmed' }),
amount_total: decimal(49.99) // currency in DOLLARS, exactly 2 decimal places
}
});
decimal(49.99) asserts a fractional value with decimal places, so an integer 4999 fails verification — the cents refactor now goes red instead of shipping. The term matcher pins status to a known set, so a new enum value the consumer can't handle is caught, not silently rendered. Where a matcher can't express the rule (that amount_total is dollars, not cents), state it in a comment and add a consumer-side assertion that would fail on an out-of-range value — a $4,999 total for a cart of one item.
The runnable takeaway: for every field in your contract, write down its unit, its precision, and whether it can be null or absent. If your matchers only check the type, add assertions — or comments plus consumer-side guards — until the contract would fail on a units, rounding, or null change. A contract that only knows shape is a contract that will let the expensive bugs through.
Where contract testing fits among the other layers
Contract testing doesn't replace integration or end-to-end testing. It occupies a specific, cheap slot that the other two can't fill, and it leaves gaps they're needed for.
Contract testing | Integration testing | End-to-end testing | |
What it catches | Provider/consumer interface mismatches — renamed, retyped, dropped, or re-unit'd fields | Two real components wired together — DB queries, real serialization, auth handshakes | Whole user journeys across the real, deployed stack |
Speed | Fast — unit-test class, seconds | Medium — boots a couple of components | Slow — minutes, full environment |
Needs both services running at once? | No — the pact is a static artifact each side uses alone | Often yes — the components under test run together | Yes — everything is deployed and live |
Runs where | In each service's own CI, pre-merge | In a CI integration job | In a staging or pre-prod environment |
Read the table as a division of labor. Contract testing is the only one of the three that catches an interface break in the breaking team's own pipeline, before merge, without standing up the other service — that's its unique value. But it verifies the conversation, not the behavior: it won't tell you the checkout math is right, only that both sides agree on the message. You still want integration tests for the wiring and a thin E2E layer for the critical journey, as laid out in 'Integration Testing: When, How, and Why' and 'End-to-End Testing Without the Pain'.
The runnable takeaway: for your worst recent cross-service bug, ask which row would have caught it earliest. If the answer is "the contract row, and we don't have it," you've found the layer to add first.
What it costs, and where it doesn't pay
Contract testing earns its place, but not everywhere, and pretending otherwise is how teams end up resenting it.
It pays when independently deployed services exchange data across a boundary a compiler can't see, especially with different teams on each side and different release cadences — exactly the Tuesday-deploy scenario. The more autonomous the teams, the more you need an artifact that speaks for the consumer when the consumer isn't in the room.
It pays less, or not at all, when:
The boundary is internal to one team and one deploy. If the same people ship both sides in the same release, the coordination cost of maintaining pacts may exceed the risk. A shared type or a good integration test can be enough.
The provider is a third party you don't control. You can't add pact verification to Stripe's CI. Here the useful tool is a contract test against a recorded/sandbox response to catch when their real API drifts from your assumption — related, but a different setup.
The consumer relationship is one-off or throwaway. Contracts are maintenance. A prototype integration doesn't need one.
There's also an organizational cost that's easy to underestimate: consumer-driven contracts require the provider team to run the consumer's pacts in their pipeline. That's a real dependency between teams' CI, and it needs a shared broker and an agreement that a failed verification blocks the merge. Without that agreement, the red build gets overridden and you're back to 3 AM. The technique is a social contract as much as a technical one.
The runnable takeaway: before adding contract testing to a boundary, confirm three things are true — the two sides deploy independently, a broken shape would actually reach production undetected, and the provider team will agree to let a failed pact block their merge. Miss the third and the tooling can't save you.
A method you can run this sprint
To introduce contract testing without boiling the ocean:
Pick the highest-risk boundary. The one that broke most recently, or the money-touching path. One boundary, not all of them.
Do the discovery move. On the consumer side, grep for every field you read from that provider. Write the list down. This is your contract's scope.
Write one consumer pact for the critical interaction — with semantics, not just types. Units, precision, nullability included.
Publish it to a broker both teams can reach (Pact Broker, or PactFlow).
Wire provider verification into the provider's CI and agree, in writing, that a failed verification blocks merge.
Break it on purpose — rename the field in a throwaway branch and confirm the provider build goes red with the consumer named. If it doesn't, your contract isn't actually protecting you.
Expand only to boundaries that clear the three-question test from the previous section.
Step 6 is the one teams skip, and it's the one that proves the whole thing works. A contract you've never watched fail is a contract you don't know is wired up.
For teams standing up this practice as they grow, 'Designing a Test Strategy for a Growing Team' covers where contract testing sits in the broader plan and how to sequence it against the other layers.
Sources
Pact — Consumer-Driven Contract Testing — official documentation for the Pact framework, the consumer/provider model, matchers, and the broker.
Pact: How Pact works — the pact-as-artifact and provider-verification mechanism described above.
The checkout/Orders bug, the total → amount_total rename, and the code snippets are a developed composite scenario built to illustrate the mechanism — realistic and representative, not a report of a specific named incident. The Pact framework and consumer-driven contract testing it demonstrates are real; the snippets are pact-ish pseudocode meant to convey the shape of the tests, not to run as-is.
Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center.


