Implement consumer-driven contract testing to prevent breaking changes across microservices. Covers Pact, Spring Cloud Contract, schema evolution, and CI/CD integration patterns.
Contract testing for microservices ensures that services agree on the shape, semantics, and behavior of their interactions, even as they evolve independently. It matters when multiple teams own services that communicate via APIs, particularly when changes to one service break consumers without coordinated releases. It is essential when deployment cycles differ, when services are versioned independently, and when the cost of downtime or data inconsistency outweighs the cost of testing.
A contract is a shared agreement between a service provider and its consumers about the format and behavior of a request or response. The contract is not just a schema; it includes headers, query parameters, status codes, error formats, and expected side effects. It is expressed as a set of testable assertions, typically stored in a file or database, and validated during service development and CI/CD.
The core idea is that the provider tests its API against a set of expected interactions, and each consumer tests its usage of the provider against those expectations. This decouples the development of services: a consumer can evolve its logic while the provider changes its implementation, so long as the contract remains satisfied.
A contract is typically defined using a standardized format such as OpenAPI (Swagger), JSON Schema, or Protocol Buffers, but the contract itself is agnostic to the format. The key is that the contract is versioned and treated as first-class code.
version: 1.0
provider: order-service
consumer: checkout-service
endpoints:
- path: /api/orders
method: POST
request:
headers:
Content-Type: application/json
body:
type: object
properties:
customer_id:
type: integer
minimum: 1
items:
type: array
items:
type: object
properties:
product_id: { type: integer, minimum: 1 }
quantity: { type: integer, minimum: 1 }
total_amount:
type: number
minimum: 0.01
required: [customer_id, items]
response:
status: 201
headers:
Location: { pattern: "/api/orders/{id}" }
body:
type: object
properties:
id:
type: integer
minimum: 1
customer_id:
type: integer
items:
type: array
items:
type: object
properties:
product_id: { type: integer }
quantity: { type: integer }
price: { type: number, minimum: 0.01 }
total_amount:
type: number
minimum: 0.01
status:
type: string
enum: [pending, confirmed, shipped]
required: [id, customer_id, items, total_amount, status]
This contract defines what the order-service must return when checkout-service sends a POST /api/orders.
The provider runs its contract tests in CI, verifying that its current implementation satisfies all known consumer expectations.
The test suite typically involves:
docker-compose up or systemctl start order-service)A minimal setup uses curl and jq to validate responses:
# run-provider-tests.sh
#!/bin/bash
set -euo pipefail
# Start the service
docker-compose up -d order-service
# Wait for service to be ready
until curl -s http://localhost:8080/health | jq -e '.status == "UP"'; do
sleep 1
done
# Run tests from contract.yml
while IFS= read -r endpoint; do
# Extract request and expected response
request=$(jq -c ".endpoints[] | select(.path == \"$endpoint\") | .request" contract.yml)
expected_response=$(jq -c ".endpoints[] | select(.path == \"$endpoint\") | .response" contract.yml)
# Send request
response=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"customer_id": 123, "items": [{"product_id": 1, "quantity": 2}]}' \
http://localhost:8080/api/orders)
# Validate status
actual_status=$(echo "$response" | jq -r '.status')
expected_status=$(echo "$expected_response" | jq -r '.status')
if [[ "$actual_status" != "$expected_status" ]]; then
echo "FAIL: Expected status $expected_status, got $actual_status"
exit 1
fi
# Validate body
echo "$response" | jq -c -e "$expected_response" || exit 1
# Validate headers
location_header=$(echo "$response" | jq -r '.headers.Location')
expected_location_pattern=$(echo "$expected_response" | jq -r '.headers.Location.pattern')
if ! echo "$location_header" | grep -q "$expected_location_pattern"; then
echo "FAIL: Location header $location_header does not match pattern $expected_location_pattern"
exit 1
fi
done < <(jq -r '.endpoints[].path' contract.yml)
This script runs the provider’s contract tests, asserting that every known request is correctly handled. It fails visibly if a consumer expects a 201 but the provider returns 200, or if the Location header does not match the expected pattern.
Each consumer runs its own contract tests during its CI pipeline, validating that it uses the provider correctly.
The consumer’s test suite typically:
git pull origin main)A typical consumer test uses curl and jq in a test-consumer.sh script:
#!/bin/bash
set -euo pipefail
# Pull latest contract
git pull origin main
# Define consumer-specific test
test_order_creation() {
local response
response=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"customer_id": 123, "items": [{"product_id": 1, "quantity": 2}]}' \
http://order-service.example.com/api/orders)
# Validate response
echo "$response" | jq -c -e '
.id | type == "number" and .id >= 1 |
.status == "confirmed" |
.total_amount | . >= 0.01 |
.items[0].price | . >= 0.01 |
.headers.Location | test("^/api/orders/[0-9]+$")
'
}
test_order_creation
This test runs when the consumer service is deployed. It ensures that the consumer’s logic — such as calculating total price, sending notifications, or updating inventory — is correct relative to the provider’s contract.
Contracts evolve over time. When the provider changes its API, it must update its contract and publish it.
The versioning strategy is key: contracts are versioned independently of service versions. A contract version can be:
v1.2)contracts/ directory with a versioned filename (e.g., contracts/v1.2.yml)version fieldWhen the provider updates its contract, it must:
version field in the contractConsumers can then choose which contract version to test against. The contract serves as a contract of record — the canonical definition of the API.
A common failure is contract drift: the provider changes behavior but forgets to update the contract. The contract becomes outdated, and consumers pass tests with old expectations, even though the provider has changed.
To catch this, providers run contract regression tests:
# contract-regression.sh
#!/bin/bash
set -euo pipefail
# Pull new contract
git pull origin main
# Compare current contract with previous version
prev_contract=$(git log -1 --pretty=format:%s | grep -o 'contract: v[0-9]+\.[0-9]+' | head -1 | cut -d' ' -f2)
git checkout "$prev_contract"
# Run tests against both versions
for version in "$prev_contract" "$(git rev-parse HEAD)"; do
echo "Testing against contract $version"
git checkout "$version"
./run-provider-tests.sh
done
This script compares the current contract against the previous version, ensuring that changes to the contract are validated.
When a contract test fails, the error message must be precise and actionable.
A typical failure looks like:
{
"error": "Contract violation",
"test": "POST /api/orders",
"expected": {
"status": 201,
"body": {
"type": "object",
"properties": {
"status": { "enum": ["pending", "confirmed", "shipped"] }
}
},
"headers": { "Location": { "pattern": "/api/orders/{id}" } }
},
"actual": {
"status": 200,
"body": {
"id": 456,
"customer_id": 123,
"items": [],
"total_amount": 50.0,
"status": "confirmed"
},
"headers": {
"Location": "/orders/456"
}
},
"violations": [
{
"field": "status",
"expected": 201,
"actual": 200,
"message": "Expected status 201, got 200"
},
{
"field": "body.status",
"expected": ["pending", "confirmed", "shipped"],
"actual": "confirmed",
"message": "Status must be one of: pending, confirmed, shipped"
},
{
"field": "headers.Location",
"expected": "/api/orders/{id}",
"actual": "/orders/456",
"message": "Location header does not match pattern /api/orders/{id}"
}
]
}
This output is consumed by CI systems and alerts teams. The error is not just "test failed" — it is a contract violation report that explains exactly what diverged.
Contract testing is most effective when integrated into CI/CD pipelines.
Each service has a ci.yml file that runs contract tests on every commit:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run provider contract tests
run: |
docker-compose up -d
sleep 10
./run-provider-tests.sh
# Exit with non-zero if any test fails
if ! ./run-provider-tests.sh; then
echo "Provider contract tests failed"
exit 1
fi
- name: Publish contract
run: |
git add contracts/
git commit -m "contract: update for v1.3"
git push origin main
Consumers also run tests:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run consumer contract tests
run: |
git pull origin main
./test-consumer.sh
# Fail if any test fails
if ! ./test-consumer.sh; then
echo "Consumer contract tests failed"
exit 1
fi
This ensures that every change to a service is validated against the contract, and that consumers are always aligned with the provider.
v1.2, but the provider publishes v1.3 and the consumer doesn’t update its contract.number for total_amount, but the provider returns a string. The test passes, but the consumer fails at runtime when parsing the number.Location: /api/orders/{id}, but the provider returns Location: /api/orders/456 — the test passes, but the consumer expects /api/orders/{id}.201 on create, but the provider returns 200. The contract passes, but the consumer’s logic breaks.The most silent failure is contract drift: the contract is not updated when the API changes, and the test suite becomes a lie.
Contract testing is not a one-off task. It is a continuous practice — a shared language between services that evolves with them.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy