← Back to Blog
Contract Testing QA Python April 6, 2026 · 8 min read

Consumer-Driven Contract Testing with pact-python v3

Third-party API boundaries are where integrations silently break. The SaaS vendor ships a new release, a field gets renamed, a previously optional key becomes required, and your system starts failing in production before any test catches it. Unit tests mock the HTTP layer entirely, so they cannot detect this. End-to-end tests hit the real API, but they are slow, flaky, and do not run in CI without live credentials. Contract testing sits between these two extremes: it verifies the shape of the API your code depends on, without making a live network call, and it produces a machine-readable artifact that the provider can run against their own implementation.

This post walks through consumer-driven contract testing using pact-python v3, the Python binding for the Pact specification. The examples come from real test code I wrote for a production integration against the GoHighLevel (GHL) CRM API v2. The consumer is a Python bot application; the provider is a third-party SaaS that we do not control. This is the exact scenario where contract testing pays off most.

What Contract Testing Is (and Why It Matters)

A contract is a record of what interactions the consumer expects from the provider: which endpoints it calls, what request shape it sends, and what response shape it needs back. The consumer owns the contract definition. The provider verifies it. Neither side needs the other to be running during their respective test runs.

This matters most at third-party boundaries for a specific reason: you cannot add a test to the vendor's CI pipeline. You can only write tests on your side. Contract testing gives you a way to express those expectations precisely and replay them cheaply, and it gives you a portable artifact you can re-run whenever the provider pushes an update, with or without live credentials.

The failure mode it prevents is subtle: a provider changes a response field from "contactId" to "contact_id". Your integration code references the old key. Nothing raises an exception — you just get None where you expected a string, and a downstream database write silently stores a null. Contract tests catch this at the shape-verification stage, not in production.

Consumer vs Provider Contracts

The Pact model has two distinct phases:

  • Consumer phase. The consumer team writes tests that define the expected interactions. Pact spins up a mock server that replays those interactions, and the real consumer code runs against it. If the code works against the mock, the interaction is recorded to a pact JSON file.
  • Provider phase. The provider team (or you, if you own both sides) takes the pact JSON, replays each interaction against the real provider implementation, and verifies that the responses match. For a third-party API like GHL, you run this step against a sandbox environment or in a separate scheduled job, not in your main CI.

This post focuses on the consumer phase, because that is what you control when integrating against a third-party SaaS.

The pact-python v3 API

The v3 API introduced a fluent builder pattern that is more explicit than the v2 decorator-based style. The core flow is four steps: create a Pact instance, define the interaction with the builder chain, run your real code against the mock server with pact.serve(), then write the interaction to disk with pact.write_file().

from pact import Pact
from pathlib import Path

PACTS_DIR = Path(__file__).parent / "pacts"
PACTS_DIR.mkdir(exist_ok=True)

pact = Pact("my-consumer", "my-provider")

(
    pact.upon_receiving("a request to get resource 123")
        .given("resource 123 exists")
        .with_request("GET", "/resources/123")
        .will_respond_with(200)
        .with_body({"id": "123", "name": "Widget"}, "application/json")
)

with pact.serve() as srv:
    # Point your real client code at srv.url
    result = await my_client.get_resource("123")

pact.write_file(PACTS_DIR) # appends/merges into the pact JSON

The builder chain reads like a sentence: "upon receiving this description, given this provider state, with this request, the provider will respond with this status and this body." The given() call specifies the provider state — a string that tells the provider verification step what precondition to set up before replaying the interaction.

The with_body() method on the response side specifies exact match by default. Pact v3 also supports matchers (regex, type, like) if you need to handle dynamic values, but exact match is appropriate for the fields your code actually reads.

A Real Example: GET /contacts/{id}

Here is the full contract test for fetching a contact from the GHL API. The client code under test wraps every response in an envelope: {"success": True, "data": <ghl_json>, "status_code": N}. This is an implementation detail of the client, not the API — understanding the distinction is important for writing correct assertions.

