Skip to main content
Back to Blog
Tutorial
2026-02-22

API Contract Testing -- Pact, OpenAPI, and Microservices

Complete guide to API contract testing for microservices. Covers consumer-driven contracts with Pact, OpenAPI validation, Pact Broker, and CI/CD integration.

API contract testing is the practice of verifying that two services -- a consumer and a provider -- can communicate correctly by testing against a shared agreement rather than testing the services together. In microservices architectures where dozens or hundreds of services interact over HTTP and messaging, contract testing is the most efficient way to catch integration failures before they reach production. This guide covers everything you need to implement contract testing with Pact and OpenAPI, from writing your first consumer test to building a fully automated CI/CD pipeline that prevents breaking changes from ever being deployed.

Key Takeaways

  • Contract testing verifies that services can communicate correctly without requiring both services to be running at the same time
  • Consumer-driven contracts (Pact) let the consumer define what it needs, and the provider verifies it can fulfill those needs
  • Pact Broker provides versioning, deployment safety checks via can-i-deploy, and webhook-triggered verification pipelines
  • OpenAPI schema validation with tools like Spectral and Prism complements contract testing by catching structural and documentation issues
  • Contract testing runs in milliseconds compared to minutes for integration tests, making it ideal for shift-left CI/CD pipelines
  • Anti-patterns like testing too much detail or skipping provider states lead to brittle contracts that break on every provider change

What Is Contract Testing?

Contract testing is a technique for verifying that two services can communicate with each other by testing each service independently against a shared contract. The contract defines the expected interactions between the consumer (the service making requests) and the provider (the service handling requests). Instead of deploying both services together and running integration tests, you test each side in isolation using the contract as the source of truth.

The problem contract testing solves is straightforward: microservices integration failures are discovered too late. In a typical workflow, a team changes a provider API, runs its own unit tests (which pass), and deploys. Hours or days later, a consumer team discovers their service is broken because a field was renamed, a response structure changed, or a status code shifted. The cost of this discovery scales with how far the change has propagated.

Traditional integration testing catches these issues but comes with severe drawbacks. You need both services running simultaneously, often with databases, message queues, and other infrastructure. These tests are slow, flaky, and expensive to maintain. End-to-end testing is even worse -- you need the entire system running, and a single broken service blocks all testing.

Contract testing sits between unit tests and integration tests on the testing spectrum. It provides the confidence of integration testing with the speed and reliability of unit tests. Each service tests independently against the contract, so you never need to spin up another service. Tests run in milliseconds, not minutes. And when a test fails, you know exactly which interaction is broken and which service is responsible.

The "contract" itself is a formal agreement. In Pact, it is a JSON file that describes specific HTTP interactions -- request method, path, headers, query parameters, and the expected response. In OpenAPI, it is a specification document that describes all available endpoints, their parameters, and response schemas. Both approaches have strengths, and many teams use them together.


Consumer-Driven Contracts Explained

The consumer-driven contract (CDC) model, popularized by Pact, flips the traditional API design process. Instead of the provider defining the API and consumers adapting to it, the consumer declares what it needs and the provider verifies it can deliver.

Here is how the flow works:

Consumer Test Suite
       |
       | 1. Define expected interactions
       |    (method, path, request body, expected response)
       v
  Pact Mock Provider
       |
       | 2. Consumer runs tests against mock
       |    Mock validates requests match expectations
       |    Mock returns defined responses
       v
   Pact File (JSON)
       |
       | 3. Publish to Pact Broker
       v
  Pact Broker
       |
       | 4. Provider fetches pact
       v
  Provider Verification
       |
       | 5. Provider replays interactions against real API
       |    Verifies responses match contract
       v
   Verification Result
       |
       | 6. Published back to Pact Broker
       v
  can-i-deploy check

Step 1 -- Consumer defines expectations. The consumer team writes tests that describe the HTTP interactions they depend on. For example: "When I send a GET request to /api/users/42, I expect a 200 response with a JSON body containing id, name, and email fields."

