Tutorial 10: API Contract Testing
- Shawn West
- May 6
- 5 min read
Updated: Aug 18
Illustrative composite: Tolvern Freight's booking UI (consumer) and rates service (provider) — the pair from Contract Testing for Microservices. That article argues why a contract is worth only the futures it forbids. This builds the loop and proves the point on your own machine.
Before you start
You need:
Two services you control, one calling the other. Contract testing across an org boundary is a different, slower conversation.
A test suite in each, both running in CI.
Docker, for a local Pact Broker. You do not need a hosted broker to complete this.
One place in the consumer where it branches on a value from the provider — a switch, a match, an if status ==. If there is no branch, Steps 8 and 9 won't demonstrate anything.
About 75 minutes. Examples use Pact with JavaScript on the consumer and Python on the provider, which is the normal cross-language case.
What you'll build
A full consumer-driven loop: a pact generated by the consumer's tests, published to a broker, verified against the real provider, gating deploys via can-i-deploy — and then the specific tightening that makes it catch a widened enum.
Step 1: Find the branch, not the endpoint (10 min)
Start in the consumer, and start with the branch rather than the call.
// booking-ui/src/price.js
function priceLabel(rate) {
switch (rate.carrier_tier) {
case 'standard': return `${rate.amount} — 3-5 days`;
case 'express': return `${rate.amount} — next day`;
case 'freight': return `${rate.amount} — palletised`;
}
}
Three branches. That switch is the real specification — the contract's job is to make sure the provider never serves a fourth value.
Check: you can name the exact set of values your consumer handles, and you found it by reading code rather than documentation. If the docs and the switch disagree, trust the switch.
Step 2: Write the consumer test (15 min)
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like } = MatchersV3;
const provider = new PactV3({ consumer: 'booking-ui', provider: 'rates-service' });
it('gets a rate for a shipment', () => {
provider
.given('a rate exists for GB to FR')
.uponReceiving('a rate request')
.withRequest({ method: 'GET', path: '/rates', query: { from: 'GB', to: 'FR' } })
.willRespondWith({
status: 200,
body: { amount: like('42.50'), carrier_tier: like('standard') },
});
return provider.executeTest(async (mock) => {
const rate = await fetchRate(mock.url, 'GB', 'FR');
expect(priceLabel(rate)).toContain('3-5 days');
});
});
Note that the assertion runs your real consumer function against the mock. A pact test that only checks the mock replied is testing Pact, not your code.
Check: run it. A pacts/booking-ui-rates-service.json file should appear. Open it — you should recognise your own request and response in it. If the file is missing, the test passed without ever calling executeTest.
Step 3: Stand up a broker (10 min)
docker run -d --name broker -p 9292:9292 pactfoundation/pact-broker
Check: http://localhost:9292 loads and shows zero pacts. An empty broker is the correct starting state — if it errors, nothing downstream will work.
Step 4: Publish the pact (5 min)
pact-broker publish ./pacts \
--consumer-app-version=$(git rev-parse --short HEAD) \
--branch=main --broker-base-url=http://localhost:9292
Check: the pact appears in the broker UI, tagged with your commit SHA. The version matters — can-i-deploy in Step 6 is answering a question about specific versions, not about services in the abstract.
Step 5: Verify on the provider (15 min)
# rates-service/tests/test_pact_verification.py
from pact import Verifier
def test_provider_honours_consumer_contracts():
verifier = Verifier(provider="rates-service", provider_base_url="http://localhost:8000")
success, _ = verifier.verify_with_broker(
broker_url="http://localhost:9292",
publish_version="abc1234",
provider_states_setup_url="http://localhost:8000/_pact/state",
)
assert success == 0
The provider_states_setup_url is the part people skip and then debug for an afternoon. It's an endpoint in your provider's test build that puts the database into the state the pact named — here, "a rate exists for GB to FR".
Check: verification passes. Now break it deliberately: rename amount to price in the provider and run again. It must fail, naming the missing field. Rename it back. You have now seen the contract catch a narrowing change, which is the easy half.
Step 6: Gate the deploy (10 min)
pact-broker can-i-deploy --pacticipant rates-service \
--version abc1234 --to-environment production \
--broker-base-url=http://localhost:9292
Check: it returns yes with a compatibility table. Then run it with a version you never published — it must say no. A can-i-deploy that always says yes is the most dangerous state in this whole setup, because it looks exactly like a working one.
Step 7: The widening change — watch it slip through (10 min)
Now the part that justifies the tutorial. In the provider, add a value the consumer has never heard of:
CARRIER_TIERS = ["standard", "express", "freight", "economy_plus"] # new
Serve economy_plus for one route. Then run, in order: the provider's own tests, pact verification, and can-i-deploy.
Check: all three pass. Every one of them. Meanwhile priceLabel() in the consumer falls through the switch and returns undefined. Sit with that for a second — this is a fully correct, by-the-book contract testing setup that just cleared a change which breaks the consumer in production.
The reason is in the pact file: like('standard') says "a string shaped like this". economy_plus is a string shaped like that.
Step 8: The decision point — how tightly to constrain (10 min)
Two plausible options, and the wrong one is worse than doing nothing.
Option A — pin exact values everywhere. Every field gets an exact matcher. Now every harmless provider change fails the build, the team learns that contract failures are noise, and within a month someone adds --ignore to CI.
Option B — constrain the value set only where the consumer branches. Everything else stays a type matcher.
The signal that decides it: does the consumer make a decision based on this value? If yes, the value set is part of the contract. If the value is only displayed or passed through, it is not.
const { term } = MatchersV3;
carrier_tier: term({ matcher: '^(standard|express|freight)$', generate: 'standard' }),
amount: like('42.50'), // displayed only — leave it loose
Check: you can point at each constrained field and name the branch in the consumer that justifies it. Any constraint you can't trace to a branch is Option A creeping in.
Step 9: Prove the gap is closed (5 min)
Republish the pact, then re-run the provider verification with economy_plus still in place.
Check: verification now fails, on the provider's build, naming carrier_tier. That failure is the thing you have actually been building for the last hour. Everything before Step 8 was infrastructure.
Remove economy_plus and confirm it goes green again.
The wrong contract beside the right one
like('standard') | term('^(standard\ | express\ | freight)$') | |
Field removed | fails | fails | ||
Type changed | fails | fails | ||
New enum value | passes | fails | ||
Harmless new field elsewhere | passes | passes | ||
Breaks on unrelated provider changes | no | no | ||
Lines of contract | 1 | 1 |
Same line count. Entirely different protection.
You're done when
Renaming a field on the provider fails verification (narrowing — Step 5).
Adding an enum value the consumer can't handle fails verification (widening — Step 9).
can-i-deploy returns no for a version you never published.
Every constrained value in the pact traces to a real branch in the consumer.
Troubleshooting
Verification passes but the consumer still breaks. You are almost certainly at Step 7 — the field is type-matched and the provider widened it. Go to Step 8.
"No pacts found for provider." The provider name string must match the consumer's provider: exactly, including case. This is the single most common setup failure.
Provider states fail with "unknown state". Your _pact/state endpoint doesn't recognise the string in .given(). They are matched literally — copy it across rather than retyping.
can-i-deploy says no with no explanation. The consumer version isn't tagged to an environment yet. Record a deployment for it first; the check is about deployed versions, not published ones.
Verification passes locally, fails in CI. The broker URL usually points at localhost in one of the two places. Check both the publish and verify steps.
Next
Contracts prove two services agree about shape. Proving one service does what it claims is a separate job — Test an API End-to-End.