@pytest.mark.asyncio
async def test_get_contact_contract(pact, ghl_client):
    """Contract: GET /contacts/{id} — GHL returns contact; client wraps in data envelope."""
    contact_id = "contact_abc_123"
    ghl_response_body = {
        "id": "contact_abc_123",
        "email": "lead@example.com",
        "firstName": "John",
        "lastName": "Doe",
        "phone": "+15550001234",
        "locationId": MOCK_LOCATION,
        "tags": ["Dallas", "buyer"],
    }

    (
        pact.upon_receiving("a request to get contact contact_abc_123")
            .given("contact contact_abc_123 exists")
            .with_request("GET", f"/contacts/{contact_id}")
            .will_respond_with(200)
            .with_body(ghl_response_body, "application/json")
    )

    with pact.serve() as srv:
        ghl_client.BASE_URL = str(srv.url).rstrip("/")
        result = await ghl_client.get_contact(contact_id)

    pact.write_file(PACTS_DIR) # merge into shared pact file

    # _make_request wraps response: {"success": True, "data": <ghl_json>, "status_code": 200}
    assert result["success"] is True
    assert result["data"]["id"] == contact_id
    assert result["data"]["email"] == "lead@example.com"
    assert "firstName" in result["data"]
    assert "tags" in result["data"]

A few details worth noting. The mock server URL comes from srv.url inside the context manager, and you point your client at it by overriding its base URL. The test uses MOCK_TOKEN and MOCK_LOCATION constants (patched into settings) rather than real credentials — the mock server does not validate authentication headers, so the client never needs a real token. The assertions check the envelope fields (result["success"], result["data"]) rather than the raw HTTP response, because that is what the calling code actually consumes.

An Interesting Edge Case: Testing Error Envelopes

When the GHL API returns a 404, most HTTP clients raise an exception. The GHL client in this codebase does not — it catches non-retryable HTTPStatusError and converts it into an error envelope: {"success": False, "error": ..., "status_code": 404}. This is a deliberate design choice that makes the calling code simpler: every response has the same shape, and you check result["success"] rather than catching exceptions everywhere.

Contract testing this behavior requires you to contract the provider-side 404 response and assert the client-side envelope transformation separately:

@pytest.mark.asyncio
async def test_get_contact_not_found_contract(pact, ghl_client):
    """Contract: GET /contacts/{id} — GHL returns 404; client returns error envelope (no raise)."""
    contact_id = "contact_does_not_exist"

    (
        pact.upon_receiving("a request to get a nonexistent contact")
            .given("contact contact_does_not_exist does not exist")
            .with_request("GET", f"/contacts/{contact_id}")
            .will_respond_with(404)
            .with_body({"message": "Contact not found", "code": 404}, "application/json")
    )

    with pact.serve() as srv:
        ghl_client.BASE_URL = str(srv.url).rstrip("/")
        # _make_request catches non-retryable HTTPStatusError and returns error dict
        result = await ghl_client.get_contact(contact_id)

    pact.write_file(PACTS_DIR)

    assert result["success"] is False
    assert result["status_code"] == 404

Notice that we do not write with pytest.raises(HTTPStatusError). If the client's error-handling contract is "never raise on 4xx, always return an envelope," then testing for a raised exception would be testing the wrong behavior. The contract test forces you to be explicit about whether errors surface as exceptions or as data. That clarity is valuable beyond the test itself — it documents the integration contract for every future engineer who touches this code.

The same pattern applies to the POST contracts that encode client-side transformations. The send_message contract specifies disclosed_message = input_message + "\n[AI-assisted message]" as the request body, because the client appends a disclosure footer before sending. The create_opportunity contract specifies an expected_request_body that includes "locationId": MOCK_LOCATION, because the client injects the location ID from settings before the request goes out. The contract captures what actually hits the wire, not what the caller passed in.

The FFI Limitation and the Per-Test Merge Pattern

pact-python v3 is built on the Pact FFI (foreign function interface), a Rust-based shared library that handles the mock server lifecycle. The FFI has a constraint: you cannot safely reuse a single Pact instance across multiple test functions. Attempting to define a second interaction on an already-served pact produces undefined behavior or silently overwrites the previous interaction, depending on the FFI version.

The naive workaround is to create one large pact file with all interactions pre-defined before any test runs. This forces you to define every interaction in one place and makes tests harder to read and isolate.