Step 2 -- Consumer tests run against a mock. Pact spins up a mock HTTP server that behaves according to the defined interactions. The consumer's actual HTTP client code runs against this mock, ensuring the consumer is correctly handling the expected responses.

Step 3 -- Pact file is generated. After the consumer tests pass, Pact generates a JSON contract file (the "pact") that captures all defined interactions. This file is published to the Pact Broker.

Step 4 -- Provider fetches and verifies. The provider team pulls the pact file and replays every interaction against their real running API. If the provider returns responses that match the contract, verification passes.

Step 5 -- Results are published. Verification results go back to the Pact Broker, creating an audit trail of which provider version satisfies which consumer contracts.

Step 6 -- Deployment safety. The can-i-deploy tool queries the Pact Broker to determine whether a specific version of a service can be safely deployed to a given environment based on the latest verification results.

This model is powerful because it decouples deployment schedules. The consumer and provider teams work independently, and the contract is the coordination mechanism. If a provider change would break a consumer, the provider's verification step catches it before deployment.


Writing Consumer Tests with Pact

Here is a practical example of writing a consumer test with Pact JS v12+. Suppose you have an order service (consumer) that fetches user details from a user service (provider).

import { PactV4, MatchersV3 } from '@pact-foundation/pact';

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

const provider = new PactV4({
  consumer: 'OrderService',
  provider: 'UserService',
  logLevel: 'warn',
});

describe('User API Contract', () => {
  it('returns user details by ID', async () => {
    // Define the expected interaction
    await provider
      .addInteraction()
      .given('a user with ID 42 exists')
      .uponReceiving('a request to get user 42')
      .withRequest('GET', '/api/users/42', (builder) => {
        builder.headers({ Accept: 'application/json' });
      })
      .willRespondWith(200, (builder) => {
        builder
          .headers({ 'Content-Type': 'application/json' })
          .jsonBody({
            id: integer(42),
            name: string('Jane Doe'),
            email: regex(
              'jane@example.com',
              '^[\\w.+-]+@[\\w-]+\\.[\\w.]+$'
            ),
            role: like('admin'),
            createdAt: like('2026-01-15T10:30:00Z'),
          });
      })
      .executeTest(async (mockServer) => {
        // Use your real HTTP client against the mock
        const response = await fetch(
          `${mockServer.url}/api/users/42`,
          { headers: { Accept: 'application/json' } }
        );

        const user = await response.json();

        // Assert on the consumer side
        expect(response.status).toBe(200);
        expect(user.id).toBe(42);
        expect(user.name).toBeDefined();
        expect(user.email).toContain('@');
      });
  });

  it('returns 404 for a non-existent user', async () => {
    await provider
      .addInteraction()
      .given('no user with ID 999 exists')
      .uponReceiving('a request to get non-existent user')
      .withRequest('GET', '/api/users/999')
      .willRespondWith(404, (builder) => {
        builder.jsonBody({
          error: string('User not found'),
          statusCode: integer(404),
        });
      })
      .executeTest(async (mockServer) => {
        const response = await fetch(
          `${mockServer.url}/api/users/999`
        );
        expect(response.status).toBe(404);

        const body = await response.json();
        expect(body.error).toBe('User not found');
      });
  });
});

Key points about this consumer test:

  • given() sets the provider state. This tells the provider what test data to set up before replaying the interaction. The string 'a user with ID 42 exists' must match a state handler on the provider side.
  • Matchers, not exact values. Using integer(), string(), like(), and regex() means the contract is flexible. The provider does not need to return the exact value 'Jane Doe' -- it just needs to return a string in the name field. This prevents brittle contracts.
  • Real HTTP client code. The test uses the same fetch call your production code uses. This ensures the consumer's actual parsing and error handling logic is exercised.
  • The pact file is generated automatically. After the test runs, Pact writes a JSON contract to the pacts/ directory. This file contains every interaction defined in the test suite.

