03 · Contract Testing (Pact)¶
API testing (Level 2 Module 3) verifies your own service works. Contract
testing verifies that a consumer (a service calling an API) and a
provider (the service being called) agree on the shape of their
interaction — without either side needing the other's real code running.
pact-python actually installed and ran in this environment; the pact file
shown below is real, generated output.
1. Install¶
2. Writing the consumer contract test¶
# test_consumer.py
import requests
from pact import Pact
pact = Pact("WebConsumer", "UserService")
def test_get_user():
expected = {"id": 1, "name": "Alice"}
(pact
.upon_receiving("a request for user 1")
.given("user 1 exists")
.with_request("GET", "/users/1")
.will_respond_with(200)
.with_body(expected))
with pact.serve() as srv:
response = requests.get(f"{srv.url}/users/1")
assert response.json() == expected
pact.write_file("./pacts")
pact.serve() starts a real, local mock provider — no UserService code
runs anywhere. The consumer's test makes an actual HTTP request against this
mock, gets back the response you specified, and — critically — Pact records
what the consumer actually asked for as it happened, not just what you
wrote in the test.
3. The generated contract file¶
{
"consumer": { "name": "WebConsumer" },
"interactions": [
{
"description": "a request for user 1",
"providerStates": [{ "name": "user 1 exists" }],
"request": { "method": "GET", "path": "/users/1" },
"response": {
"body": {
"content": { "id": 1, "name": "Alice" },
"contentType": "application/json"
},
"status": 200
}
}
]
}
That JSON was actually generated by the test run above, not hand-written. This file is the contract — a precise, machine-checkable record of one real interaction, that both teams can version, review, and verify against independently.
4. Verifying the provider actually honors the contract¶
The provider team runs their real service and replays every recorded interaction against it, checking the real response matches what the contract promised:
from pact import Verifier
verifier = Verifier(provider="UserService", provider_base_url="http://localhost:8000")
success, logs = verifier.verify_pacts("./pacts/WebConsumer-UserService.json")
assert success == 0 # Verifier returns a process-style exit code
This step needs a real, running UserService (with /users/1 actually
implemented and seeded with the "user 1 exists" provider state), which isn't
available in this sandboxed environment — the verifier's mechanics were
reviewed, not executed. The consumer-side test above, which needed no real
provider, is the part that ran end-to-end.
5. Why this beats a shared staging environment¶
Without contract testing, catching "the consumer expects a field the provider stopped sending" usually means deploying both services to a shared staging environment and running an E2E test — slow, and only catches the break after both sides have already changed. With Pact, the consumer's contract test runs standalone on every consumer-side commit, and the provider's verification runs standalone on every provider-side commit, against a shared, versioned contract file — usually stored in a Pact Broker. Either side can find out about a breaking change before deploying, without either service needing the other one running.
6. Testing-specific traps¶
Trap 1 — the contract testing only what the consumer happens to use. A
consumer that only reads id and name from a much larger response object
generates a contract that says nothing about the other fields. If the
provider removes an unrelated field the consumer never used, the contract
still passes — correctly, since nothing broke for that consumer — but this
means one contract per consumer, not one global "API spec," is by design,
not an oversight.
Trap 2 — provider states that don't match real seed data. The given("user
1 exists") provider state is a label the verifier passes to the provider
before replaying the interaction — the provider side must implement a
state-handler that actually seeds a user with id 1. A mismatch here produces
a verification failure that looks like a contract violation but is actually
a test-setup gap on the provider's side.
Trap 3 — treating a passing contract test as equivalent to a full
integration test. Contract tests verify the shape and presence of fields,
not necessarily every business rule (e.g., that /users/1 genuinely returns
the currently logged-in user's own data, not any arbitrary user). Keep a
small number of true E2E tests for behavior contracts can't express.
Trap 4 — stale contracts nobody re-verifies. A contract generated once and never re-run against a newer provider version gives false confidence. Contract verification belongs in the provider's CI pipeline, running against every version of every contract a Pact Broker currently considers "in production," not as a one-time step.
How It Actually Works¶
Contract testing (Pact-style) solves a specific coordination problem integration tests can't: verifying a consumer and provider agree on an API shape without either side needing the other's real, running system in the test loop. Mechanically, a consumer test runs against a Pact mock provider — a local HTTP server the Pact library spins up that returns pre-recorded expected responses — and while doing so, Pact's client library intercepts every request/response pair and serializes it into a JSON "pact file" describing exact expected request shape and response shape. That same pact file is later replayed against the real provider in a separate provider- side verification step: the pact tooling issues each recorded request against the real provider and diffs the real response against the recorded contract, failing if they diverge.
This split-verification design is why contract tests can run fully in isolation on each side's own CI pipeline (no live network dependency between two teams' pipelines) while still catching real drift: a provider that changes a field name breaks pact verification on its own CI run the next time it re-executes the shared contract file, well before that change would have surfaced as an integration failure in a downstream consumer's environment.
Cheat sheet¶
| Concept | Pact-Python API |
|---|---|
| Define consumer/provider names | Pact("WebConsumer", "UserService") |
| Describe one interaction | .upon_receiving(...).given(...).with_request(...).will_respond_with(...) |
| Run consumer test against a mock | with pact.serve() as srv: ... |
| Save the contract | pact.write_file("./pacts") |
| Provider replays and checks | Verifier(...).verify_pacts(...) |
| Share contracts across teams | Pact Broker (versioned, queryable) |
| What breaks the contract | consumer expectations changing, or provider dropping a used field |
| What contract testing is NOT | a substitute for all E2E/business-logic tests |
Exercise¶
- Write a consumer contract for a
POST /ordersendpoint expecting a{"id": ..., "status": "created"}response, run it, and confirm the generatedpacts/*.jsonfile records the exact request body and response you specified. - Add a second interaction to the same
Pactobject for aGET /orders/{id}call returning404when the order doesn't exist, and confirm both interactions appear in the written pact file. - Change the consumer's expected response to a field name that doesn't
match what you'd request (
"stat"instead of"status"), rerun the test, and describe what actually fails — the mock or your assertion. - If you have a real HTTP service to test against (even a small Flask app
you write yourself with
/ordersreturning matching JSON), install and run theVerifieragainst your generated pact file, and report whether it passes. - In two sentences, explain to a teammate why contract testing wouldn't
have caught a bug where
/orders/{id}returns the wrong order for a valid id (right shape, wrong data) — and what kind of test would.