Skip to main content

Pact vs OpenAPI for Frontend-Backend Integration

Frontend and backend teams that ship independently frequently discover a gap that documentation alone cannot close: OpenAPI describes what an API should look like, but it cannot verify that the backend actually behaves the way a specific frontend relies on. This guide is part of Consumer-Driven Contracts with Pact and addresses the common, expensive mistake of treating OpenAPI validation as a substitute for integration testing.

Pact vs OpenAPI responsibility split Diagram showing OpenAPI covering schema shape and documentation while Pact covers consumer-specific interaction verification between a React frontend consumer and a Node.js provider. OpenAPI Specification Schema shape & field types Nullable / required rules Documentation & SDK generation Shared across ALL consumers Static linting (Spectral) Does NOT verify: runtime behaviour or consumer-specific interactions Pact Contracts Consumer-specific interactions Exact request/response matching Nullable field matchers Provider state seeding Live provider verification in CI Does NOT replace: schema governance or cross-consumer documentation

Problem Statement: Treating OpenAPI as Integration Testing

Teams frequently reach for OpenAPI mocks as their integration test boundary. The reasoning is understandable: the spec is already written, Prism can spin up a compliant mock server in seconds, and the feedback loop feels fast. The problem surfaces when the backend ships a valid-per-spec response that still breaks the frontend — because OpenAPI never captured which fields this particular consumer actually reads, which optional fields it treats as present, or which nullable paths through the data it handles.

Integration tests built on OpenAPI mocks will pass even when:

  • A required field changes from string to string | null and the frontend crashes on null
  • The backend omits an optional field the frontend unconditionally dereferences
  • Header casing changes in a way the spec permits but the client library does not tolerate

These failures appear only in production or in E2E tests run against a real environment — at which point the blast radius is large.

Root Cause

The mismatch stems from two fundamentally different validation models.

OpenAPI is a declarative schema: it defines the shape of every possible valid response. type: ["string", "null"] means the field may be either a string or null. The spec says nothing about which consumers care about that field, or whether they handle null safely.

Pact is interaction-driven: it records the exact HTTP exchanges a specific consumer issues and receives, then verifies that the provider can reproduce those exchanges. A Pact file for a React dashboard and a Pact file for a mobile app will each declare only the fields that consumer actually reads — and each will use matchers to express exactly what values are acceptable.

The collision point is nullable optional fields. When a frontend team writes a Pact interaction without explicitly mapping a nullable field to MatchersV3.nullValue() in @pact-foundation/pact v12+, the verifier defaults to strict type matching. If the live backend returns null, the verifier rejects it as a type violation even if the OpenAPI spec explicitly permits null. The consumer interaction and the provider response diverge on a perfectly valid schema.

Common CI failure signatures from this mismatch:

  • TypeError: Cannot read properties of undefined (reading 'id') — frontend mock deserialization crash
  • Mismatch: Expected 'string' but received 'null' — Pact verifier strict type rejection
  • HTTP 400: Missing required interaction match — mock service worker routing failure

Step-by-Step Fix

Step 1: Generate TypeScript Types from the OpenAPI Spec

Anchor the consumer test suite to the OpenAPI spec so that schema changes propagate as compile-time errors rather than runtime surprises. Use openapi-typescript 7 to produce a type-safe surface:

npx openapi-typescript ./openapi.yaml --output ./src/generated/api-types.ts

Import the generated types into your Pact consumer tests. Any field that changes to string | null in the spec will immediately cause a TypeScript error in the test file, alerting you to update the Pact matcher before verification runs.

// src/generated/api-types.ts (excerpt — generated, do not edit)
export interface User {
  id: string;
  email: string;
  secondaryEmail: string | null; // nullable in OpenAPI 3.1
}

Why this works: You get a compile-time link between the OpenAPI schema and your Pact matchers. The build fails early rather than Pact verification failing late.

Step 2: Declare Nullable Matchers in Pact Consumer Tests

