Approval Testing in Microservices: API Contract Snapshots

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.py

Run these tests separately from unit tests:

# In CI, after deploying to staging
pytest tests/contracts/ -v --tb=short

When 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.

Read more

Start now free