The pattern that works cleanly is: create a fresh Pact instance per test, then call pact.write_file() at the end of each test with the same output directory. The FFI merges interactions into the existing file rather than overwriting it. Combined with a session-scoped fixture that deletes any stale file before the test run begins, you get a clean accumulated pact JSON at the end of the session.

PACTS_DIR = Path(__file__).parent / "pacts"
PACTS_DIR.mkdir(exist_ok=True)

@pytest.fixture(scope="session", autouse=True)
def clear_pact_file():
    """Remove stale pact file before session so interactions accumulate cleanly."""
    pact_file = PACTS_DIR / "jorge-bots-ghl-api-v2.json"
    if pact_file.exists():
        pact_file.unlink()

@pytest.fixture
def pact():
    """Fresh Pact instance per test; writes merge into a shared pact file."""
    if not PACT_AVAILABLE:
        pytest.skip("pact-python not installed")
    return Pact("jorge-bots", "ghl-api-v2")

# Each test function receives a fresh pact, defines one interaction,
# and calls pact.write_file(PACTS_DIR) at the end.
# After 5 tests, the file contains all 5 interactions.

The naming convention for the pact file (jorge-bots-ghl-api-v2.json) matches the consumer and provider names passed to Pact() — that is how the FFI determines which file to merge into. If you change the consumer or provider name between tests, you get separate files. This is usually a bug, not a feature, so keeping the names consistent across all tests in a module is important.

The PACT_AVAILABLE guard around the import is worth keeping. pact-python requires the FFI shared library to be present, which is not always true in minimal CI environments. Guarding with a skip rather than a hard import failure lets the rest of the test suite run without pact-python installed.

How the Pact JSON Fits Into CI

After the consumer tests pass, you have a pact JSON file in tests/contract/pacts/. This file is the artifact that makes contract testing useful beyond the current run.

Two practices make this work in CI:

Version the pact file in source control. Commit the generated JSON alongside the test code. When a test changes the expected contract — because your code now reads a new field, or because the provider changed a field name — the diff in the pact JSON is visible in code review. This creates an explicit audit trail of contract changes. Reviewers can see exactly which fields were added, removed, or renamed, not just that "some tests were updated."

Run provider verification separately. Consumer tests run in your main CI on every pull request. Provider verification runs in a separate job — ideally triggered by the provider's release pipeline, or on a schedule, or both. For a third-party API like GHL, provider verification requires a sandbox environment and a real (non-mock) token scoped to that sandbox. Running it on a schedule (daily or on every consumer release) catches breaking changes without blocking your main CI on live network availability.

# .github/workflows/contract-verify.yml (provider verification job)
# Runs on schedule or workflow_dispatch; not on every PR

- name: Run consumer contract tests
  run: pytest tests/contract/ -m contract -v

- name: Verify pact file is committed
  run: git diff --exit-code tests/contract/pacts/

# Separate job, separate credentials:
- name: Provider verification (against GHL sandbox)
  run: pytest tests/contract/test_ghl_provider.py -v
  env:
    GHL_API_KEY: ${{ secrets.GHL_SANDBOX_API_KEY }}
    GHL_LOCATION_ID: ${{ secrets.GHL_SANDBOX_LOCATION_ID }}

The git diff --exit-code step is a lightweight safeguard: it fails the build if a developer ran the tests locally, the pact JSON changed, but the updated file was not staged. This prevents the common failure mode where the pact file on disk diverges from the committed version.

For teams using the Pact Broker, the pact JSON can be published there instead of committed directly to source control. The broker adds can-i-deploy gates, webhook triggers, and interaction history. For a small team or a third-party-only integration, committing the file directly is simpler and sufficient.

Summary

Consumer-driven contract testing fills a gap that neither unit tests nor end-to-end tests cover: verifying the exact shape of a third-party API dependency without live credentials or network access. The pact-python v3 API is more verbose than earlier versions but more explicit about what is being contracted. The per-test Pact instance plus write_file() merge pattern works around the FFI limitation cleanly. And versioning the pact JSON as a build artifact gives you a diff-visible record of every contract change across your API integration's history.

For more on how this fits into a broader QA automation strategy — including end-to-end tests, performance benchmarks, and the full CI pipeline — see the QA portfolio page.