Replace bare null assignments and implicit expectations with Pact’s type-aware matchers from @pact-foundation/pact v12:

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

// consumer.pact.test.ts
const userPayload = {
  id: like('usr_123'),               // any string matching the type
  email: like('test@example.com'),   // any valid string
  secondaryEmail: nullValue(),        // explicitly permits null
};

pact
  .addInteraction({
    states: [{ description: 'user exists with no secondary email' }],
    uponReceiving: 'a request for a user profile',
    withRequest: { method: 'GET', path: '/users/usr_123' },
    willRespondWith: {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      body: userPayload,
    },
  });

nullValue() instructs the Pact verifier to accept null for secondaryEmail. Without it, any null received from the live provider triggers a mismatch and fails CI.

Why this works: The Pact file now encodes the same nullable semantics as the OpenAPI spec, eliminating the divergence at the source.

Step 3: Seed Provider State Handlers to Match the Contract

The provider state handler must return null explicitly — not omit the key. A missing key and a null value are different on the wire; Pact treats them differently too.

// provider.pact.test.ts
import { Verifier } from '@pact-foundation/pact';

it('verifies consumer pacts', () => {
  return new Verifier({
    providerBaseUrl: 'http://localhost:8080',
    pactBrokerUrl: process.env.PACT_BROKER_URL!,
    pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
    provider: 'backend-api',
    providerVersion: process.env.GITHUB_SHA,
    publishVerificationResult: true,
    consumerVersionSelectors: [{ mainBranch: true }],
    stateHandlers: {
      'user exists with no secondary email': async () => {
        await db.upsert({
          id: 'usr_123',
          email: 'test@example.com',
          secondaryEmail: null,   // present and null — not absent
        });
      },
    },
  }).verifyProvider();
});

Why this works: Provider state alignment ensures the live backend response matches the exact structure the consumer declared. Omitting the key produces a different JSON payload and a different Pact verdict.

Step 4: Validate Nullable Field Semantics in the OpenAPI Spec

Cross-verify that the OpenAPI definition correctly models the nullable contract. Syntax differs between OpenAPI versions:

# OpenAPI 3.1 (JSON Schema draft 2020-12 — preferred)
components:
  schemas:
    User:
      type: object
      required: [id, email]
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        secondaryEmail:
          type: ["string", "null"]    # type union — OAS 3.1 idiomatic
# OpenAPI 3.0 (if you have not migrated yet)
components:
  schemas:
    User:
      type: object
      required: [id, email]
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        secondaryEmail:
          type: string
          nullable: true              # OAS 3.0 only — deprecated in 3.1

Lint the spec with Spectral 6.11 to enforce your chosen nullable convention consistently:

npx @stoplight/spectral-cli@6.11 lint openapi.yaml --ruleset .spectral.yaml

Why this works: A linted, version-consistent spec is the source of truth that both generated TypeScript types and Pact matchers must reflect.

Before / After Comparison

The critical change is in the consumer test body — from a bare value that produces a strict-match Pact file to a matcher that encodes nullable intent:

// BEFORE — causes verifier failure when backend returns null
const userPayload = {
  id: 'usr_123',
  email: 'test@example.com',
  secondaryEmail: null,   // Pact records the literal null; verifier rejects non-null AND errors on provider returning null for non-nullable type expectation
};

// AFTER — verifier accepts null from the provider
const userPayload = {
  id: like('usr_123'),
  email: like('test@example.com'),
  secondaryEmail: nullValue(),   // matcher; verifier permits null
};

The like() wrappers on id and email also remove the brittle dependency on exact string values, making the contract durable across test data changes.

Role Comparison: Pact vs OpenAPI

Concern OpenAPI Pact
Schema shape (field names, types) Authoritative Derived (via generated types)
Nullable field semantics Declares type: ["string","null"] Enforces with nullValue() matcher
Cross-team documentation Yes — human and machine readable No — consumer-specific snapshot
SDK and mock server generation Yes (openapi-typescript, Prism) No
Consumer-specific interaction verification No Yes — per-consumer Pact files
Live provider verification in CI No Yes — Verifier against running service
Breaking change detection Via diff tooling (openapi-diff) Via pending pacts / can-i-deploy
Governance scope All consumers simultaneously One consumer at a time

