Contract Testing with Pact for CMS API Stability

Pact catches a broken CMS API contract at CI time, before a malformed payload reaches React Query, SWR, or Apollo Client. Frontends consuming headless endpoints fail silently or crash when an upstream schema change bypasses client-side validation — and with ISR or stale-while-revalidate caching, the broken response gets cached across edge nodes before anyone notices. Contract testing shifts that validation left with consumer-driven guarantees.

Root-Cause Analysis of CMS Contract Drift

CMS platforms ship on their own release cycles, and provider teams deploy changes they consider non-breaking: a new optional field, a relaxed nullable constraint, a renamed GraphQL union discriminator, a different pagination cursor format. The frontend, meanwhile, depends on strict TypeScript interfaces and rigid destructuring. An unexpected null, a missing field, or a date that flips from ISO 8601 to Unix epoch throws an unhandled type error in the hydration layer.

Mock-based integration tests make this worse: a hardcoded fixture passes CI while the live endpoint returns something incompatible. Without a shared, versioned contract, builds go green locally and deployments fail in staging. Static mocks can’t see provider-side drift — which is exactly why Automated Testing for Headless Integrations has to assert against real contracts.

Step-by-Step Implementation: Consumer-Driven Pact Setup

Pact separates consumer expectations from provider verification: the frontend declares the contract, and the CMS must satisfy it before either side deploys.

The consumer-driven handshake across the broker:

sequenceDiagram
  participant C as Frontend (consumer)
  participant M as Pact mock server
  participant B as Pact Broker
  participant P as CMS API (provider)
  C->>M: Run consumer test with matchers
  M-->>C: Verify expected request/response
  C->>B: Publish pact (version + tag)
  B->>P: Replay recorded requests
  P-->>B: Responses verified against matchers
  Note over B: can-i-deploy gate
  B-->>C: Both sides compatible, deploy allowed

1. Install Core Dependencies

Install Pact V3 alongside your test runner:

Bash
npm install --save-dev @pact-foundation/pact jest ts-jest @types/jest

2. Define Consumer Expectations with Matchers

Declare the expected request/response shape without mocking the whole CMS. Matchers assert structure while allowing dynamic values (IDs, timestamps, slugs). A consumer test for a REST article endpoint:

TypeScript
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { fetchArticle } from './cms-client';

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

const provider = new PactV3({
  consumer: 'NextJS_Frontend',
  provider: 'HeadlessCMS_API',
  dir: process.cwd() + '/pacts',
  log: process.cwd() + '/logs/pact.log',
  spec: 3, // Pact Specification V3
});

describe('CMS Article Fetch Contract', () => {
  test('returns valid article payload', () => {
    return provider
      .given('an article exists with id 42')
      .uponReceiving('a request for article 42')
      .withRequest({
        method: 'GET',
        path: '/api/v1/articles/42',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: integer(42),
          slug: string('my-article'),
          title: string('Valid Title'),
          publishedAt: iso8601DateTime(),
          isDraft: boolean(false),
          tags: eachLike(string('tech')),
          metadata: like({ seo: { description: string('') } }),
        },
      })
      .executeTest(async (mockserver) => {
        // Point your CMS client to the Pact mock server
        const baseUrl = mockserver.url;
        const article = await fetchArticle(baseUrl, '42');
        
        // Assertions against your actual frontend types
        expect(article.id).toBe(42);
        expect(article.tags).toHaveLength(1);
        expect(typeof article.isDraft).toBe('boolean');
      });
  });
});

3. Generate and Publish the Contract

The test writes a JSON pact file to /pacts — the exact HTTP contract your frontend expects. Publish it to a Pact Broker for cross-team visibility and version tracking:

Bash
npx pact-broker publish ./pacts --consumer-app-version $CI_COMMIT_SHA --tag $CI_COMMIT_BRANCH

4. Provider-Side Verification

The CMS team (or a dedicated pipeline) replays the published pact against the live or staging API. No CMS code changes required — it runs as an independent step:

Bash
npx pact-verify \
  --provider-base-url https://cms-staging.example.com \
  --pact-broker-base-url https://broker.pact.io \
  --provider-app-version $CI_COMMIT_SHA \
  --publish-verification-results

Pact replays the recorded requests and asserts the provider’s responses match the declared matchers. Any structural deviation fails the build.

5. CI/CD Integration and Deployment Gates

Gate both deployments behind can-i-deploy:

Bash
npx pact-broker can-i-deploy \
  --pacticipant NextJS_Frontend \
  --version $CI_COMMIT_SHA \
  --to-environment production

The broker confirms every active consumer has verified this provider version (and vice versa), blocking incompatible schema changes from shipping.

Handling Edge Cases: Nullable Fields and GraphQL Unions

CMS APIs return conditional payloads; matchers cover them without brittle tests:

  • Optional/nullable fields: MatchersV3.nullValue() with MatchersV3.like() allows null while validating shape when present.
  • GraphQL schema drift: Pact validates query structure and response shape. Pair it with schema validation so union types and interface implementations stay consistent across deploys.
  • Pagination cursors: Don’t hardcode cursor strings. Use string('regex', '^[A-Za-z0-9+/=]+$') to validate format while allowing dynamic values.

Operational Impact on Data Layers

Enforced in CI, contract testing intercepts malformed payloads before they hit the runtime, so React Query, SWR, and Apollo Client always receive type-safe data and ISR/edge caches never serve corrupted HTML or trigger hydration mismatches. The CMS API becomes a versioned contract instead of an implicit agreement: frontend teams get deterministic validation, provider teams get immediate feedback on breaking changes, and publishing keeps running.