Contract Testing Between Services
End-to-end tests across ten services do not scale. Consumer expectations verified against the provider, where the broker fits, and what contract tests deliberately do not cover.
End-to-end tests across ten services do not scale. Consumer expectations verified against the provider, where the broker fits, and what contract tests deliberately do not cover.
End-to-end tests across ten services do not work, and the reason is arithmetic rather than attitude. Every service must be deployed, in a compatible version, with data seeded consistently, before any test runs. The suite is slow, its failures are ambiguous, and it can only run where all ten services exist — which means it cannot gate an individual service's pipeline.
Contract testing replaces it. Each side is verified against a shared agreement, separately, and neither needs the other running. By the end of this lesson you will know how consumer-driven contracts work, what a broker is for, and what contract tests deliberately do not cover.
Two services, orders and payments. orders calls
POST /charges and reads status from the response.
The obvious test starts both services and exercises the call. That works for two and becomes untenable as the number grows, for reasons worth naming:
n services, all deployed compatibly, before any test runs
a failure names the journey, not the service that broke
each service's own pipeline cannot run it — the others are missing
the environment becomes a queue: one shared integration env, many teams
one flaky service makes the whole suite unreliableThe alternative teams reach for is mocking, and it has a specific hole.
Real, and untenable at scale.
Every service deployed compatibly before any test runs, one shared environment as a queue, and a failure that names the journey rather than the service that broke it.
Fast, and proves nothing jointly.
orders tests against its own stub of payments, payments
tests against nothing, and neither learns when they disagree.
Both suites green, production broken.
Independent runs, one shared expectation.
The consumer writes down what it needs; the provider's own pipeline verifies it can supply that. Neither has to run the other.
A contract is a set of concrete expectations: for this request, this response. It is produced by the consumer and verified by the provider, and the two happen at different times in different pipelines.
Neither side ever runs the other. The consumer's test needs no payments
service; the provider's verification needs no orders service. Each runs in
its own pipeline in seconds.
Three things are doing work here.
Like and Term are matchers. Like("ch_1") means "a string, and I do
not care which". Term constrains to a pattern. This is the difference
between a contract and a snapshot: pinning exact values would break on every
harmless change, while matchers pin the shape, which is what the consumer
actually depends on.
given(...) is a provider state. A precondition the provider must be
able to establish before replaying this interaction. It is the coordination
point between the two sides, and the part that needs a conversation.
The assertion is on your own code. place_order really runs, through your
real HTTP client, against the generated mock — so your serialisation, headers
and error handling are exercised, which is the seam placement from the faking
lesson.
Running the test writes orders-payments.json.
The provider replays every interaction:
And it must implement the provider states — a test-only endpoint that puts the service into the state a given interaction requires:
That endpoint must exist only in a test build. Shipping it to production is a way to let anyone rewrite your database.
Provider states are where contract testing takes real effort, and it is honest work: naming the preconditions your consumers depend on forces both sides to be explicit about states that were previously assumed.
Files passed by hand do not scale past two services. A broker — Pact Broker, or PactFlow — stores contracts, versions them against git commits, and answers the question that matters at deploy time.
can-i-deploy is the point of the whole apparatus. It answers: is every
consumer currently in production compatible with this version? If yes, the
deploy proceeds. If no, it is blocked — with the name of the consumer and the
interaction that would break.
That is a stronger guarantee than an integrated test suite provides, and it is available per-service, in seconds, without deploying anything.
Bad — exact values, and fields the consumer does not use:
Good — matchers, and only what is used:
The bad contract fails when the provider changes a timestamp format, adds a field, renames something the consumer never reads, or returns a different fee. Every one of those is a legitimate provider change, and each produces a red build for a team that did nothing wrong — after which the contract suite becomes the thing people disable.
Worse, it reverses the benefit. The contract was supposed to give the provider freedom to evolve anything unused; pinning seven fields removes that freedom and makes the provider's every release a negotiation.
The good contract says: I send this, and I need an id that is a string and
a status that is one of three values. The provider may change everything
else freely, and cannot break the one thing that matters without finding out
immediately.
Being clear about this prevents over-claiming, which is how the technique gets discredited.
Not behaviour
A contract says the response has a status field matching a
pattern. It says nothing about whether the charge was taken,
or whether the amount was right.
Not the network
TLS, DNS, service discovery, timeouts, load balancers — none of it is exercised. A post-deployment smoke test covers that.
Not a chain
Contracts are pairwise. A works with B and B works with C does not guarantee the journey through all three — though it does eliminate the interface mismatches that cause most such failures.
Not a replacement for every journey
Keep a small number that prove the assembled system works. Contract testing means a handful rather than a hundred.
The next lesson moves from correctness to behaviour under pressure: performance testing, and why a load test is only as good as its model of a user — arrival rates, think time, percentiles over averages, and finding the knee rather than a number.
Before that, pick the single riskiest service-to-service call you have and write one contract for it, consumer side, with matchers rather than values. One contract on the interface that would hurt most is the version of this lesson that pays for itself immediately.
1. The consumer writes tests against a MOCK provider that is
generated from its expectations. Its own suite passes or fails
as normal.
2. Running those tests produces a PACT — a file recording every
request the consumer makes and the response it relies on.
3. The provider replays every interaction in the pact against
itself, in its own pipeline, and must satisfy all of them.
4. If the provider cannot, the contract is broken — and it is broken
before either side is deployed.# Why not integrated testing
# n services deployed compatibly before any test runs
# failures name the journey, not the service
# a service's own pipeline cannot run it
# one shared environment becomes a queue
# and mocking on both sides leaves both green while prod is broken
# The flow
1. consumer tests against a mock generated from its expectations
2. running them produces a PACT: request -> response it relies on
3. provider replays every interaction against itself, in its own
pipeline
4. a break is found before either side is deployed
# neither side ever runs the other
# CONSUMER-DRIVEN is the point
# the contract records what the consumer actually READS
# so the provider stays free to change everything else# Provider
replay every interaction; implement provider states via a
test-only endpoint that seeds each precondition
# NEVER ship that endpoint to production
# Broker — the part that makes it operational
pact-broker publish ./pacts --consumer-app-version=$GIT_SHA
pact-broker can-i-deploy --pacticipant payments \
--version $GIT_SHA --to-environment production
# "is every consumer currently in production compatible with this
# version?" — answered in seconds, deploying nothing
# The mistake: pinning too much
# exact timestamps, fees, references, fields you never read
# -> red builds for legitimate provider changes, and the provider
# loses the freedom the contract was supposed to give it
# TEST: does the consumer's code read this field? If not, remove it.
# What contract tests do NOT cover
behaviour was the charge actually taken, for the right amount
the network TLS, DNS, discovery, timeouts, load balancers
a chain contracts are pairwise
assembly keep a HANDFUL of end-to-end journeys
# The arrangement
contracts for interfaces / own tests for behaviour /
smoke test for the environment / a few journeys for assembly# in the orders service's test suite
from pact import Consumer, Provider
pact = Consumer("orders").has_pact_with(Provider("payments"))
def test_a_successful_charge_is_recorded_as_paid():
expected = {
"id": Like("ch_1"), # any string
"status": Term(r"succeeded|pending", "succeeded"),
"amount": Like(4500), # any integer
}
(
pact.given("a valid payment method exists for account 42")
.upon_receiving("a charge request for 45.00")
.with_request(
"post", "/charges",
body={"account_id": 42, "amount": 4500},
headers={"Content-Type": "application/json"},
)
.will_respond_with(201, body=expected)
)
with pact:
order = place_order(account_id=42, total=Decimal("45.00"))
assert order.status == "paid"# in the payments service's test suite
from pact import Verifier
def test_the_payments_service_satisfies_its_consumers():
verifier = Verifier(provider="payments",
provider_base_url="http://localhost:8000")
success, _ = verifier.verify_with_broker(
broker_url=BROKER_URL,
provider_states_setup_url=(
"http://localhost:8000/_pact/provider-states"
),
publish_verification_results=True,
)
assert success == 0@app.post("/_pact/provider-states", include_in_schema=False)
async def set_up_provider_state(state: ProviderState):
if state.state == "a valid payment method exists for account 42":
await seed_account_with_payment_method(42)
elif state.state == "account 42 has no payment method":
await seed_account_without_payment_method(42)
return {"ok": True}# consumer pipeline: publish what it needs
pact-broker publish ./pacts \
--consumer-app-version="$GIT_SHA" --branch="$GIT_BRANCH"
# provider pipeline: verify against consumers, and publish results
pytest tests/contract/
pact-broker create-version-tag --pacticipant payments \
--version "$GIT_SHA" --tag main
# either pipeline, before deploying
pact-broker can-i-deploy \
--pacticipant payments --version "$GIT_SHA" \
--to-environment productionexpected = {
"id": "ch_1",
"status": "succeeded",
"amount": 4500,
"created_at": "2026-03-15T12:00:00Z",
"provider_reference": "pi_3Mx8vK2eZvKYlo2C",
"fee": 63,
"net": 4437,
}expected = {
"id": Like("ch_1"),
"status": Term(r"succeeded|pending|failed", "succeeded"),
}# Consumer: matchers, not values
"id": Like("ch_1") # any string
"status": Term(r"succeeded|pending", "succeeded")
.given("a valid payment method exists for account 42") # state
# the assertion is on YOUR code, through your real HTTP client