Combining Pact and OpenAPI for Bulletproof Contract Testing

Combining Pact and OpenAPI for Bulletproof Contract Testing

Pact and OpenAPI both prevent API integration failures, but they solve different parts of the problem. OpenAPI defines the contract — the shape of requests and responses. Pact verifies that specific consumer interactions are honored by the provider. Used together, they cover each other's blind spots.

This guide shows how to use both tools in a complementary workflow.

Pact vs OpenAPI: What Each Covers

OpenAPI covers:

  • Full API surface area (all endpoints, all parameters)
  • Schema validation (types, required fields, formats)
  • Breaking change detection between versions
  • Documentation for any consumer (internal or external)

Pact covers:

  • Specific consumer scenarios (what this consumer actually calls)
  • Consumer-driven verification (the consumer defines what it needs)
  • Behavioral contracts (not just schema, but actual response values)
  • Integration testing without running both services simultaneously

Where each falls short:

  • OpenAPI doesn't verify that your implementation actually matches the spec
  • Pact doesn't cover the full API surface — only what consumers explicitly test
  • OpenAPI tests can pass while the implementation returns wrong values
  • Pact tests are slow to set up and require consumer participation

Using both covers these gaps.

Architecture Overview

Consumer                    Provider
─────────────────────────────────────────────
Tests define interactions → Pact generates
Pact files published to → Pact Broker
                           ↓
OpenAPI spec generated/maintained
                           ↓
Provider verification runs:
  1. Pact: replays consumer interactions
  2. OpenAPI: validates spec compliance
  3. oasdiff: checks for breaking changes vs previous version

Step 1: Consumer-Side Pact Tests

// consumer/src/users-client.test.ts
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
import { UsersClient } from "./users-client";
import path from "path";

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

const provider = new PactV3({
  consumer: "frontend-app",
  provider: "users-api",
  dir: path.resolve(process.cwd(), "pacts"),
});

describe("Users API Contract", () => {
  test("GET /users returns a list of users", async () => {
    await provider.addInteraction({
      states: [{ description: "users exist" }],
      uponReceiving: "a request for all users",
      withRequest: {
        method: "GET",
        path: "/users",
        headers: { Accept: "application/json" },
      },
      willRespondWith: {
        status: 200,
        headers: { "Content-Type": "application/json" },
        body: eachLike({
          id: string("user-123"),
          name: string("Alice"),
          email: string("alice@example.com"),
          createdAt: string("2026-01-01T00:00:00Z"),
        }),
      },
    });

    await provider.executeTest(async (mockServer) => {
      const client = new UsersClient(mockServer.url);
      const users = await client.listUsers();
      
      expect(users).toHaveLength(1);
      expect(users[0]).toHaveProperty("id");
      expect(users[0]).toHaveProperty("email");
    });
  });

  test("POST /users creates a user and returns 201", async () => {
    await provider.addInteraction({
      states: [{ description: "user with email does not exist" }],
      uponReceiving: "a request to create a user",
      withRequest: {
        method: "POST",
        path: "/users",
        headers: { "Content-Type": "application/json" },
        body: {
          name: "Bob",
          email: "bob@example.com",
        },
      },
      willRespondWith: {
        status: 201,
        headers: { "Content-Type": "application/json" },
        body: {
          id: string("user-456"),
          name: "Bob",
          email: "bob@example.com",
          createdAt: string("2026-01-01T00:00:00Z"),
        },
      },
    });

    await provider.executeTest(async (mockServer) => {
      const client = new UsersClient(mockServer.url);
      const user = await client.createUser({ name: "Bob", email: "bob@example.com" });
      
      expect(user.id).toBeDefined();
      expect(user.email).toBe("bob@example.com");
    });
  });
});

Step 2: Publish Pacts to Broker

# .github/workflows/consumer.yml
- name: Run consumer tests and publish pacts
  run: |
    npm test
    npx pact-broker publish ./pacts \
      --broker-base-url ${{ secrets.PACT_BROKER_URL }} \
      --broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
      --consumer-app-version ${{ github.sha }} \
      --branch ${{ github.ref_name }}

Step 3: Provider Verification

// provider/src/pact-verification.test.ts
import { Verifier } from "@pact-foundation/pact";
import path from "path";

