Skip to main content
ship it and sleep

Contract Testing Across Services with Pact

7 min read Chapter 18 of 66

Contract Testing Across Services with Pact

The Failure

The catalog team renamed the price field to unitPrice in their API response. They updated their OpenAPI spec, their own tests passed, and they deployed to production on a Tuesday afternoon.

The checkout service still expected price. Every checkout request failed with a null pointer exception because response.price was undefined. The team rolled back catalog-service, coordinated the field rename across both services, and deployed again two days later.

An OpenAPI spec catches structural changes if someone reads it. A contract test catches structural changes automatically, in CI, before the image is built.

The Mechanism

Consumer-Driven Contracts

In consumer-driven contract testing, the consumer defines what it expects from the provider. The consumer writes tests that say: “When I send a GET request to /api/products/123, I expect a response with fields id, name, price, and currency.”

These expectations are recorded as a “pact” (a JSON file) and published to a Pact broker. The provider runs verification tests against these pacts to prove that its API satisfies the consumer’s expectations.

The flow:

  1. Consumer CI runs consumer contract tests → generates pact file → publishes to Pact broker
  2. Pact broker sends a webhook to the provider repository
  3. Provider CI runs verification tests against the consumer’s pact → reports results to the broker
  4. Consumer CI runs can-i-deploy check before promotion → broker confirms the provider has verified the pact

The E-Commerce Contract Map

ConsumerProviderContract Scope
checkout-servicecatalog-serviceProduct lookup: GET /api/products/:id{id, name, price, currency}
checkout-serviceinventory-serviceStock reservation: POST /api/reservations{reservationId, status, expiresAt}
checkout-servicepayments-servicePayment charge: POST /api/charges{chargeId, status, amount}
frontend-shellcatalog-serviceProduct listing: GET /api/products?category=:cat{items[], total, page}
frontend-shellcheckout-serviceCart operations: POST /api/cart/checkout{orderId, status}

Five contracts. Each consumer defines its expectations. Each provider verifies against all consumers.

The Implementation

Consumer Test: Checkout → Catalog

// checkout-service/tests/contract/catalog.pact.test.js
const { PactV4, MatchersV3 } = require("@pact-foundation/pact");
const { like, integer, string, decimal } = MatchersV3;

const provider = new PactV4({
  consumer: "checkout-service",
  provider: "catalog-service",
});

describe("Catalog API Contract", () => {
  it("returns product details for a valid product ID", async () => {
    await provider
      .addInteraction()
      .given("product 42 exists")
      .uponReceiving("a request for product 42")
      .withRequest("GET", "/api/products/42")
      .willRespondWith(200, (builder) => {
        builder.jsonBody({
          id: integer(42),
          name: string("Wireless Mouse"),
          price: decimal(29.99),
          currency: string("USD"),
          inStock: like(true),
        });
      })
      .executeTest(async (mockServer) => {
        const response = await fetch(`${mockServer.url}/api/products/42`);
        const product = await response.json();

        expect(response.status).toBe(200);
        expect(product.id).toBe(42);
        expect(product.price).toBeDefined();
        expect(product.currency).toBeDefined();
      });
  });

  it("returns 404 for a non-existent product", async () => {
    await provider
      .addInteraction()
      .given("product 99999 does not exist")
      .uponReceiving("a request for non-existent product")
      .withRequest("GET", "/api/products/99999")
      .willRespondWith(404, (builder) => {
        builder.jsonBody({
          error: string("Product not found"),
          code: string("PRODUCT_NOT_FOUND"),
        });
      })
      .executeTest(async (mockServer) => {
        const response = await fetch(`${mockServer.url}/api/products/99999`);
        expect(response.status).toBe(404);
      });
  });
});

Consumer Test: Checkout → Inventory

// checkout-service/tests/contract/inventory.pact.test.js
const { PactV4, MatchersV3 } = require("@pact-foundation/pact");
const { like, string, iso8601DateTimeWithMillis } = MatchersV3;

const provider = new PactV4({
  consumer: "checkout-service",
  provider: "inventory-service",
});

describe("Inventory API Contract", () => {
  it("reserves stock for a product", async () => {
    await provider
      .addInteraction()
      .given("product 42 has 10 units in stock")
      .uponReceiving("a stock reservation request")
      .withRequest("POST", "/api/reservations", (builder) => {
        builder.jsonBody({
          productId: 42,
          quantity: 1,
          orderId: "order-abc-123",
        });
      })
      .willRespondWith(201, (builder) => {
        builder.jsonBody({
          reservationId: string("res-xyz-789"),
          status: string("RESERVED"),
          expiresAt: iso8601DateTimeWithMillis("2024-03-15T10:30:00.000Z"),
        });
      })
      .executeTest(async (mockServer) => {
        const response = await fetch(`${mockServer.url}/api/reservations`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            productId: 42,
            quantity: 1,
            orderId: "order-abc-123",
          }),
        });
        const reservation = await response.json();
        expect(response.status).toBe(201);
        expect(reservation.status).toBe("RESERVED");
        expect(reservation.expiresAt).toBeDefined();
      });
  });
});

