Contract Testing for Microservices: Consumer-Driven Contracts with Pact

Contract Testing for Microservices: Consumer-Driven Contracts with Pact

When teams develop microservices independently, the biggest risk is one team changing an API that another team depends on. Contract testing solves this — without requiring a shared test environment or coordinating release schedules.

What Is Contract Testing?

A contract is a formal specification of the interactions between two services: what the consumer expects to receive, and what the provider agrees to deliver.

Consumer-driven contracts flip the traditional integration testing model. Instead of the provider publishing docs that consumers adapt to, consumers define exactly what they need, and providers verify they meet those needs.

This means:

  • Consumers can test against a mock without a live provider
  • Providers know exactly who depends on which parts of their API
  • Breaking changes are caught in CI, before any service is deployed

Pact: The Standard for Contract Testing

Pact is the most widely-used contract testing framework. It supports most languages (JavaScript, Java, Python, Go, Ruby, .NET) and works with REST, GraphQL, and message-based systems.

How Pact Works

  1. The consumer writes a test that defines what it expects from the provider
  2. Pact generates a pact file (JSON) from that test
  3. The pact file is published to a Pact Broker (shared registry)
  4. The provider runs verification tests using the pact file
  5. If verification passes, both teams know the integration is safe

Consumer Test Example

// order-service/tests/payment-service.pact.spec.js
const { Pact } = require('@pact-foundation/pact');

const provider = new Pact({
  consumer: 'order-service',
  provider: 'payment-service',
  port: 4000,
  log: './logs/pact.log',
  dir: './pacts',
});

describe('Payment Service', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());

  describe('charging a card', () => {
    beforeEach(() => {
      return provider.addInteraction({
        state: 'a valid payment method exists for customer abc',
        uponReceiving: 'a charge request',
        withRequest: {
          method: 'POST',
          path: '/charges',
          headers: { 'Content-Type': 'application/json' },
          body: {
            customerId: 'abc',
            amount: like(9999),
            currency: 'usd'
          }
        },
        willRespondWith: {
          status: 200,
          body: {
            id: like('ch_123'),
            status: 'succeeded',
            amount: like(9999)
          }
        }
      });
    });

    it('charges the customer', async () => {
      const paymentClient = new PaymentClient('http://localhost:4000');
      const result = await paymentClient.charge('abc', 9999, 'usd');
      
      expect(result.status).toBe('succeeded');
      expect(result.amount).toBe(9999);
    });
  });
});

The like() matcher says "I don't care about the exact value, just the type." This prevents brittle tests that break when test data changes.

Provider Verification Example

// payment-service/tests/pact-verification.spec.js
const { Verifier } = require('@pact-foundation/pact');

describe('Pact Verification', () => {
  it('validates the expectations of order-service', () => {
    return new Verifier({
      provider: 'payment-service',
      providerBaseUrl: 'http://localhost:3001',
      pactBrokerUrl: 'https://your-pact-broker.com',
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT,
      stateHandlers: {
        'a valid payment method exists for customer abc': async () => {
          // Set up test data so this state is true
          await db.customers.upsert({
            id: 'abc',
            paymentMethodId: 'pm_valid'
          });
        }
      }
    }).verifyProvider();
  });
});

The stateHandlers set up the database state the consumer test assumes. This is how provider tests stay realistic without sharing a database with the consumer.

Pact Broker: The Registry

The Pact Broker stores all contracts and verification results. You can use:

  • PactFlow — hosted service from the Pact team, free for small teams
  • Self-hosted — open-source broker you run yourself
# Publish pact from consumer CI
npx pact-broker publish ./pacts \
  --broker-base-url https://your-pactflow.io \
  --broker-token <span class="hljs-variable">$PACT_TOKEN \
  --consumer-app-version <span class="hljs-variable">$GIT_COMMIT \
  --branch <span class="hljs-variable">$BRANCH_NAME

The broker tracks which versions of each service are compatible. Before deploying, check:

# Can I deploy order-service@abc123 to production?
npx pact-broker can-i-deploy \
  --pacticipant order-service \
  --version <span class="hljs-variable">$GIT_COMMIT \
  --to-environment production \
  --broker-base-url https://your-pactflow.io

If any consumer pact fails against the version you're deploying, can-i-deploy returns exit code 1 and blocks the deployment.

Matching Rules