The key takeaway: OpenAPI governs the shared schema contract; Pact verifies per-consumer interaction correctness. For frontend-backend integration, you need both layers. For teams choosing between hosted Pact brokers, see the comparison at Pact Broker vs PactFlow for Small Teams.

Verification

After applying the fixes, run the full consumer-provider verification cycle. The output from @pact-foundation/pact v12 should confirm clean matchers and a successful provider verification:

# Consumer side — generate the Pact file
npx jest consumer.pact.test.ts

# Expected output:
# Pact written to: ./pacts/frontend-backend-api.json
# Interactions: 1, Pending: 0

# Provider side — verify against the live service
npx jest provider.pact.test.ts

# Expected output (abbreviated):
# Verifying provider 'backend-api' against consumer 'frontend'
#   Given 'user exists with no secondary email'
#     GET /users/usr_123 ... OK
# 1 interaction, 0 failures

In CI, the can-i-deploy check then uses the published verification result to gate deployments:

npx pact-broker can-i-deploy \
  --pacticipant frontend \
  --version ${GITHUB_SHA} \
  --to-environment production \
  --broker-base-url ${PACT_BROKER_URL}

A passing can-i-deploy means the specific consumer version has been verified against the specific provider version — something an OpenAPI mock alone can never guarantee.

Edge Cases and Caveats

OAS 3.0 vs 3.1 nullable syntax. OpenAPI 3.0 uses nullable: true; OpenAPI 3.1 uses type: ["string","null"]. If you generate TypeScript types with openapi-typescript 7 against an OAS 3.0 spec, the output type may differ from an OAS 3.1 spec for the same semantic intent. Pin your OAS version and ensure your generator flags match. See the OpenAPI Specification Deep Dive for version migration guidance.

Key absence vs null. JSON distinguishes between a key being absent and a key being present with a null value. Pact treats these differently. If the OpenAPI spec marks a field as not required and nullable, decide explicitly whether your consumer handles key absence and update the Pact interaction accordingly — either with nullValue() or by omitting the field from the body entirely and using eachLike() patterns on array fields.

Bidirectional contract testing. PactFlow’s bidirectional feature allows you to upload the OpenAPI spec as the provider contract and a Pact file as the consumer contract and compare them server-side without running provider state handlers. This reduces the setup burden significantly for teams where maintaining state handlers is expensive, but it requires PactFlow’s hosted broker rather than the open-source Pact Broker.

Frequently Asked Questions

Can OpenAPI replace Pact for frontend-backend contract testing?

No. OpenAPI validates structure and schema conformance; it cannot verify that the backend honours the exact interactions a specific consumer depends on. Pact captures consumer-specific expectations and verifies them against the live provider — use both tools together for full coverage.

What happens if a nullable field in OpenAPI has no matching nullValue() matcher in Pact?

The Pact verifier treats the null response as a type mismatch and fails verification. Add MatchersV3.nullValue() to every field that is nullable in the OpenAPI spec to keep both contracts in sync.

Should the Pact contract or the OpenAPI spec be the source of truth?

OpenAPI is the canonical schema definition shared across teams and tools. Pact contracts are consumer-specific interaction snapshots layered on top. Schema changes flow from OpenAPI downward; the Pact interactions must then be updated to reflect those changes.

How do I keep OpenAPI and Pact in sync automatically?

Generate TypeScript request/response types from the OpenAPI spec with openapi-typescript 7, then import those types into Pact consumer tests. Type errors at compile time catch schema drift before Pact verification runs.

Is bidirectional contract testing a better option?

Bidirectional testing (supported in PactFlow) lets you upload an OpenAPI spec as the provider contract and a Pact file as the consumer contract and compare them automatically. It reduces the need to write provider state handlers but requires PactFlow’s hosted broker.