Provider Verification

On the provider side, verification is the process of replaying every interaction from the pact file against your real running API and checking that the responses match the contract.

import { Verifier } from '@pact-foundation/pact';
import { app } from '../src/app'; // Your Express/Fastify app

describe('User Service Provider Verification', () => {
  let server: any;

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

  afterAll(async () => {
    server.close();
  });

  it('validates the contract with OrderService', async () => {
    const verifier = new Verifier({
      providerBaseUrl: 'http://localhost:8080',
      provider: 'UserService',

      // Fetch pacts from the Pact Broker
      pactBrokerUrl: 'https://your-broker.pactflow.io',
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,

      // Only verify pacts for consumers deployed to production
      consumerVersionSelectors: [
        { deployedOrReleased: true },
        { mainBranch: true },
      ],

      // Enable pending pacts (new consumers do not break provider)
      enablePending: true,

      // Provider states setup
      stateHandlers: {
        'a user with ID 42 exists': async () => {
          // Seed the database with test data
          await db.users.create({
            id: 42,
            name: 'Test User',
            email: 'test@example.com',
            role: 'admin',
            createdAt: new Date('2026-01-15T10:30:00Z'),
          });
        },
        'no user with ID 999 exists': async () => {
          // Ensure no user with this ID exists
          await db.users.deleteWhere({ id: 999 });
        },
      },

      // Publish verification results
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT_SHA,
      providerVersionBranch: process.env.GIT_BRANCH,
    });

    await verifier.verifyProvider();
  });
});

Provider states are the most important concept in provider verification. Each given() clause in the consumer test must have a corresponding state handler on the provider. The state handler sets up the necessary test data so the provider can return the expected response. Without proper provider states, the provider might return a 404 when the consumer expects a 200, causing a false verification failure.

Pending pacts are a critical feature for teams adopting contract testing incrementally. When a new consumer writes a pact for the first time, the provider has not yet implemented the verification. With pending pacts enabled, these new contracts are verified but failures do not break the provider build. Once the provider passes verification for the first time, the pact is no longer pending and subsequent failures will break the build.

WIP (Work in Progress) pacts extend this concept further. Pacts from feature branches are automatically treated as WIP, so experimental consumer changes do not break the provider's main branch.

The can-i-deploy workflow ties everything together:

# Before deploying UserService to production, check safety
pact-broker can-i-deploy \
  --pacticipant UserService \
  --version $(git rev-parse HEAD) \
  --to-environment production

# Output:
# Computer says yes \o/
# All required verification results are published and successful

This command checks whether the current version of UserService has been successfully verified against all consumers currently deployed to production. If any contract verification is missing or failed, the deployment is blocked.


The Pact Broker

The Pact Broker is the central hub for managing contracts across your microservices ecosystem. You can self-host it or use the managed PactFlow service. It provides:

Contract storage and versioning. Every pact file is stored with its consumer version (typically a git SHA). You can see the history of contracts between any two services, track how interactions have evolved, and identify when breaking changes were introduced.

The can-i-deploy tool. This is arguably the most valuable feature. Before deploying any service, can-i-deploy checks whether the version you are about to deploy has been verified against all the services it interacts with in the target environment. This is a binary pass/fail check that integrates directly into your deployment pipeline.

Environment tracking. The Pact Broker tracks which version of each service is currently deployed to each environment (dev, staging, production). This enables can-i-deploy to check against the exact versions that are running, not just the latest versions.

Webhooks. When a consumer publishes a new pact, a webhook can trigger the provider's CI pipeline to verify it. This creates a tight feedback loop: a consumer pushes a change, the contract is published, the provider is automatically verified, and the result is available within minutes.

Network diagram. The Pact Broker generates a visual dependency graph of all your services and their contract relationships. This is invaluable for understanding the topology of a large microservices system and identifying which services are most coupled.

