Skip to main content

Consumer-Driven API Contract Testing with Pact 2026

·APIScout Team
testingapipactcontract-testingmicroservices

Consumer-Driven API Contract Testing with Pact in 2026

TL;DR

Integration testing between microservices has a fundamental problem: you either test against the real service (slow, expensive, flaky), mock the service yourself (the mock drifts from reality), or don't test the integration at all (bugs in production). Consumer-driven contract testing solves this. With Pact.js, each API consumer defines a "contract" — what endpoints they call and what response shape they expect. The API provider runs these contracts as tests. If the provider breaks any consumer's contract, the tests fail before deployment. The result: you catch breaking API changes before they reach production, without requiring both services to be deployed at the same time.

Key Takeaways

  • The core problem: When Service A calls Service B's API, how do you test that B's API is still compatible with A's expectations without running both services simultaneously?
  • Pact's answer: Consumers generate contracts; providers verify them — no live service-to-service calls needed
  • Pact Broker stores and shares contracts between teams; PactFlow is the managed SaaS version
  • can-i-deploy CLI checks if a consumer/provider pair is safe to deploy — the key CI/CD integration point
  • Pact works best for REST and GraphQL — gRPC support exists but is more complex
  • Not a replacement for E2E tests — Pact catches contract violations; E2E tests catch workflow failures; both are needed

The Problem Pact Solves

Without Contract Testing

In a typical microservice setup:

Frontend → Orders API → [Users Service, Products Service, Shipping Service]

The Orders API team mocks out the Users Service in their tests. The mock works as written — but three months ago, the Users Service changed the user.address field from { street, city } to { line1, line2, city, zip }. The Orders API mock still returns the old format. Tests pass. Integration breaks in production.

The alternatives are:

  1. E2E tests against real services — slow, expensive, require all services to be deployed simultaneously
  2. Shared test environments — expensive to maintain, often flaky, don't catch contract changes before merge
  3. API snapshots — like jest snapshots but for API responses; still require a running server and can accept wrong-but-consistent behavior

Pact's Approach

Pact inverts the testing relationship:

  1. Consumer defines expectations: "I expect GET /users/:id to return { id, name, email, address: { line1, city, zip } }"
  2. Pact generates a contract (a JSON file) from these expectations
  3. Consumer tests run against a Pact mock server — not the real service
  4. Provider verifies the contract — runs the consumer's expectations as test cases against the real provider
  5. If the provider's API changes in a way that breaks the consumer's expectations, the provider's verification fails

Setting Up Pact.js: Consumer Side

Installation

npm install --save-dev @pact-foundation/pact

Writing a Consumer Test

// tests/consumers/orders-users-client.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
import path from 'path'
import { UsersClient } from '../../src/clients/users-client'

const { like, string, integer, eachLike } = MatchersV3

const provider = new PactV3({
  consumer: 'OrdersService',
  provider: 'UsersService',
  dir: path.resolve(process.cwd(), 'pacts'),  // Where contracts are saved
  port: 1234,
})

describe('OrdersService → UsersService contract', () => {
  describe('GET /users/:id', () => {
    it('returns a user when the user exists', async () => {
      await provider
        .given('a user with ID abc123 exists')
        .uponReceiving('a request for user abc123')
        .withRequest({
          method: 'GET',
          path: '/users/abc123',
          headers: { Authorization: like('Bearer token') },
        })
        .willRespondWith({
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: {
            id: string('abc123'),  // Match any string, but document the example
            name: string('Alice Johnson'),
            email: string('alice@example.com'),
            address: {
              line1: string('123 Main St'),
              city: string('San Francisco'),
              zip: string('94105'),
              country: string('US'),
            },
            plan: string('pro'),
            createdAt: string('2026-01-15T10:30:00Z'),
          },
        })
        .executeTest(async (mockServer) => {
          // Run the actual client code against the Pact mock server
          const client = new UsersClient(mockServer.url)
          const user = await client.getUser('abc123', 'Bearer token')

          // Assert on what the Orders service actually uses
          expect(user.id).toBe('abc123')
          expect(user.address.zip).toBeDefined()  // Orders service uses zip for shipping
        })
    })

    it('returns 404 when user does not exist', async () => {
      await provider
        .given('no user with ID xyz999 exists')
        .uponReceiving('a request for non-existent user xyz999')
        .withRequest({ method: 'GET', path: '/users/xyz999' })
        .willRespondWith({
          status: 404,
          body: { error: string('User not found'), code: string('USER_NOT_FOUND') },
        })
        .executeTest(async (mockServer) => {
          const client = new UsersClient(mockServer.url)
          await expect(client.getUser('xyz999', 'Bearer token'))
            .rejects.toThrow('User not found')
        })
    })
  })
})

Running this test:

  1. Validates that the UsersClient code correctly handles both success and 404 cases
  2. Generates a pacts/OrdersService-UsersService.json contract file

The Generated Contract File

