Contract Testing for Microservices

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.

Defining the Contract

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.

Running the Provider Test Suite

The provider runs its contract tests in CI, verifying that its current implementation satisfies all known consumer expectations.

The test suite typically involves:

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.

Running the Consumer Test Suite

Each consumer runs its own contract tests during its CI pipeline, validating that it uses the provider correctly.

The consumer’s test suite typically:

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.

Contract Versioning and Evolution

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:

When the provider updates its contract, it must:

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

Handling Contract Violations

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.

Integrating with CI/CD

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.

Common Pitfalls and Sharp Edges

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.