describe("Pact Verification", () => {
  test("verifies all consumer contracts", async () => {
    const verifier = new Verifier({
      providerBaseUrl: "http://localhost:3000",
      provider: "users-api",
      providerVersion: process.env.GIT_SHA,
      providerVersionBranch: process.env.GIT_BRANCH,
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      publishVerificationResult: true,
      stateHandlers: {
        "users exist": async () => {
          // Seed test database with users
          await seedDatabase();
        },
        "user with email does not exist": async () => {
          // Ensure clean state
          await clearUsers();
        },
      },
    });

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

Step 4: Cross-Validate Pacts Against OpenAPI Spec

This is the key integration step — verify that your Pact interactions are consistent with your OpenAPI spec:

npm install -g pact-openapi-validator
# Validate pact file against OpenAPI spec
pact-openapi-validator \
  --pact ./pacts/frontend-app-users-api.json \
  --openapi ./api/openapi.yaml

This catches cases where:

  • A Pact interaction uses a field that doesn't exist in the OpenAPI schema
  • A Pact interaction expects a type that doesn't match the spec
  • A Pact covers an endpoint that was removed from the OpenAPI spec

Alternatively, use openapi-pact-compatibility-checker:

// scripts/validate-pact-openapi-compatibility.js
const { OpenAPIValidator } = require("express-openapi-validator");
const pact = require("./pacts/frontend-app-users-api.json");
const yaml = require("js-yaml");
const fs = require("fs");

const spec = yaml.load(fs.readFileSync("api/openapi.yaml", "utf8"));
const Ajv = require("ajv");
const ajv = new Ajv({ allErrors: true });

let valid = true;

for (const interaction of pact.interactions) {
  const { request, response } = interaction;
  const pathKey = request.path;
  const method = request.method.toLowerCase();
  
  // Check path exists in spec
  if (!spec.paths[pathKey]) {
    console.error(`Pact uses path ${pathKey} which is not in OpenAPI spec`);
    valid = false;
    continue;
  }
  
  const operation = spec.paths[pathKey][method];
  if (!operation) {
    console.error(`Pact uses method ${method} on ${pathKey} which is not in spec`);
    valid = false;
    continue;
  }
  
  // Validate response body against spec schema
  const statusCode = String(response.status);
  const responseSchema = operation.responses?.[statusCode]
    ?.content?.["application/json"]?.schema;
  
  if (responseSchema && response.body) {
    const validate = ajv.compile(responseSchema);
    // Note: Pact response bodies may contain matchers, not raw values
    // This is a simplified check
    console.log(`✓ ${method.toUpperCase()} ${pathKey} ${statusCode} — spec covers this interaction`);
  }
}

if (!valid) {
  process.exit(1);
}

console.log("All Pact interactions are consistent with OpenAPI spec");

Step 5: Generate Pact Tests from OpenAPI

For providers without consumers (public APIs, new APIs), generate skeleton Pact tests from your OpenAPI spec:

npm install -g @pactflow/openapi-to-pact
openapi-to-pact \
  --openapi api/openapi.yaml \
  --output ./pacts/generated/

This creates Pact files covering all documented endpoints, which you can then:

  1. Use as provider self-verification tests
  2. Share with consumers as starting points for their contract tests
  3. Run against your implementation to verify spec compliance

CI/CD Integration

# Full pipeline combining Pact + OpenAPI
name: Contract Tests

jobs:
  consumer-tests:
    steps:
      - name: Run Pact consumer tests
        run: npm test
      
      - name: Publish Pacts
        run: |
          npx pact-broker publish ./pacts \
            --broker-base-url $PACT_BROKER_URL \
            --broker-token $PACT_BROKER_TOKEN \
            --consumer-app-version $GITHUB_SHA

  openapi-validation:
    steps:
      - name: Lint OpenAPI spec
        run: spectral lint api/openapi.yaml
      
      - name: Check for breaking changes
        run: |
          git show origin/main:api/openapi.yaml > /tmp/old.yaml
          oasdiff breaking /tmp/old.yaml api/openapi.yaml --fail-on ERR
      
      - name: Validate Pacts against OpenAPI
        run: node scripts/validate-pact-openapi-compatibility.js

  provider-verification:
    needs: [consumer-tests, openapi-validation]
    steps:
      - name: Start provider
        run: npm start &
      
      - name: Verify Pact contracts
        run: npm run test:pact
      
      - name: Can I deploy check
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant users-api \
            --version $GITHUB_SHA \
            --to-environment production \
            --broker-base-url $PACT_BROKER_URL \
            --broker-token $PACT_BROKER_TOKEN

When to Use Each Approach

Use Pact when:

  • You have multiple consumers with different needs
  • You want consumer teams to drive the contract
  • You need to verify specific interaction scenarios
  • You're managing microservices with multiple teams

Use OpenAPI when:

  • You have external/public consumers
  • You want to document the full API surface
  • You need breaking change detection in CI
  • You're generating client SDKs or mock servers

Use both when:

  • You have internal and external consumers
  • You want to prove spec-implementation alignment
  • You're building high-reliability APIs where contract drift is a critical risk

The combination of Pact and OpenAPI gives you coverage neither provides alone: OpenAPI proves the spec is correct and unbroken, Pact proves the implementation honors specific consumer needs. Together they form a contract testing strategy that scales from a single team to hundreds of consumers.

Read more

Start now free