The contract file is human-readable JSON that documents the agreement:

{
  "consumer": { "name": "OrdersService" },
  "provider": { "name": "UsersService" },
  "interactions": [
    {
      "description": "a request for user abc123",
      "providerStates": [{ "name": "a user with ID abc123 exists" }],
      "request": {
        "method": "GET",
        "path": "/users/abc123",
        "headers": { "Authorization": "Bearer token" }
      },
      "response": {
        "status": 200,
        "body": {
          "id": "abc123",
          "address": { "line1": "123 Main St", "city": "San Francisco", "zip": "94105" }
        },
        "matchingRules": {
          "body.id": { "matchers": [{ "match": "type" }] }
        }
      }
    }
  ]
}

Setting Up Pact.js: Provider Side

The provider loads the contract and verifies that its actual implementation satisfies every interaction.

// tests/providers/users-service.pact.test.ts
import { Verifier } from '@pact-foundation/pact'
import path from 'path'
import { app } from '../../src/app'  // Your Express/Fastify app
import { db } from '../../src/db'

describe('UsersService provider verification', () => {
  let server: ReturnType<typeof app.listen>

  beforeAll(async () => {
    server = app.listen(3001)
  })

  afterAll(() => server.close())

  it('validates all consumer contracts', async () => {
    await new Verifier({
      providerBaseUrl: 'http://localhost:3001',

      // Load contracts from local filesystem (in monorepo)
      // or from Pact Broker in CI
      pactUrls: [
        path.resolve(process.cwd(), '../orders-service/pacts/OrdersService-UsersService.json'),
      ],

      // Or use Pact Broker:
      // pactBrokerUrl: 'https://your-org.pactflow.io',
      // pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      // consumerVersionSelectors: [{ mainBranch: true }, { deployed: true }],

      // Provider state handlers — set up test data for each provider state
      stateHandlers: {
        'a user with ID abc123 exists': async () => {
          await db.users.upsert({
            id: 'abc123',
            name: 'Alice Johnson',
            email: 'alice@example.com',
            address: { line1: '123 Main St', city: 'San Francisco', zip: '94105', country: 'US' },
            plan: 'pro',
          })
        },
        'no user with ID xyz999 exists': async () => {
          await db.users.delete({ id: 'xyz999' })
        },
      },

      publishVerificationResult: process.env.CI === 'true',
      providerVersion: process.env.GIT_SHA ?? '0.0.0',
    }).verifyProvider()
  })
})

If the Users Service changes address.zip to address.postalCode, the provider verification fails at this test step — before the change can be deployed.


The Pact Broker: Sharing Contracts Between Teams

In a multi-team environment, contracts need to be shared. The Pact Broker (open source) or PactFlow (managed SaaS) acts as a contract registry:

# Consumer CI: publish the contract after tests pass
npx pact-broker publish ./pacts \
  --broker-base-url https://your-org.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN \
  --consumer-app-version $GIT_SHA \
  --branch $BRANCH_NAME

# Provider CI: fetch and verify all consumer contracts
# (uses pactBrokerUrl in the Verifier config above)

can-i-deploy — The Deployment Gate

The critical CI/CD integration is can-i-deploy:

# Before deploying Orders Service to production:
npx pact-broker can-i-deploy \
  --pacticipant OrdersService \
  --version $GIT_SHA \
  --to-environment production \
  --broker-base-url https://your-org.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN

This checks: "Is there a verified contract between OrdersService at this version and all its providers at their production versions?" If any provider hasn't verified the contract, deployment is blocked.


Async Contract Testing: Message Pacts

Pact isn't limited to HTTP APIs. For event-driven architectures (Kafka, RabbitMQ, SNS/SQS), Pact supports message contracts — ensuring that the events a producer publishes match what consumers expect.

Message Consumer Test

import { PactV3, MatchersV3 } from '@pact-foundation/pact'
const { like, string, integer } = MatchersV3

describe('OrdersService → EventBus contract', () => {
  const messagePact = new PactV3({
    consumer: 'ShippingService',
    provider: 'OrdersService',
    dir: path.resolve(process.cwd(), 'pacts'),
  })

  it('handles an order.created event', async () => {
    await messagePact
      .expectsToReceive('an order.created event')
      .withContent({
        eventType: string('order.created'),
        orderId: string('order-abc123'),
        customerId: string('cust-xyz'),
        items: [
          {
            productId: string('prod-001'),
            quantity: integer(2),
            price: like(29.99),
          },
        ],
        shippingAddress: {
          line1: string('123 Main St'),
          city: string('San Francisco'),
          zip: string('94105'),
        },
        createdAt: string('2026-03-09T12:00:00Z'),
      })
      .verify(async (event) => {
        // Test that ShippingService correctly processes the event
        const result = await processOrderCreatedEvent(event)
        expect(result.shipmentQueued).toBe(true)
        expect(result.address.zip).toBe('94105')
      })
  })
})

