Skip to content

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

pip install pact-python requests

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")
$ pytest test_consumer.py -v
test_consumer.py::test_get_user PASSED
1 passed in 0.39s

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

$ cat pacts/WebConsumer-UserService.json
{
  "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

  1. Write a consumer contract for a POST /orders endpoint expecting a {"id": ..., "status": "created"} response, run it, and confirm the generated pacts/*.json file records the exact request body and response you specified.
  2. Add a second interaction to the same Pact object for a GET /orders/{id} call returning 404 when the order doesn't exist, and confirm both interactions appear in the written pact file.
  3. 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.
  4. If you have a real HTTP service to test against (even a small Flask app you write yourself with /orders returning matching JSON), install and run the Verifier against your generated pact file, and report whether it passes.
  5. 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.