Here is a typical Pact Broker workflow in a CI pipeline:

# Consumer CI: publish pact after tests pass
pact-broker publish ./pacts \
  --consumer-app-version $(git rev-parse HEAD) \
  --branch $(git branch --show-current) \
  --broker-base-url https://your-broker.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN

# Provider CI: verify pacts (triggered by webhook or on every build)
# (runs the Verifier code shown in the previous section)

# Deployment gate: check before deploying
pact-broker can-i-deploy \
  --pacticipant OrderService \
  --version $(git rev-parse HEAD) \
  --to-environment production

# After successful deployment: record the deployment
pact-broker record-deployment \
  --pacticipant OrderService \
  --version $(git rev-parse HEAD) \
  --environment production

The record-deployment step is critical. It tells the Pact Broker which version is now running in production, so future can-i-deploy checks from other services use the correct version.


OpenAPI Schema Validation

While Pact focuses on consumer-driven contracts (testing specific interactions), OpenAPI schema validation takes a provider-driven approach. An OpenAPI specification describes the complete API surface -- every endpoint, parameter, request body, and response schema. Validating against this specification catches a different class of issues.

Spectral is the leading tool for OpenAPI linting. It validates your specification against a set of rules, catching issues like missing descriptions, inconsistent naming conventions, and undocumented error responses.

# .spectral.yml
extends:
  - spectral:oas
rules:
  operation-operationId:
    severity: error
    description: Every operation must have an operationId
  operation-description:
    severity: warn
    description: Every operation should have a description
  oas3-api-servers:
    severity: error
    description: API must define at least one server
  response-must-have-error-schema:
    severity: warn
    given: "$.paths.*.*.responses[?(@property >= 400)]"
    then:
      field: content
      function: truthy
    description: Error responses should have a schema

Run Spectral in CI to catch specification issues before they affect consumers:

npx @stoplight/spectral-cli lint openapi.yaml --fail-severity warn

Prism is a mock server and validation proxy that uses your OpenAPI spec. As a mock server, it returns realistic example responses based on the spec, allowing consumers to test against the API before the provider has even implemented it. As a validation proxy, it sits between the consumer and the real provider, flagging any requests or responses that do not conform to the spec.

# Run Prism as a mock server
npx @stoplight/prism-cli mock openapi.yaml --port 4010

# Run Prism as a validation proxy
npx @stoplight/prism-cli proxy openapi.yaml http://localhost:8080 --port 4010

openapi-diff detects breaking changes between two versions of an OpenAPI specification. Integrate it into your PR pipeline to catch breaking changes before they are merged:

npx openapi-diff openapi-main.yaml openapi-branch.yaml

How OpenAPI validation complements Pact: Pact tests specific interactions that consumers actually use. OpenAPI validation tests the entire API surface against a specification. Use Pact to ensure consumers are not broken by provider changes. Use OpenAPI validation to ensure the specification is complete, consistent, and free of breaking changes. Together, they provide comprehensive coverage: Pact catches interaction-level regressions, and OpenAPI catches specification-level issues.


CI/CD Integration

Contract testing delivers maximum value when it is fully integrated into your CI/CD pipeline. The goal is a workflow where contract verification happens automatically and deployment is gated on verification results.

Here is a GitHub Actions workflow for the consumer side:

# .github/workflows/consumer-contract-tests.yml
name: Consumer Contract Tests

on:
  push:
    branches: [main, 'feature/**']
  pull_request:
    branches: [main]

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

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Run consumer contract tests
        run: npm run test:contract
        env:
          CI: true

      - name: Publish pacts to broker
        if: github.ref == 'refs/heads/main' || github.event_name == 'push'
        run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version ${{ github.sha }} \
            --branch ${{ github.ref_name }} \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --broker-token ${{ secrets.PACT_BROKER_TOKEN }}

      - name: Can I deploy?
        if: github.ref == 'refs/heads/main'
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant OrderService \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --broker-token ${{ secrets.PACT_BROKER_TOKEN }}

