Contract Testing for AI Agent Microservices: Pact and Schema Validation
Implement consumer-driven contract testing for AI agent microservices using Pact and JSON Schema validation. Catch breaking API changes before they reach production with automated CI integration.
Why Contract Testing Matters for Agent Microservices
In a monolithic agent, if you change a function signature, the compiler or linter catches it immediately. In a microservices architecture, if the RAG service changes its response format from {"documents": [...]} to {"results": [...]}, the conversation manager breaks at runtime. Integration tests might catch this, but they require running the entire system together — which is slow and fragile.
Contract testing sits between unit tests and integration tests. It verifies that two services agree on the shape of their API interaction without requiring both services to run simultaneously. Each side of the contract is tested independently, and mismatches are caught in CI before deployment.
Consumer-Driven Contracts
In consumer-driven contract testing, the consumer (the service making the API call) defines what it expects from the provider (the service receiving the call). The conversation manager consumes the RAG service, so it defines the contract:
# test_rag_contract.py — Consumer side (conversation manager)
import pytest
from pact import Consumer, Provider
pact = Consumer("ConversationManager").has_pact_with(
Provider("RAGRetrieval"),
pact_dir="./pacts",
)
def test_retrieve_documents_contract():
"""Define what the conversation manager expects from RAG."""
expected_body = {
"documents": [
{
"content": "Account balance policies state...",
"score": 0.92,
"metadata": {"source": "policy-docs"},
}
]
}
(
pact.given("documents exist for the query")
.upon_receiving("a retrieval request")
.with_request(
method="POST",
path="/retrieve",
headers={"Content-Type": "application/json"},
body={
"query": "account balance policy",
"top_k": 5,
},
)
.will_respond_with(
status=200,
headers={"Content-Type": "application/json"},
body=expected_body,
)
)
with pact:
# Make the actual call against the Pact mock server
import httpx
response = httpx.post(
f"{pact.uri}/retrieve",
json={"query": "account balance policy", "top_k": 5},
)
assert response.status_code == 200
data = response.json()
assert "documents" in data
assert len(data["documents"]) > 0
assert "content" in data["documents"][0]
assert "score" in data["documents"][0]
This test generates a Pact file — a JSON document describing the expected interaction. The Pact file is shared with the provider team.
Provider Verification
The RAG service team runs the Pact file against their actual service to verify they honor the contract:
# test_rag_provider.py — Provider side (RAG service)
from pact import Verifier
def test_rag_provider_honors_contracts():
verifier = Verifier(
provider="RAGRetrieval",
provider_base_url="http://localhost:8002",
)
output, _ = verifier.verify_pacts(
pact_dir="./pacts",
provider_states_setup_url=(
"http://localhost:8002/_pact/setup"
),
)
assert output == 0, "Provider verification failed"
The provider needs a state setup endpoint that configures test data:
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
# Added to the RAG service for Pact verification
@app.post("/_pact/setup")
async def pact_provider_state(request: Request):
body = await request.json()
state = body.get("state")
if state == "documents exist for the query":
# Seed the vector store with test documents
await vector_store.insert_test_document(
content="Account balance policies state...",
metadata={"source": "policy-docs"},
)
elif state == "no documents exist":
await vector_store.clear_test_data()
return {"status": "ok"}
JSON Schema Validation as a Lightweight Alternative
When Pact feels too heavy, JSON Schema validation provides a simpler contract mechanism. Define schemas for each service's API and validate in tests:
# schemas/rag_response.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["documents"],
"properties": {
"documents": {
"type": "array",
"items": {
"type": "object",
"required": ["content", "score"],
"properties": {
"content": {"type": "string", "minLength": 1},
"score": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"metadata": {"type": "object"}
}
}
}
}
}
Validate responses against this schema in your consumer tests:
import jsonschema
import json
def load_schema(name: str) -> dict:
with open(f"schemas/{name}.json") as f:
return json.load(f)
class RAGClient:
def __init__(self, base_url: str):
self.base_url = base_url
self.schema = load_schema("rag_response")
self.client = httpx.AsyncClient()
async def retrieve(self, query: str, top_k: int = 5) -> dict:
resp = await self.client.post(
f"{self.base_url}/retrieve",
json={"query": query, "top_k": top_k},
)
resp.raise_for_status()
data = resp.json()
# Validate response matches expected schema
jsonschema.validate(instance=data, schema=self.schema)
return data
# Test
async def test_rag_response_matches_schema():
client = RAGClient("http://localhost:8002")
result = await client.retrieve("test query")
# If the schema changed, jsonschema.validate raises
assert len(result["documents"]) >= 0
CI Integration
Add contract verification to your CI pipeline so that breaking changes are caught before merge:
# .github/workflows/contract-tests.yml
name: Contract Tests
on:
pull_request:
branches: [main]
jobs:
consumer-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pact-python pytest httpx
- run: pytest tests/contracts/consumer/ -v
- uses: actions/upload-artifact@v4
with:
name: pacts
path: pacts/
provider-verification:
needs: consumer-contracts
runs-on: ubuntu-latest
strategy:
matrix:
service: [rag-retrieval, tool-execution, memory-service]
steps:
- uses: actions/checkout@v4
with:
repository: "org/${{ matrix.service }}"
- uses: actions/download-artifact@v4
with:
name: pacts
path: pacts/
- run: pip install pact-python pytest
- run: |
docker compose up -d ${{ matrix.service }}
pytest tests/contracts/provider/ -v
FAQ
How is contract testing different from integration testing?
Integration tests run multiple real services together and test end-to-end flows. Contract tests verify that two services agree on API shapes without running them simultaneously. Integration tests are slower (minutes), harder to debug, and catch issues late. Contract tests are fast (seconds), run independently per service, and catch API mismatches early. Use both — contracts in CI on every PR, integration tests nightly or before releases.
What should I include in an AI agent service contract?
Include the request path, method, required headers, request body shape, response status code, and response body shape. For agent services, pay special attention to the structure of LLM-related fields like token counts, model names, and streaming chunk formats. Do not include exact values for dynamic fields — use type matchers instead.
How do I handle contract testing for event-driven communication?
Pact supports message-based contracts. Instead of HTTP interactions, you define the expected message shape. The consumer specifies what events it expects to receive, and the provider verifies it publishes events matching that shape. This works for Kafka, RabbitMQ, and NATS events between agent services.
#ContractTesting #Pact #SchemaValidation #Microservices #AgenticAI #Testing #LearnAI #AIEngineering
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.