CI Pipeline: Consumer Side

# HARDENED: Consumer contract tests with Pact broker publishing
contract-test:
  runs-on: ubuntu-latest
  needs: [build]
  steps:
    - uses: actions/checkout@v4

    - name: Run consumer contract tests
      run: |
        docker run --rm \
          -v $PWD/pacts:/app/pacts \
          ${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }} \
          npm run test:contract

    - name: Publish pacts to broker
      if: github.ref == 'refs/heads/main'
      env:
        PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
        PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
      run: |
        npx @pact-foundation/pact-cli \
          publish pacts/ \
          --consumer-app-version=${{ github.sha }} \
          --branch=${{ github.ref_name }} \
          --tag=${{ github.ref_name }}

    - name: Can I deploy?
      if: github.ref == 'refs/heads/main'
      env:
        PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
        PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
      run: |
        npx @pact-foundation/pact-cli \
          can-i-deploy \
          --pacticipant=checkout-service \
          --version=${{ github.sha }} \
          --to-environment=production

CI Pipeline: Provider Side (Webhook-Triggered)

# HARDENED: Provider verification triggered by Pact broker webhook
# catalog-service/.github/workflows/verify-pacts.yml
name: verify-pacts
on:
  repository_dispatch:
    types: [pact-changed]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start catalog-service
        run: |
          docker compose -f docker-compose.test.yml up -d --wait
          echo "Waiting for service to be ready..."
          docker compose -f docker-compose.test.yml exec -T catalog \
            curl --retry 10 --retry-delay 2 --retry-connrefused \
            http://localhost:8080/health

      - name: Verify pacts
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          npx @pact-foundation/pact-cli \
            verify \
            --provider=catalog-service \
            --provider-base-url=http://localhost:8080 \
            --provider-version=${{ github.sha }} \
            --provider-branch=main \
            --publish-verification-results \
            --pact-broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --pact-broker-token=${{ secrets.PACT_BROKER_TOKEN }}

      - name: Cleanup
        if: always()
        run: docker compose -f docker-compose.test.yml down -v

Pact Broker Webhook Configuration

Configure the Pact broker to trigger provider verification when a consumer publishes a new pact:

{
  "events": [{ "name": "contract_content_changed" }],
  "request": {
    "method": "POST",
    "url": "https://api.github.com/repos/acme/catalog-service/dispatches",
    "headers": {
      "Authorization": "Bearer ${GITHUB_TOKEN}",
      "Accept": "application/vnd.github.v3+json"
    },
    "body": {
      "event_type": "pact-changed",
      "client_payload": {
        "consumer": "${pactbroker.consumerName}",
        "provider": "${pactbroker.providerName}",
        "pactUrl": "${pactbroker.pactUrl}"
      }
    }
  }
}

The Gate

The can-i-deploy check is the contract gate. It queries the Pact broker to verify that all contracts between the consumer (at the current version) and all its providers (at their production versions) have been successfully verified.

If the catalog service has not verified the latest checkout pact, can-i-deploy fails and the checkout service cannot be promoted. This prevents deploying a consumer that depends on API changes the provider has not confirmed.

$ pact-cli can-i-deploy \
    --pacticipant checkout-service \
    --version abc123 \
    --to-environment production

Computer says no ¯\_(ツ)_/¯

CONSUMER          | C.VERSION | PROVIDER           | P.VERSION | SUCCESS?
checkout-service  | abc123    | catalog-service    | def456    | true
checkout-service  | abc123    | inventory-service  | ghi789    | true
checkout-service  | abc123    | payments-service   | jkl012    | FAILED

The verification between checkout-service (abc123) and
payments-service (jkl012) has failed.

The pipeline stops. The checkout team sees exactly which provider contract is broken and can coordinate with the payments team before deploying.

The Recovery

Provider verification fails after a consumer publishes a new pact: The consumer expects something the provider does not offer. Two paths:

  1. The consumer’s expectation is wrong — fix the consumer test
  2. The consumer needs a new feature — the provider team implements it, verifies the pact, and both services deploy

Provider wants to remove a field: Run can-i-deploy for the provider to see which consumers depend on that field. Coordinate with those teams. Use the expand-contract pattern (CH9): add the new field, migrate consumers, then remove the old field.

Pact broker is down: Contract tests still run locally (they generate pacts against a mock server). Publishing and can-i-deploy fail. The pipeline stops at the gate. Do not skip the gate. Fix the broker or wait for it to recover. A 30-minute broker outage is cheaper than a production contract break.

Too many contracts to maintain: This is a sign that service boundaries are wrong. If service A has contracts with 8 other services, service A is doing too much. Refactor the API or introduce an API gateway that consolidates the contract surface.