And for the provider side:

# .github/workflows/provider-contract-verification.yml
name: Provider Contract Verification

on:
  push:
    branches: [main]
  repository_dispatch:
    types: [pact-changed]

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

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Start provider service
        run: npm run start:test &
        env:
          DATABASE_URL: postgresql://localhost:5432/test

      - name: Wait for service
        run: npx wait-on http://localhost:8080/health --timeout 30000

      - name: Verify provider contracts
        run: npm run test:provider-contracts
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          GIT_COMMIT_SHA: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}

      - name: Can I deploy?
        if: github.ref == 'refs/heads/main'
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant UserService \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
            --broker-token ${{ secrets.PACT_BROKER_TOKEN }}

The repository_dispatch event is key. When a consumer publishes a new pact, the Pact Broker webhook sends a repository_dispatch event to the provider repository, triggering immediate verification. This creates a feedback loop: consumer publishes pact, provider verifies within minutes, and the can-i-deploy result is available before either service attempts to deploy.

For a broader guide on structuring your CI/CD testing pipeline, including unit tests, integration tests, and E2E tests alongside contract testing, see our CI/CD testing pipeline with GitHub Actions guide.


Contract Testing Anti-Patterns

Contract testing is straightforward in concept but easy to misuse. Here are the most common anti-patterns and how to avoid them.

Testing too much detail. The contract should describe the shape of the interaction, not every exact value. If your consumer test asserts that the response body contains "name": "Jane Doe" as an exact match instead of using a type matcher like string(), every minor data change on the provider breaks the contract. Use matchers liberally: like(), eachLike(), integer(), string(), regex().

Coupling consumer and provider repositories. Some teams put consumer and provider code in the same repository and run contract tests as part of a monorepo build. This defeats the purpose. Contract testing is specifically designed for independently deployable services. If you can run integration tests directly, you do not need contracts. Use the Pact Broker to decouple the teams.

Skipping provider states. If you do not set up provider states, the provider verification runs against whatever data happens to be in the test database. This leads to flaky verifications -- sometimes the data exists, sometimes it does not. Every given() clause in the consumer must have a corresponding state handler on the provider.

Testing implementation details. Do not include internal headers, debug fields, or implementation-specific response structures in your contracts. If the consumer does not actually use a field, do not include it in the contract. This minimizes coupling between services.

Ignoring pending pacts. When a new consumer creates its first contract, the provider has not verified it yet. Without pending pacts enabled, this immediately breaks the provider build. Always enable pending pacts so new consumers can onboard without blocking existing deployments.

Using contract testing as integration testing. Contract testing verifies that the interface between two services is compatible. It does not test business logic, data transformations, or complex workflows that span multiple services. You still need integration tests for those scenarios -- contract testing reduces the number of integration tests you need, it does not eliminate them.


Contract Testing vs Integration Testing

Understanding when to use contract testing versus integration testing is essential for building an efficient test strategy.

AspectContract TestingIntegration Testing
SpeedMilliseconds -- each side tested independentlySeconds to minutes -- requires running services
ReliabilityDeterministic -- no network, no shared stateOften flaky -- network timeouts, data conflicts
ScopeInterface compatibility between two servicesEnd-to-end behavior across multiple services
EnvironmentNo external dependencies neededRequires databases, queues, and other services
MaintenanceLow -- contracts evolve with consumer needsHigh -- test data, environments, and timing issues
Failure isolationPinpoints exactly which interaction is brokenFailures can cascade across multiple services
CI/CD fitRuns on every commit in secondsOften gated to nightly or pre-deploy stages
What it catchesBreaking interface changesBusiness logic errors across service boundaries
What it missesMulti-service workflow bugsNothing (if you have enough tests and patience)

The ideal strategy uses both. Contract tests run on every commit to catch interface-level regressions quickly. Integration tests run on merges to main or pre-deployment to catch workflow-level bugs that contract tests cannot. This gives you fast feedback on every push and comprehensive validation before deployment.


