Approval Testing in Microservices: API Contract Snapshots
Microservice architectures distribute the blast radius of API changes across multiple teams and deployment pipelines. A field renamed in the user service breaks the order service, which breaks the notification service, and nobody finds out until three services are deployed and the monitoring lights up. The standard advice is "write contract tests" — but the contract testing ecosystem is fragmented, and the tools have significant setup costs.
Approval testing offers a lighter-weight approach to parts of this problem. It's not a complete replacement for consumer-driven contracts, but for teams that need more coverage than they currently have without the infrastructure investment of Pact, it fills meaningful gaps.
Pact vs Approval Testing: Different Problems
Pact is a consumer-driven contract testing tool. The consumer defines what it expects from the provider, the provider verifies it can satisfy those expectations, and the Pact Broker coordinates between them. This is the right tool when:
- Services are owned by different teams
- You need formal negotiation of contracts
- Breaking changes need to be caught before deployment, not after
- You have the infrastructure budget for a Pact Broker
Approval testing for service contracts is different. Instead of defining expectations upfront, you capture what the provider actually returns and treat that as the approved baseline. This is more useful when:
- You own both sides of the service boundary
- You want to catch accidental API changes before they propagate
- You need quick coverage without infrastructure setup
- You're testing internal services where formal Pact protocol is overkill
The two approaches compose: use Pact at external service boundaries, use approval testing at internal ones.
Snapshotting API Responses
The basic pattern: call the service, capture the full response, approve it. Any future change to the response shape — added fields, removed fields, changed types — fails the test.
In Python with approvaltests:
import requests
from approvaltests import verify_as_json
from approvaltests.scrubbers import create_regex_scrubber, combine_scrubbers
def test_user_service_get_profile_contract():
response = requests.get(
f"{USER_SERVICE_URL}/users/test-user-001/profile",
headers={"Authorization": f"Bearer {TEST_TOKEN}"}
)
assert response.status_code == 200
scrubber = combine_scrubbers(
# Remove dynamic values that legitimately change
create_regex_scrubber(r'"last_login": "[^"]+"', '"last_login": "<DATETIME>"'),
create_regex_scrubber(r'"request_id": "[^"]+"', '"request_id": "<UUID>"'),
)
verify_as_json(response.json(), scrubber=scrubber)The approved file documents the exact response contract:
{
"user_id": "test-user-001",
"username": "testuser",
"email": "test@example.com",
"profile": {
"display_name": "Test User",
"bio": "Test account for contract verification",
"avatar_url": "https://cdn.example.com/avatars/default.png"
},
"roles": ["user", "beta_tester"],
"last_login": "<DATETIME>",
"request_id": "<UUID>"
}When the user service team renames username to login_name in their next release, your contract test fails immediately — before you deploy the consuming service and discover the breakage in production.
The Consumer Snapshot Pattern
A more systematic approach is to snapshot responses from the consumer's perspective — every place in your codebase where you call a specific external endpoint gets a corresponding snapshot:
# services/user_client.py
class UserServiceClient:
def get_profile(self, user_id: str) -> dict:
response = self.session.get(f"/users/{user_id}/profile")
response.raise_for_status()
return response.json()
def search_users(self, query: str, limit: int = 20) -> dict:
response = self.session.get("/users/search", params={"q": query, "limit": limit})
response.raise_for_status()
return response.json()# tests/contracts/test_user_service_client_contracts.py
import pytest
from unittest.mock import patch
from approvaltests import verify_as_json
class TestUserServiceClientContracts:
def test_get_profile_response_shape(self, live_user_service):
client = UserServiceClient(base_url=live_user_service.url)
response = client.get_profile("test-user-001")
verify_as_json(response, test_name="get_profile")
def test_search_users_response_shape(self, live_user_service):
client = UserServiceClient(base_url=live_user_service.url)
response = client.search_users("test")
verify_as_json(response, test_name="search_users")Running these tests against a staging environment captures the current contract. These tests then run in CI against staging before every deployment.
Snapshotting What the HTTP Client Sends
Equally important is snapshotting what your service sends to other services — not just what it receives. When you change how you construct requests (different headers, different query parameters, different request body structure), you need to know.
In Python with requests-mock:
import requests_mock
from approvaltests import verify
def test_order_service_request_to_inventory():
with requests_mock.Mocker() as m:
m.post(
"http://inventory-service/api/reserve",
json={"reservation_id": "RES-001", "status": "reserved"}
)
order_service.create_order(test_order_data)
# Capture what was actually sent
request = m.last_request
request_snapshot = {
"method": request.method,
"url": request.url,
"headers": {
k: v for k, v in request.headers.items()
if k in ["Content-Type", "Accept", "X-Service-Name"]
},
"body": request.json()
}
verify_as_json(request_snapshot)This test will fail if someone changes the order service to send a different payload to the inventory service — catching accidental breaking changes before they reach staging.
In Node.js with nock:
const nock = require('nock');
const { verify } = require('approvals');
test('order service sends correct reservation request', async () => {
let capturedRequest;
nock('http://inventory-service')
.post('/api/reserve')
.reply(function(uri, requestBody) {
capturedRequest = { uri, body: requestBody, headers: this.req.headers };
return [200, { reservation_id: 'RES-001', status: 'reserved' }];
});
await orderService.createOrder(testOrderData);
// Scrub dynamic headers before approving
delete capturedRequest.headers['x-request-id'];
delete capturedRequest.headers['date'];
verify(__dirname, 'inventory-reservation-request', JSON.stringify(capturedRequest, null, 2));
});GraphQL Response Snapshots
GraphQL responses are particularly well-suited for approval testing because the response structure is determined by the query — and if the server changes what fields it returns, you want to know.
import requests
from approvaltests import verify_as_json
PRODUCTS_QUERY = """
query GetProducts($category: String!, $limit: Int!) {
products(category: $category, first: $limit) {
edges {
node {
id
name
price
inventory {
available
reserved
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
def test_products_query_contract():
response = requests.post(
f"{GRAPHQL_URL}/graphql",
json={
"query": PRODUCTS_QUERY,
"variables": {"category": "electronics", "limit": 5}
},
headers={"Authorization": f"Bearer {TEST_TOKEN}"}
)
assert response.status_code == 200
data = response.json()
assert "errors" not in data
# Scrub IDs and cursors which are opaque and may change
scrubber = combine_scrubbers(
create_regex_scrubber(r'"id": "([^"]+)"', '"id": "<ID>"'),
create_regex_scrubber(r'"endCursor": "([^"]+)"', '"endCursor": "<CURSOR>"'),
)
verify_as_json(data, scrubber=scrubber)This catches breaking changes in the GraphQL schema: fields being removed, types changing, nullable fields becoming non-nullable.
Kafka Message Schema Snapshots
For event-driven microservices, Kafka message schemas are contracts too. When the user service publishes a UserUpdated event, every downstream consumer depends on its structure.
from kafka import KafkaConsumer
from approvaltests import verify_as_json
import json
def test_user_updated_event_schema():
# Trigger the event
user_service.update_user(test_user_id, {"display_name": "Updated Name"})
# Consume it
consumer = KafkaConsumer(
"user-events",
bootstrap_servers=TEST_KAFKA_BROKERS,
auto_offset_reset="latest",
consumer_timeout_ms=5000,
value_deserializer=lambda x: json.loads(x.decode('utf-8'))
)
messages = []
for message in consumer:
if message.value.get("event_type") == "UserUpdated":
messages.append(message.value)
break
assert len(messages) == 1, "Expected UserUpdated event not received"
event = messages[0]
# Scrub dynamic values
scrubber = combine_scrubbers(
create_regex_scrubber(r'"timestamp": \d+', '"timestamp": <TIMESTAMP>'),
create_regex_scrubber(r'"event_id": "[^"]+"', '"event_id": "<UUID>"'),
)
verify_as_json(event, scrubber=scrubber)The approved file documents the exact Kafka message structure. Schema changes that break consumers show up as test failures before deployment.
Backward Compatibility Verification
When you need to verify that a service change is backward compatible, approval testing can snapshot both the old and new response in the same test run:
def test_user_service_backward_compatibility():
# Current client (uses v1 API)
v1_response = v1_client.get_user(test_user_id)
# New client (uses v2 API with additional fields)
v2_response = v2_client.get_user(test_user_id)
# Verify all v1 fields still present and unchanged in v2
v2_as_v1_perspective = {
k: v2_response[k]
for k in v1_response.keys()
if k in v2_response
}
# This should match the v1 snapshot exactly
verify_as_json(v2_as_v1_perspective, test_name="v1_fields_in_v2_response")If the v2 API removes or changes any fields that v1 consumers rely on, this test fails.
Organizing Contract Tests
Contract tests work best as a separate test suite that runs on a schedule against staging, not just on every commit. A structure that scales:
tests/
contracts/
__init__.py
conftest.py # live service fixtures
approved/ # approved snapshots
user_service/
get_profile.approved.json
search_users.approved.json
inventory_service/
reserve_items.approved.json
kafka/
user_updated_event.approved.json
test_user_service_contracts.py
test_inventory_service_contracts.py
test_kafka_contracts.pyRun these tests separately from unit tests:
# In CI, after deploying to staging
pytest tests/contracts/ -v --tb=shortWhen a contract test fails, the diff tells you exactly what changed in the external service. This is more useful than a generic "request failed" error — you know whether it's a new field (probably safe), a removed field (breaking for your consumers), or a changed value format (requires investigation).
The investment in contract snapshots compounds over time. Every time a downstream service changes its API, you find out in your test run rather than from an error report. Every time you change how you call an upstream service, you have a record of what you changed and why. Microservice testing tends to get harder as systems grow — approval testing keeps the complexity manageable by making the contracts explicit and visible rather than implicit and discovered only in production.
HelpMeTest can run these same contract checks on a continuous schedule against your live environments, alerting you when contract drift happens even outside your CI pipeline.