API contract testing solves a specific problem that unit tests and integration tests don't address: ensuring that when a provider team changes an API, those changes don't break the consumers who depend on it. In microservices architectures, this problem is pervasive. The checkout service depends on the payment service API. The payment service team changes a response field name. Checkout breaks in production. Contract testing catches this before deployment.
What API Contract Testing Is
A contract test verifies the agreement between an API provider and its consumers. The "contract" defines:
- Which endpoints consumers call
- What request format they send
- What response structure they expect
The key insight: the consumer defines the contract, not the provider. The provider's test then verifies it can fulfill the consumer's expectations.
This is "consumer-driven contract testing" — the opposite of the naive approach where providers document what they offer and consumers hope they're reading the same documentation.
Consumer-Driven Contracts vs Provider-Driven
Provider-Driven (Traditional, Problematic)
Provider team: "Our API returns this JSON schema"
↓
Consumer team: Builds against that schema
↓
Provider team: Refactors, renames a field
↓
Consumer team: Discovers breakage in production
Consumer-Driven (Contract Testing)
Consumer team: "We expect this structure from your API"
↓
Contract is stored and shared
↓
Provider team: Runs contract tests before deploy
↓
If contract breaks → Pipeline fails, deployment blocked
Implementing Contract Testing with Pact
Pact is the most widely used consumer-driven contract testing framework. It works in both directions:
- Consumers define contracts (as part of their tests)
- Providers verify they satisfy all consumer contracts
Consumer Side: Define the Contract
// checkout-service/tests/payment-service.pact.spec.js
const { Pact } = require('@pact-foundation/pact');
const path = require('path');
const axios = require('axios');
describe('Payment Service Contract', () => {
const provider = new Pact({
consumer: 'checkout-service',
provider: 'payment-service',
port: 4000,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe('POST /charges', () => {
it('creates a charge successfully', async () => {
// Define what the consumer expects
await provider.addInteraction({
state: 'a payment can be processed',
uponReceiving: 'a charge request',
withRequest: {
method: 'POST',
path: '/charges',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
},
body: {
amount: 1000,
currency: 'usd',
source: 'tok_visa'
}
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: Pact.Matchers.like('ch_abc123'), // Matches any string
amount: Pact.Matchers.like(1000), // Matches any number
currency: 'usd', // Exact match
status: 'succeeded', // Exact match
created_at: Pact.Matchers.term({ // Matches date format
generate: '2025-01-15T14:30:00Z',
matcher: '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z'
})
}
}
});
// Run the actual consumer code against the mock
const checkoutClient = new PaymentClient('http://localhost:4000');
const result = await checkoutClient.createCharge({
amount: 1000,
currency: 'usd',
source: 'tok_visa'
});
// Verify the consumer code handled the response correctly
expect(result.id).toBeDefined();
expect(result.status).toBe('succeeded');
});
it('handles payment declined', async () => {
await provider.addInteraction({
state: 'a payment will be declined',
uponReceiving: 'a charge request that will be declined',
withRequest: {
method: 'POST',
path: '/charges',
headers: { 'Content-Type': 'application/json' },
body: {
amount: 1000,
currency: 'usd',
source: 'tok_chargeDeclined' // Decline test token
}
},
willRespondWith: {
status: 402,
body: {
error: {
type: 'card_error',
code: 'card_declined',
message: Pact.Matchers.like('Your card was declined.')
}
}
}
});
// Verify consumer handles error correctly
const checkoutClient = new PaymentClient('http://localhost:4000');
await expect(
checkoutClient.createCharge({ amount: 1000, currency: 'usd', source: 'tok_chargeDeclined' })
).rejects.toThrow('card_declined');
});
});
});
Running these tests produces a Pact file (contract):
{
"consumer": {"name": "checkout-service"},
"provider": {"name": "payment-service"},
"interactions": [
{
"description": "a charge request",
"providerState": "a payment can be processed",
"request": {
"method": "POST",
"path": "/charges",
"body": {"amount": 1000, "currency": "usd", "source": "tok_visa"}
},
"response": {
"status": 200,
"body": {
"id": "ch_abc123",
"amount": 1000,
"currency": "usd",
"status": "succeeded"
}
}
}
]
}
Provider Side: Verify the Contract
// payment-service/tests/pact-verification.spec.js
const { Verifier } = require('@pact-foundation/pact');
const app = require('../src/app');
const path = require('path');
describe('Payment Service - Pact Verification', () => {
it('validates all consumer contracts', async () => {
const server = app.listen(3000);
await new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://your-pact-broker.pactflow.io',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
provider: 'payment-service',
// Provider state handlers
stateHandlers: {
'a payment can be processed': async () => {
// Set up test data/state for this scenario
await db.seedTestData({ payment_mode: 'normal' });
},
'a payment will be declined': async () => {
await db.seedTestData({ payment_mode: 'decline' });
}
},
publishVerificationResult: true,
providerVersion: process.env.GIT_COMMIT_SHA,
}).verifyProvider();
server.close();
});
});
Pact Broker: The Contract Repository
The Pact Broker stores contracts and tracks which provider versions satisfy which consumer contracts:
# Publish consumer contract to Pact Broker
pact-broker publish \
./pacts \
--broker-base-url https://your-broker.pactflow.io \
--consumer-app-version $(git rev-parse HEAD) \
--broker-token ${PACT_BROKER_TOKEN}
# Check if provider can be deployed safely
pact-broker can-i-deploy \
--pacticipant payment-service \
--version $(git rev-parse HEAD) \
--to-environment production \
--broker-base-url https://your-broker.pactflow.io
The can-i-deploy command is the key integration point with CI/CD:
# GitHub Actions workflow
- name: Verify Pact contracts
run: npm run test:pact
- name: Publish results to Pact Broker
run: |
npx pact-broker publish ./pacts \
--consumer-app-version ${{ github.sha }}
- name: Check deployment safety
run: |
npx pact-broker can-i-deploy \
--pacticipant payment-service \
--version ${{ github.sha }} \
--to-environment production
# This fails (blocks deployment) if any consumer contracts are violated
OpenAPI-Based Contract Testing
If your APIs use OpenAPI/Swagger specifications, you can use spec-based contract testing:
# openapi-validation-config.yml
checks:
# Verify API responses match OpenAPI spec
- name: "Payment API - OpenAPI Compliance"
spec: "./openapi/payment-service.yaml"
endpoints:
- path: "/charges"
method: POST
validate_request: true
validate_response: true
- name: "User API - OpenAPI Compliance"
spec: "./openapi/user-service.yaml"
endpoints:
- path: "/users/{id}"
method: GET
validate_response: true
Tools like schemathesis, Dredd, and Spectral validate API behavior against OpenAPI specs:
# Schemathesis: Generate and run tests from OpenAPI spec
pip install schemathesis
# Run tests against your API based on OpenAPI spec
schemathesis run \
https://api.example.com/openapi.json \
--url https://api.example.com \
--auth "Bearer ${API_TOKEN}" \
--checks all
# Output:
# 100 operations / 2000 tests / 0 failures
# No schema validation errors found
Contract Testing in Practice
What to Contract Test
| API Type | Contract Testing Priority | Reason | |---|---|---| | Internal service-to-service | High | Breaking changes are common | | Public API | Medium | Versioning handles this differently | | Third-party dependency | Low (verify their behavior instead) | You don't control their tests | | Database queries | Not applicable | Use integration tests |
What Not to Contract Test
- Response times (use performance tests)
- Security behavior (use security tests)
- Load capacity (use load tests)
- State management beyond request/response (use integration tests)
Integrating Contract Tests with Monitoring
Contract tests run in CI/CD but don't catch runtime drift. Combine them with runtime monitoring:
# API Contract Monitoring Strategy
# Development/CI: Pact contract tests (catch breaking changes before deploy)
ci_pipeline:
- consumer_pact_tests
- can_i_deploy_gate # Blocks if contracts violated
- deploy_if_safe
# Production: Runtime validation (catch issues that slip through)
production_monitoring:
- url: "https://api.example.com/charges"
assertions:
# Validate key contract fields still exist in production
- type: json_path
path: "$.id"
operator: exists
- type: json_path
path: "$.status"
operator: in
values: ["succeeded", "pending", "failed"]
If a contract field disappears in production (due to a deployment that bypassed contracts), your monitoring catches it.
Schema Drift Detection
Track API schema changes over time to detect gradual drift:
# Schema snapshot comparison
def detect_schema_drift(api_url, auth_header, stored_schema_path):
"""
Compare current API schema against stored baseline.
Alert when unexpected changes occur.
"""
import json
import jsondiff
# Get current schema
response = requests.get(
f"{api_url}/openapi.json",
headers={"Authorization": auth_header}
)
current_schema = response.json()
# Load stored baseline
with open(stored_schema_path) as f:
baseline_schema = json.load(f)
# Compare
diff = jsondiff.diff(baseline_schema, current_schema)
if diff:
# Classify changes
breaking_changes = []
non_breaking_changes = []
for path, change in flatten_diff(diff).items():
if is_breaking_change(path, change):
breaking_changes.append({"path": path, "change": change})
else:
non_breaking_changes.append({"path": path, "change": change})
return {
"drift_detected": True,
"breaking_changes": breaking_changes,
"non_breaking_changes": non_breaking_changes,
"recommendation": "Review breaking changes before deploying"
}
return {"drift_detected": False}
Conclusion
API contract testing is the prevention layer in your reliability stack. It catches breaking API changes before they reach production, where they become incidents. Consumer-driven contracts with Pact are the most rigorous approach for microservices; OpenAPI-based validation works well for API-first teams with formal specifications. Neither replaces runtime monitoring — contract tests verify structure, not availability. Use AzMonitor's API monitoring to continuously verify your contracts are being fulfilled in production, with response body assertions that validate key contract fields are present and correctly typed. Together, contract testing and runtime monitoring provide both "can't deploy" and "alerts if it breaks" coverage for your API agreements.
3 monitors free forever · No credit card needed · Set up in 2 minutes
Start monitoring free →