Message Producer Verification

// On the OrdersService (producer) side
import { MessageProviderPact } from '@pact-foundation/pact'

describe('OrdersService message verification', () => {
  it('verifies order.created event format', async () => {
    const verifier = new MessageProviderPact({
      messageProviders: {
        'an order.created event': async () => {
          // Return an example event that the producer would actually publish
          return {
            eventType: 'order.created',
            orderId: 'order-abc123',
            customerId: 'cust-xyz',
            items: [{ productId: 'prod-001', quantity: 2, price: 29.99 }],
            shippingAddress: {
              line1: '123 Main St',
              city: 'San Francisco',
              zip: '94105',
            },
            createdAt: new Date().toISOString(),
          }
        },
      },
      pactUrls: [path.resolve(process.cwd(), '../shipping-service/pacts/ShippingService-OrdersService.json')],
      providerVersion: process.env.GIT_SHA,
      publishVerificationResult: process.env.CI === 'true',
    })

    await verifier.verify()
  })
})

If the Orders team changes shippingAddress.zip to shippingAddress.postalCode in the Kafka event schema, the Shipping service's message contract test fails before the change can be deployed.


Common Pact Pitfalls and Best Practices

Pitfall 1: Over-Specifying the Contract

The most common mistake is specifying too much in consumer contracts:

// ❌ Bad — over-specified contract
.willRespondWith({
  status: 200,
  body: {
    id: 'abc123',  // Exact value match — will fail if the test ID changes
    name: 'Alice Johnson',  // Exact match — irrelevant to the consumer
    email: 'alice@example.com',
    createdAt: '2026-01-15T10:30:00Z',  // Exact timestamp — always wrong
    lastLoginAt: '2026-03-09T08:00:00Z',  // Field the consumer doesn't use
    internalScore: 847,  // Field the consumer doesn't use
  },
})

// ✅ Good — only specify what the consumer actually uses
.willRespondWith({
  status: 200,
  body: {
    id: string('abc123'),  // Type match — any string is fine
    name: string('Alice Johnson'),  // Consumer displays the name
    address: {
      zip: string('94105'),  // Consumer uses this for shipping calculation
    },
    // Don't include lastLoginAt, internalScore — consumer doesn't use them
  },
})

Only contract the fields your consumer actually reads. Extra fields make contracts brittle and create false failures when the provider adds new fields.

Pitfall 2: Testing Business Logic in Contracts

Pact tests integration, not business logic:

// ❌ Bad — testing business logic in a contract
it('returns upgraded plan after payment', async () => {
  await provider
    .given('user abc123 just paid for pro plan')
    .uponReceiving('GET /users/abc123')
    .willRespondWith({ body: { plan: 'pro' } })  // Testing business logic, not integration
    .executeTest(async (mockServer) => {
      const result = await client.getUser('abc123')
      expect(result.plan).toBe('pro')  // This is a unit test for the payment flow
    })
})

// ✅ Good — testing integration shape
it('returns a user with a plan field', async () => {
  await provider
    .given('a user with ID abc123 exists')
    .uponReceiving('a request for user abc123')
    .willRespondWith({
      body: {
        id: string(),
        plan: string('pro'),  // Just verifying the field exists and is a string
      },
    })
    .executeTest(async (mockServer) => {
      const result = await client.getUser('abc123')
      expect(typeof result.plan).toBe('string')  // Shape, not value
    })
})

Pitfall 3: Skipping Provider States

Provider states are critical for reproducible tests. Without them, the provider test data is unpredictable:

// ❌ Bad — no provider state (what data exists when this test runs?)
.given('')

// ✅ Good — explicit provider state
.given('a user with ID abc123 exists with plan=pro')

Provider states should be:

  • Specific enough for the state handler to set up the right data
  • Reusable across multiple interactions that share setup
  • Idempotent — running them twice shouldn't cause failures

Pact vs. Alternative Approaches

ApproachProsCons
Pact contract testingCatches breaking changes before deployment, no live services neededSetup overhead, needs organizational adoption
API snapshot testingSimple, no Broker neededRequires running server, accepts wrong-but-consistent behavior
E2E integration testsTests real workflowsSlow, expensive, requires all services deployed together
Manual API versioningLow tooling costRelies on discipline; breaking changes still slip through
OpenAPI validationDocuments the schemaDoesn't test that consumers use the documented API correctly

Methodology

  • npm download data from npmjs.com API, March 2026 weekly averages
  • Package versions: @pact-foundation/pact v12.x
  • Sources: Pact Foundation documentation (docs.pact.io), PactFlow blog, "Consumer-Driven Contracts" by Martin Fowler, Thoughtworks Technology Radar

Explore API testing tools on APIScout — compare Pact with other API testing and mocking libraries.

Related: API Mocking: MSW vs Mirage vs WireMock 2026 · API Error Handling Patterns for Production 2026 · API Breaking Changes Without Breaking Clients 2026

Comments