Production engineering guide for contract testing patterns covering patterns, implementation strategies, and operational best practices.
Contract Testing Patterns is a collection of proven approaches to ensure that services, APIs, and microservices communicate correctly and consistently across changes in either the provider or consumer. It matters when independent teams ship services that depend on each other, and when versioning, deployment frequency, and interface complexity increase. When a consumer breaks because a provider added a field but forgot to update the documentation, or when a provider deploys a change that the consumer doesn’t expect, contract testing catches these mismatches early and reliably. It is most valuable during continuous integration, release pipelines, and when multiple teams own different parts of a distributed system.
A consumer-driven contract begins with a real usage scenario: a client calling an API endpoint with a specific request and expecting a specific response. The pattern starts by recording a real interaction between the consumer and provider, using tools like curl, httpie, or Postman, and then extracting the request and response as a formal contract.
To capture a contract, the consumer sends an HTTP request with a known payload and verifies the response:
curl -X POST \
-H "Content-Type: application/json" \
-d @order-request.json \
http://orderservice/api/v1/orders \
-w "\nStatus: %{http_code}\n"
The order-request.json file contains:
{
"customerId": 123,
"items": [
{ "productId": 101, "quantity": 2 },
{ "productId": 102, "quantity": 1 }
],
"notes": "Gift for birthday"
}
The provider responds with:
HTTP/1.1 201 Created
Content-Type: application/json
{
"orderId": 4567,
"status": "confirmed",
"total": 149.98,
"currency": "USD",
"createdAt": "2023-10-05T12:34:56Z",
"lines": [
{
"productId": 101,
"quantity": 2,
"price": 49.99,
"total": 99.98
},
{
"productId": 102,
"quantity": 1,
"price": 49.99,
"total": 49.99
}
]
}
The contract is then defined as a JSON file, order-created.contract.json, that captures the request and response in a standardized format:
{
"request": {
"method": "POST",
"path": "/api/v1/orders",
"headers": {
"Content-Type": "application/json"
},
"body": {
"customerId": 123,
"items": [
{ "productId": 101, "quantity": 2 },
{ "productId": 102, "quantity": 1 }
],
"notes": "Gift for birthday"
}
},
"response": {
"status": 201,
"headers": {
"Content-Type": "application/json"
},
"body": {
"orderId": 1234,
"status": "confirmed",
"total": 149.98,
"currency": "USD",
"createdAt": "2023-10-05T12:34:56Z",
"lines": [
{
"productId": 101,
"quantity": 2,
"price": 49.99,
"total": 99.98
},
{
"productId": 102,
"quantity": 1,
"price": 49.99,
"total": 49.99
}
]
}
}
}
This contract becomes a test artifact and a shared specification. It is committed to version control and used by both provider and consumer teams.
The provider team runs a contract test suite on every commit. The test suite loads each contract and verifies that the current implementation satisfies it. A common tool is pact, but the pattern is agnostic: it works with any test runner that can make HTTP calls and validate responses.
The test runner reads the contract and performs the request, then compares the actual response against the expected one.
name: Contract Tests
on: [push]
jobs:
contract-testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start service
run: |
npm install
npm start &
sleep 10
- name: Run contract tests
run: |
pact verify \
--provider-base-url http://localhost:3000 \
--pact-files ./pacts/order-created.contract.json \
--verify-interactions \
--verbose
The pact verify command compares the actual response from the running service against the contract. It checks:
If the provider returns currency: "EUR" instead of USD, the test fails with:
Contract mismatch: response.body.currency
Expected: "USD"
Actual: "EUR"
The test can be configured with flags to handle common edge cases:
--strict — all unexpected fields cause failure--include-optional-fields — optional fields in the contract are not required--ignore-headers — list of headers to ignore during comparisonWhen --strict is used, adding a new field like "processingTime": 2.3 in the response without defining it in the contract fails the test.
When a new version of the API is released, the contract must evolve. The consumer updates their contract to reflect new behavior. The provider must support both old and new contracts during a transition.
The pattern uses a versioning strategy where each contract is associated with a version. The contract file includes a version field:
{
"version": "v1.1",
"request": { ... },
"response": { ... }
}
The provider test suite is configured to accept multiple versions:
- name: Run contract tests
run: |
pact verify \
--provider-base-url http://localhost:3000 \
--pact-files ./pacts/v1.0/order-created.contract.json \
--pact-files ./pacts/v1.1/order-created.contract.json \
--include-optional-fields \
--strict
A new contract is added for the v1.1 version, which includes an additional processingTime field in the response:
"response": {
"status": 201,
"body": {
"orderId": 1234,
"status": "confirmed",
"total": 149.98,
"currency": "USD",
"createdAt": "2023-10-05T12:34:56Z",
"processingTime": 2.3,
"lines": [ ... ]
}
}
When the provider returns only the original response (without processingTime), the test fails with:
Missing field: response.body.processingTime
The provider can then implement the new field and re-run the test. The test suite now passes for both versions, ensuring backward compatibility.
A common mistake is assuming that adding a field to the contract automatically makes it required. Without explicit configuration, a new field is optional by default. To make it required, the contract must include required: true in the field definition:
"response": {
"body": {
"processingTime": {
"type": "number",
"required": true,
"description": "Time in seconds to process the order"
}
}
}
Without this, a service can return processingTime: null and the test passes, even though the consumer expects a non-null value.
Contracts are not just test inputs; they are documentation and a shared contract of truth. They are published to a central registry so both provider and consumer teams can discover and use them.
The registry is a simple HTTP server that serves contract files. The provider uploads contracts using curl or scp:
# Publish contract to registry
curl -X PUT \
-H "Content-Type: application/json" \
-d @order-created.contract.json \
http://registry.contract.example.com/v1/orders/created
The consumer team queries the registry to discover available contracts:
curl http://registry.contract.example.com/v1/orders/created
It returns the full contract, including metadata:
{
"contractId": "orders-created-v1.1",
"version": "v1.1",
"provider": "OrderService",
"consumer": "CheckoutApp",
"createdAt": "2023-10-05T12:34:56Z",
"request": { ... },
"response": { ... }
}
The registry can also serve a list of all contracts:
curl http://registry.contract.example.com/v1/contracts
This returns a list of all available contracts with version and metadata, enabling automated discovery.
In systems with many services, a single contract may be consumed by multiple consumers. Each consumer may have a different version of the same API, and each provider may have multiple consumers.
The pattern uses a contract directory structure, where each contract is named and versioned:
pacts/
├── orders/
│ ├── order-created.v1.0.json
│ └── order-created.v1.1.json
├── customers/
│ ├── customer-lookup.v1.0.json
│ └── customer-lookup.v1.1.json
The provider runs a full contract test suite on every push:
pact verify \
--provider-base-url http://localhost:3000 \
--pact-files pacts/orders/order-created.v1.0.json \
--pact-files pacts/orders/order-created.v1.1.json \
--pact-files pacts/customers/customer-lookup.v1.0.json \
--verify-interactions \
--strict
Each contract is tested independently. If a consumer updates their contract, the provider can run only the relevant tests:
# Run only order-related contract tests
pact verify \
--pact-files pacts/orders/order-created.v1.0.json \
--pact-files pacts/orders/order-created.v1.1.json \
--provider-base-url http://localhost:3000
A common failure mode is missing or incorrect path in the contract. If the provider expects /api/v1/orders but the contract specifies /api/orders, the test fails with a clear error:
Expected request path: /api/v1/orders
Actual request path: /api/orders
Another silent failure is header mismatch. The contract specifies Content-Type: application/json, but the provider returns Content-Type: application/json;charset=utf-8. The test passes unless --exact-headers is used.
Contracts often need to handle dynamic values: timestamps, IDs, and random data.
The pattern uses matchers to define how values should match. A matcher is a key in the body object that defines how a field should be validated.
For example, a createdAt field that is a timestamp should match the ISO8601 format, but the exact time may vary.
"response": {
"body": {
"createdAt": {
"match": "date-time",
"format": "2006-01-02T15:04:05Z"
}
}
}
The matcher date-time validates that the field is a valid ISO8601 timestamp. The format field is optional but recommended for strict validation.
For a orderId field, which is a number that may vary between runs, a matcher is used:
"orderId": {
"match": "number",
"min": 1000,
"max": 99999
}
This allows any number between 1000 and 99999 to pass.
The pattern also supports array matching with eachLike and arrayLike:
"lines": {
"match": "array",
"items": {
"match": "object",
"properties": {
"productId": { "match": "number" },
"quantity": { "match": "number" },
"price": { "match": "number" },
"total": { "match": "number" }
}
}
}
This allows the order lines to be in any order and with any number of items.
Finally, contract tests are used as a gate in deployment pipelines. A service cannot be promoted to production unless all contracts pass.
The pipeline step runs the contract test suite and uploads the results to a dashboard:
# Run contract tests and upload results
pact verify \
--provider-base-url http://localhost:3000 \
--pact-files pacts/*.json \
--verbose \
--output-dir ./results \
--publish-results \
--results-url http://dashboard.contract.example.com
The --publish-results flag uploads the test results to an HTTP endpoint, which records the outcome, duration, and metadata.
When a contract fails, the pipeline pauses and sends a notification with the failure details:
Contract: order-created.v1.1
Status: Failed
Error: Missing field: response.body.processingTime
Expected: 2.3
Actual: null
The consumer team can then update their contract, re-run the test, and merge the changes. The provider team can run the tests locally with pact verify --local to validate changes before deployment.
This pattern ensures that services evolve together, reducing the risk of integration failures and enabling independent, reliable releases.
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