The most expensive bug in a microservices world is the one that escapes provider tests, escapes consumer tests, and shows up in production the moment two services are deployed together. Service B renamed a field from userId to user_id. Service B's tests pass — the new shape works. Service A's tests pass too — they mock service B and the mock doesn't know the field was renamed. Customers find the bug ten minutes after deploy. Contract testing is the discipline designed specifically to catch this class of bug, and it's one of the highest-leverage testing practices a team can adopt.
The problem contract testing solves
Three traditional approaches each leave a gap.
- Unit tests verify each service in isolation. They prove "service B's
userControllerworks as written." They say nothing about whether other services can use it. - Integration tests spin up service A and service B together and exercise them. Catches drift, but expensive — needs both services live, and doesn't scale to dozens of microservices.
- Mocks in consumer tests let service A test against a fake service B. Fast, isolated, but the mock can drift from the real provider with no signal.
The mock-drift gap is where contract testing fits. A contract is the authoritative description of "what consumer A expects from provider B." Verified independently on both sides, drift is detected before it reaches production.
What a contract actually is
In its simplest form a contract describes one or more interactions — request shapes the consumer makes plus response shapes it expects:
Interaction: "get user by id"
Request:
method: GET
path: /users/123
headers: { Authorization: "Bearer ..." }
Expected response:
status: 200
body: {
id: integer,
name: string,
email: string,
role: enum [admin, editor, viewer]
}
Two things to notice:
- The contract is specific: a particular request, a particular response shape. Not "the API in general."
- The contract is the consumer's perspective. It says what consumer A needs — it doesn't enumerate every field provider B can return.
Each consumer has its own contract with its own subset of fields. Provider B verifies it can satisfy every consumer's contract.
Consumer-driven contracts
The standard form of contract testing — popularised by Pact — is consumer-driven:
- The consumer writes a test that records its expectations: "I'll call this endpoint, and I expect this shape back."
- The recording is serialised into a contract file (a Pact file).
- The provider runs the contract: it replays each interaction against the real provider and verifies the actual response matches what was expected.
Why "consumer-driven"? Because the consumer knows what data it actually uses. Provider-defined schemas often over-specify (every field that could be returned) or under-specify (the spec is out of date). The consumer's test, by contrast, captures only the fields it actually consumes — those are exactly the fields that must keep working.
Where contract tests fit
Contract testing slots between unit tests and integration tests:
- Faster than integration tests. No need to spin up both services together. Each side runs its half independently.
- More reliable than mocks alone. The mock is generated from the contract, so it can't drift undetected.
- Catches breaking changes early. Provider tries to rename a field → contract verification fails on the provider's CI before merge.
- Scales to many consumers. A provider with 10 consumers ends up with 10 contracts, each verified independently. No combinatorial explosion.
The contract testing flow
Step 1 of 6
Consumer writes expectation
A test in service A says: 'when I call GET /users/123, I expect a body with id, name, and email of these types.' This runs against a Pact-provided mock.
Six steps, but the engineering effort lives mostly in the first and the fourth. Once the broker is wired up, day-to-day contract testing is just adding interactions in consumer tests and trusting the verification step on the provider's CI.
What contract testing isn't
Important to scope correctly:
- It isn't end-to-end testing. It doesn't verify that a complete user flow works through three services. It only verifies that pairwise interactions remain compatible.
- It isn't business-logic testing. "When stock is zero, return 409" is a logic test, not a contract test. The contract verifies shape and basic semantics — that a 409 with the right shape is possible — not the full rule.
- It doesn't replace functional tests. You still need tests that verify "this endpoint actually returns the right data for the right input." Contract tests sit alongside, not on top of.
A useful mental model: functional tests check "the API does what it's supposed to do." Contract tests check "the API still meets what its consumers expect." Both are needed.
When to invest
Contract testing pays off when:
- You have multiple services that talk to each other (true for nearly any non-trivial product).
- You have multiple teams owning those services (the social cost of breaking changes is high).
- You deploy frequently (any merged PR could be the one that breaks an integration).
- You can't easily run integration tests with all services live (the world above ~5 services).
For a small monolith with one frontend and one backend owned by the same team, contract testing is overkill — function tests on the API plus a few smoke tests on the frontend are usually enough. The win compounds with team count and service count.
A worked example
Imagine a frontend team consuming a users service:
- Frontend: needs
id,name,email, andavatarUrlfromGET /users/me. - Users service: returns
id,name,email,avatarUrl, plus internal fields likepasswordHash(for auth) andinternalRiskScore(for moderation).
The frontend's contract enumerates the four fields it needs. Provider verification confirms they're present.
Now the users team renames avatarUrl to avatar_url. Their own tests still pass — they're internal and use the new name. But the next CI run on the contract verification fails: consumer expected avatarUrl, provider returned avatar_url. The breaking change is caught before deploy. The team can then choose: rename back, add an alias, or coordinate a deprecation cycle with the frontend.
That single feedback loop — "your change breaks consumer X" — is the core value contract testing delivers.
⚠️ Common mistakes
- Treating contracts as full schemas. Don't enumerate every field the provider could return. Capture only what the consumer actually uses. Smaller contracts = smaller blast radius for changes.
- Skipping the broker. Hand-passing Pact files via email or shared drives is fragile. A broker (Pactflow or self-hosted) is what makes "can I deploy?" answerable.
- Forgetting to version. Contracts must be tagged with consumer/provider versions or the broker can't tell you what's currently compatible. Wire this into your release process.
🎯 Practice task
Sketch a contract for a real consumer-provider pair. 25-30 minutes — no code yet.
- Pick two services in your project (or a simple imagined pair: a checkout service consuming a payment service).
- List the requests the consumer makes: method, path, headers, sample body.
- For each request, list only the fields the consumer actually uses from the response. Resist the urge to include "everything."
- Identify one realistic breaking change the provider could make (rename, retype, remove a field). Predict whether your contract would catch it.
- Browse the Pact documentation — skim the "Getting started" page in your team's language to see what a real consumer test looks like.
- Stretch: find a recent production incident in your team's history that was a breaking-change-between-services bug. Would contract testing have caught it? Note that as the business case if you ever propose adopting it.
You now know what contract testing is and why teams invest in it. The next lesson zooms in on Pact specifically — the dominant framework, and the one most jobs reference.