Pact's matching rules let you write flexible contracts that don't break on irrelevant changes.

Type Matchers

import { like, eachLike, term, integer, decimal } from '@pact-foundation/pact/src/dsl/matchers';

willRespondWith: {
  status: 200,
  body: {
    id: like('abc-123'),           // any string
    count: integer(5),             // any integer
    price: decimal(9.99),          // any decimal
    status: term({                 // matches regex
      generate: 'active',
      matcher: '^(active|inactive|pending)$'
    }),
    items: eachLike({              // array with at least one item
      name: like('widget')
    })
  }
}

When to Use Exact Values

Use exact values only when the specific value matters to the consumer's business logic:

// Good: exact status code matters
status: 200

// Good: exact enum value drives consumer behavior  
paymentStatus: 'succeeded'  // consumer branches on this

// Bad: exact dynamic ID doesn't matter
orderId: '4f8a2b'  // use like('4f8a2b') instead

Message Contract Testing

Pact also supports asynchronous messaging (Kafka, SNS, RabbitMQ):

// Consumer: order-service expects this event from inventory-service
messagePact
  .given('inventory available for product abc')
  .expectsToReceive('inventory reserved event')
  .withContent({
    event: 'inventory.reserved',
    productId: like('abc'),
    quantity: integer(1),
    reservationId: like('res-123')
  })
  .verify(async (message) => {
    // Simulate processing the message
    await orderService.handleInventoryReserved(message);
    // Assert the side effects
    const order = await Order.findByReservation(message.reservationId);
    expect(order.status).toBe('confirmed');
  });

GraphQL Contract Testing

For GraphQL APIs, use Pact's GraphQL body matching:

import { GraphQLInteraction, Matchers } from '@pact-foundation/pact';

const graphqlQuery = new GraphQLInteraction()
  .uponReceiving('a query for user details')
  .withRequest({ path: '/graphql', method: 'POST' })
  .withQuery(
    `query GetUser($id: ID!) {
      user(id: $id) {
        id
        name
        email
      }
    }`
  )
  .withVariables({ id: '123' })
  .willRespondWith({
    status: 200,
    body: {
      data: {
        user: {
          id: Matchers.like('123'),
          name: Matchers.like('Alice'),
          email: Matchers.like('alice@example.com')
        }
      }
    }
  });

CI/CD Integration

Consumer Pipeline

# .github/workflows/consumer.yml
- name: Run consumer pact tests
  run: npm test

- name: Publish pacts to broker
  run: |
    npx pact-broker publish ./pacts \
      --broker-base-url $PACT_BROKER_URL \
      --broker-token $PACT_TOKEN \
      --consumer-app-version ${{ github.sha }} \
      --branch ${{ github.ref_name }}

Provider Pipeline

# .github/workflows/provider.yml
- name: Verify pacts from broker
  env:
    GIT_COMMIT: ${{ github.sha }}
    PACT_BROKER_TOKEN: ${{ secrets.PACT_TOKEN }}
  run: npm run test:pact

- name: Check can-i-deploy
  run: |
    npx pact-broker can-i-deploy \
      --pacticipant payment-service \
      --version ${{ github.sha }} \
      --to-environment production

Common Mistakes

Testing provider implementation details. Contracts should describe what the consumer needs, not every field the provider happens to return. Keep contracts minimal.

Not using state handlers. If you skip state handlers, provider tests run against whatever data happens to be in the test database. This causes flaky tests that pass sometimes and fail others.

Duplicating unit test coverage. Don't test all business logic in contract tests. Contract tests verify the API shape, not every business rule.

Ignoring the can-i-deploy check. Publishing verification results is useless if you don't gate deployments on can-i-deploy. Both steps are required.

Letting pacts get stale. When consumers stop using an endpoint, remove the interaction from the pact. Stale contracts create false confidence and slow down provider verification.

When Contract Testing Isn't Enough

Contract testing covers the shape and protocol of communication. It doesn't test:

  • Actual business logic correctness
  • Performance under load
  • Data consistency across services
  • Third-party service behavior

You still need integration tests and E2E tests for these. Contract testing reduces how many integration tests you need by catching API mismatches early.

Contract testing is most valuable when multiple teams own different services and deploy independently. If you have a single team managing all services, the overhead may not be worth it — shared integration tests might be simpler.

For teams at scale, contract testing is often the single highest-ROI testing investment in the microservices stack.

Read more