Pact Provider Verification in Python with pytest-pact
Provider verification is the other half of the contract testing equation. Consumers write pacts that describe what they need. Providers verify those pacts against their real implementation. This guide covers the Python side of that workflow using pact-python and pytest.
How Provider Verification Works
When you run provider verification, Pact does the following:
- Fetches published pact files from the Pact Broker (or reads them from a local path).
- For each interaction in each pact, calls the
providerStatesSetUpendpoint to configure your application state. - Replays the recorded HTTP request against your running provider.
- Compares the actual response to the expected response using the matchers defined in the consumer test.
- Reports pass/fail per interaction and publishes results back to the broker.
Your job as the provider developer is to start your application, implement state handlers, and make the verification pass.
Installing pact-python
pip install pact-python pytest requestspact-python installs the Pact Ruby standalone binary internally — you don't need Ruby on your machine. The package wraps it with a Python API.
For a running example, the provider is a FastAPI service called product-service:
pip install fastapi uvicorn[standard] httpxThe Provider Application
# product_service/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
# In-memory store for tests
_products: dict[int, dict] = {}
class Product(BaseModel):
id: int
name: str
price: float
inStock: bool
@app.get("/products/{product_id}")
async def get_product(product_id: int):
product = _products.get(product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
# Provider state endpoint — only mounted during testing
@app.post("/_pact/provider_states")
async def provider_states(body: dict):
state = body.get("state")
if state == "a product with ID 42 exists":
_products[42] = {
"id": 42,
"name": "Widget Pro",
"price": 29.99,
"inStock": True,
}
elif state == "no product with ID 999 exists":
_products.pop(999, None)
return {"result": "state set"}The /_pact/provider_states endpoint is critical. Pact calls it before each interaction to put your application in the right state. In production you'd never expose this endpoint — mount it only when PACT_PROVIDER_STATES_SETUP_URL is set or via a test-only configuration flag.
Writing the Provider Verification Test
# tests/test_provider.py
import pytest
import subprocess
import time
import requests
from pact import Verifier
PROVIDER_URL = "http://localhost:8001"
PROVIDER_STATES_URL = f"{PROVIDER_URL}/_pact/provider_states"
@pytest.fixture(scope="session", autouse=True)
def provider_server():
"""Start the FastAPI provider server for the duration of the test session."""
proc = subprocess.Popen(
["uvicorn", "product_service.main:app", "--port", "8001"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for the server to be ready
for _ in range(20):
try:
requests.get(f"{PROVIDER_URL}/docs")
break
except requests.ConnectionError:
time.sleep(0.5)
yield proc
proc.terminate()
proc.wait()
def test_product_service_satisfies_contracts():
verifier = Verifier(
provider="product-service",
provider_base_url=PROVIDER_URL,
)
output, _ = verifier.verify_with_broker(
broker_url=os.environ["PACT_BROKER_BASE_URL"],
broker_token=os.environ["PACT_BROKER_TOKEN"],
provider_states_setup_url=PROVIDER_STATES_URL,
publish_verification_results=True,
provider_version=os.environ.get("GITHUB_SHA", "local"),
provider_version_branch=os.environ.get("GITHUB_REF_NAME", "main"),
consumer_version_selectors=[
{"mainBranch": True},
{"deployedOrReleased": True},
],
verbose=False,
)
assert output == 0, "Provider verification failed — see output above"Note the consumer_version_selectors list. This controls which consumer pacts are fetched:
{"mainBranch": True}— the latest pact from each consumer's main branch{"deployedOrReleased": True}— pacts for consumer versions currently deployed to any environment
Always include {"deployedOrReleased": True} so you can't deploy a provider that breaks a consumer already in production.
Provider States in Depth
Provider states handle the stateful nature of real applications. Here's a more complete state handler that works with SQLAlchemy:
# product_service/test_states.py
from sqlalchemy.orm import Session
from product_service.models import Product
from product_service.database import get_db
STATE_HANDLERS = {}
def state(name):
def decorator(fn):
STATE_HANDLERS[name] = fn
return fn
return decorator
@state("a product with ID 42 exists")
def seed_product_42(db: Session):
db.merge(Product(id=42, name="Widget Pro", price=29.99, in_stock=True))
db.commit()
@state("no product with ID 999 exists")
def clear_product_999(db: Session):
db.query(Product).filter(Product.id == 999).delete()
db.commit()
@state("the product catalogue has 5 items")
def seed_catalogue(db: Session):
db.query(Product).delete()
for i in range(1, 6):
db.add(Product(id=i, name=f"Product {i}", price=i * 10.0, in_stock=True))
db.commit()The provider states endpoint dispatches to these handlers:
@app.post("/_pact/provider_states")
async def provider_states(body: dict, db: Session = Depends(get_db)):
state_name = body.get("state", "")
handler = STATE_HANDLERS.get(state_name)
if handler:
handler(db)
return {"result": "ok"}Verifying Against Local Pact Files
During development, you may want to verify against a locally generated pact file without publishing it to the broker:
def test_against_local_pact():
verifier = Verifier(
provider="product-service",
provider_base_url=PROVIDER_URL,
)
output, _ = verifier.verify_pacts(
sources=["./pacts/order-service-product-service.json"],
provider_states_setup_url=PROVIDER_STATES_URL,
)
assert output == 0This is useful when a consumer team is developing a new contract and wants to share the pact file directly (e.g., via a PR attachment) before the CI pipeline publishes it.
Using pactman (Alternative Library)
Some teams prefer pactman, which offers a slightly different API that integrates more naturally with pytest fixtures:
pip install pactman# tests/test_provider_pactman.py
import pytest
from pactman import PactBrokerConfig, ProviderStateMixin
from pactman.verifier import PactVerifier
class TestProductProvider(ProviderStateMixin):
provider_name = "product-service"
provider_base_url = "http://localhost:8001"
@pytest.fixture(autouse=True)
def setup_states(self):
self.register_state("a product with ID 42 exists", self.seed_product_42)
self.register_state("no product with ID 999 exists", self.clear_product_999)
def seed_product_42(self):
# seed logic here
pass
def clear_product_999(self):
# clear logic here
pass
def test_verify(self):
verifier = PactVerifier(
provider=self.provider_name,
provider_base_url=self.provider_base_url,
broker=PactBrokerConfig(
url=os.environ["PACT_BROKER_BASE_URL"],
token=os.environ["PACT_BROKER_TOKEN"],
),
)
verifier.verify()pact-python is generally preferred for new projects as it tracks the upstream Pact specification more closely.
Can-I-Deploy in Python CI
Add this step after provider verification:
# scripts/can_i_deploy.py
import subprocess
import sys
import os
result = subprocess.run(
[
"pact-broker", "can-i-deploy",
"--pacticipant", "product-service",
"--version", os.environ["GITHUB_SHA"],
"--to-environment", "production",
"--broker-base-url", os.environ["PACT_BROKER_BASE_URL"],
"--broker-token", os.environ["PACT_BROKER_TOKEN"],
],
capture_output=True,
text=True,
)
print(result.stdout)
print(result.stderr, file=sys.stderr)
sys.exit(result.returncode)GitHub Actions Integration
# .github/workflows/provider-pact.yml
name: Provider Contract Verification
on:
push:
branches: [main, 'feature/**']
# Also run when a consumer publishes a new pact (via webhook)
repository_dispatch:
types: [pact-changed]
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: product_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- name: Run database migrations
env:
DATABASE_URL: postgresql://test:test@localhost:5432/product_test
run: alembic upgrade head
- name: Verify provider contracts
env:
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_NAME: ${{ github.ref_name }}
DATABASE_URL: postgresql://test:test@localhost:5432/product_test
run: pytest tests/test_provider.py -v
- name: Can-I-Deploy
env:
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: python scripts/can_i_deploy.pyWebhook Trigger from PactFlow
Configure PactFlow to trigger provider verification automatically when a consumer publishes a new pact. In PactFlow settings, create a webhook:
- Event: Contract content changed, Contract published with no provider version
- Method: POST
- URL:
https://api.github.com/repos/your-org/product-service/dispatches - Headers:
Authorization: Bearer {github_token},Content-Type: application/json - Body:
{"event_type": "pact-changed", "client_payload": {"pact_url": "${pactbroker.pactUrl}"}}
This closes the feedback loop: a consumer team pushes a code change, publishes a new pact, and within minutes the provider CI runs and the consumer team knows whether their change is compatible.
Debugging Verification Failures
When verification fails, Pact prints the mismatched interaction:
1) Verifying a pact between order-service and product-service
Given a product with ID 42 exists
a request for product 42
returns a response which
has status code 200 (OK)
has a matching body
$.price -> Expected 29.99 (Number) but received "29.99" (String)This specific error — numeric vs string — is a common mismatch when an ORM returns Decimal types as strings in JSON. Fix it in your serializer:
class ProductResponse(BaseModel):
id: int
name: str
price: float # not Decimal — ensures JSON serialization as number
inStock: bool
class Config:
json_encoders = {Decimal: float}Provider verification failures are always actionable: they point to the exact field and the type mismatch. Unlike integration test failures that require you to trace through logs, Pact gives you a precise diff.