Skip to content
Richard Cooper
Go back

Contract testing

Deployment coupling ended on an uncomfortable note. Share a contracts package and your services deploy in lockstep; share nothing and they drift apart and break each other silently in production. The post said “share the smallest, most stable contract you can” — but that just relocates the question. If a producer and a consumer no longer compile against the same types, what stops one of them changing the wire shape and quietly breaking the other?

The answer is a specific practice, and it’s the missing half of the previous post: contract testing. Share the contract — not the code.

Contract, not code

The mistake is treating “we must agree on the shape” as “we must share the class”. You don’t. What both sides genuinely need to agree on is a specification of the boundary: this endpoint, these fields, these types, this event shape. Write that down as an artifact both sides own — an OpenAPI document for an HTTP API, an AsyncAPI or schema file for messages — and you have a contract that carries no code dependency and forces no shared deploy.

Then you make each side prove it honours the spec, independently, in its own pipeline:

Neither test needs the other side deployed. That’s the whole trick — compatibility is verified against a stable, versioned document instead of enforced by a shared binary, so the two services come unstuck from each other’s release schedule while staying safe.

Provider-side: prove the real host honours the spec

The strongest version I run boots the actual service and checks its live surface against the spec. A tool reads the contract, fires the requests it describes at the running host, and asserts the responses conform. In CI it looks like a dedicated step: start the service on a port, point the contract runner at it, fail the build on any divergence.

# CI: contract-compliance step
start the service host        →  http://localhost:6500
run the contract runner       →  every path/field/type in the spec, exercised
assert: live responses conform to the spec, or fail the build

There’s a subtlety here that earned its place the hard way. Most of a service’s tests run it in-memory, through a test host that injects tidy test configuration. That’s fast, but it masks a whole class of failure that only happens when the real host starts with its real config. I once watched a change add an authentication requirement that every one of a couple of hundred in-memory tests happily passed — because the test host wired auth differently — and the build went red only at the contract-compliance step, which booted the genuine host and found it couldn’t start. The lesson stuck: a contract test that exercises the real boundary catches what a mocked boundary hides. The value isn’t only “does the shape match” — it’s “does the thing that actually ships actually serve this.”

Shallow on purpose

That real-host emphasis cuts the other way too, and it’s what keeps contract tests cheap: you keep the real host, but you do not need the real world behind it. A contract test is deliberately shallow — you’re exercising the endpoint and asserting its shape, not proving the business logic or the integrations underneath. So fake the datastore, stub the downstream service, let a repository hand back a canned record — whatever makes the endpoint respond in the shape the spec describes.

The two kinds of “don’t mock this / do mock that” aren’t in tension, because they mock different things. You keep the real host — routing, serialisation, auth, configuration, the wiring that actually ships — because that surface is what the contract is about. But everything past the boundary — the database, the message broker, the third party three hops down — can be hollow, because the contract says nothing about them. Real edges, faked middle. That’s what keeps these tests fast enough to run on every build and focused enough that when one fails, it failed for exactly one reason: the shape at the boundary moved.

Consumer-side: prove you only rely on what’s promised

The other direction matters just as much and gets skipped more often. A consumer should test that every field and endpoint it depends on genuinely exists in the spec — so that when the provider publishes a new version, the consumer’s build tells it immediately whether anything it relies on has moved, rather than finding out from a 500 in production.

Taken further, this becomes consumer-driven contract testing: consumers publish the expectations they actually exercise, and the provider’s pipeline verifies it satisfies the union of all of them. The payoff is precise blast-radius knowledge — the provider learns, at build time, exactly which consumers a proposed change would break, and can decide whether to evolve the contract or hold the line. It costs coordination (somewhere to publish and collect those expectations), which is the honest price of that precision.

Changing the contract becomes a visible, versioned act

Here’s how this pays back the deployment-coupling story directly. With a shared package, a breaking change could be a class rename in a routine diff — invisible until it detonated. With a shared spec under contract tests, changing the boundary means changing a versioned document, and every affected pipeline lights up. The change stops being an accident and becomes a deliberate negotiation — which is exactly the care a boundary other teams build on deserves, because it’s a one-way door. You evolve it the grown-up way: add the new shape alongside the old, support both through a transition, retire the old once the contract tests show nobody depends on it. Expand, migrate, contract — the same discipline, now with a test telling you when the “contract” step is safe.

It’s also, quite literally, a fitness function for compatibility: an automated check that the system still has a property you care about — here, “producer and consumers still agree” — run on every build so it can’t quietly erode.

The honest limits

Contract testing buys compatibility, not correctness, and it’s worth knowing the edges:

The one-line version

You decouple deployments by refusing to share code across a boundary — and you stay safe doing it by sharing a contract instead and making both sides prove they honour it, independently, on every build. The spec is the thing you agree on; the tests are how you keep the agreement honest; and a breaking change becomes a version bump everyone can see coming, instead of a rename that quietly takes down production on a Tuesday.


Share this post:

Previous Post
Deployment coupling
Next Post
I was an enterprise architecture sceptic