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 versionStep 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.yamlThis 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-pactopenapi-to-pact \
--openapi api/openapi.yaml \
--output ./pacts/generated/This creates Pact files covering all documented endpoints, which you can then:
- Use as provider self-verification tests
- Share with consumers as starting points for their contract tests
- 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_TOKENWhen 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.