Automate Contract Testing with AI Agents

Writing and maintaining contract tests manually is time-consuming, especially as your microservices ecosystem grows. AI coding agents equipped with specialized QA skills can generate consumer tests, provider verification setups, and CI/CD pipeline configurations from your existing API code and OpenAPI specifications.

Install contract testing skills to give your AI agent expert knowledge:

# Consumer-driven contract testing with Pact
npx @qaskills/cli add contract-testing-pact

# API contract validation patterns
npx @qaskills/cli add api-contract-validator

Additional skills for comprehensive API testing:

# Generate contract tests from existing code
npx @qaskills/cli add contract-test-generator

# REST API testing fundamentals
npx @qaskills/cli add api-testing-rest

# Generate complete API test suites from specs
npx @qaskills/cli add api-test-suite-generator

With these skills installed, your AI agent can analyze your API endpoints, generate consumer tests with proper matchers, create provider verification setups with state handlers, and configure the Pact Broker integration in your CI pipeline. The agent understands the nuances of contract testing -- when to use type matchers versus exact values, how to structure provider states, and how to avoid the anti-patterns described earlier in this guide.

Browse all available QA skills at qaskills.sh/skills or read the getting started guide to set up your first skill in under a minute.

For related guides, see our complete API testing guide covering REST and GraphQL testing, our CI/CD testing pipeline guide for pipeline architecture, and our shift-left testing guide for moving contract testing earlier in your development workflow.


Frequently Asked Questions

When should I use contract testing instead of integration testing?

Use contract testing when you have independently deployable services that communicate over well-defined interfaces (HTTP, messaging). Contract testing is especially valuable when different teams own different services and you need to catch breaking changes without running expensive integration environments. If your services are in a monorepo and always deployed together, integration tests may be simpler and sufficient. The tipping point is usually around 5-10 microservices -- below that, integration tests are manageable; above that, the combinatorial explosion of service versions makes contract testing essential.

Should I use Pact, OpenAPI validation, or both?

They solve different problems and complement each other well. Pact tests specific interactions that consumers actually depend on, catching regressions in the exact fields and behaviors that matter. OpenAPI validation tests the entire API surface against a specification, catching documentation drift, missing error schemas, and breaking changes in endpoints that might not have Pact coverage yet. Use Pact for critical consumer-provider relationships where breaking changes have high impact. Use OpenAPI validation for specification hygiene and breaking change detection across the entire API. Most mature teams use both: Pact for interaction-level safety and OpenAPI for specification-level governance.

How much maintenance overhead does contract testing add?

The maintenance burden is significantly lower than integration tests. Consumer tests only need updating when the consumer's actual usage of the API changes -- not when the provider adds new fields or endpoints. Provider state handlers need maintenance when the data model changes, but this is typically straightforward. The Pact Broker handles versioning and environment tracking automatically. The biggest maintenance cost is initial setup: configuring the broker, writing state handlers, and integrating with CI/CD. Once that infrastructure is in place, ongoing maintenance is minimal. Teams typically report that the time saved by avoiding broken deployments and reducing integration test suites far exceeds the contract testing maintenance cost.

How does contract testing fit alongside E2E tests?

Contract testing does not replace E2E testing -- it reduces how much E2E testing you need. E2E tests validate complete user workflows across the entire system: login, browse products, add to cart, checkout. Contract tests validate that individual service-to-service interfaces are compatible. By catching interface-level regressions with fast contract tests, you free your E2E suite to focus on critical user journeys rather than trying to cover every possible service interaction. A practical split: use contract tests for every service boundary (fast, comprehensive, runs on every commit) and reserve E2E tests for the 10-20 most important user workflows (slower, higher-level, runs before deployment). This gives you both speed and confidence.

API Contract Testing -- Pact, OpenAPI, and Microservices | QASkills.sh