Contract Testing with Pact for CMS API Stability

As part of Automated Testing for Headless Integrations, 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.

Why a static mock passes while production breaksThe provider ships a change such as a date format switch; the static fixture in CI still has the old format so tests pass, while the live response has the new format and the frontend crashes at hydration.Provider changepublishedAt → epoch msStatic fixturestill ISO 8601CI testsgreenLive responseepoch msHydrationTypeError
A fixture is a frozen copy of one response; only a verified contract notices when the provider moves on.

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:

The consumer-driven handshake across the brokerThe frontend's consumer test runs against the Pact mock server and publishes a pact; the broker replays it against the CMS API or gateway, records verification results, and can-i-deploy answers whether both versions are compatible.Frontend (consumer)Pact mock serverPact BrokerCMS API (provider)consumer test with matchersexpected request and responsepublish pact (sha + branch)replay recorded interactionsverification resultscan-i-deploycompatible: deploy allowed
Neither side deploys until the broker has a verification result linking both versions.

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.

Who owns which step when the provider is a SaaS CMSThe Pact steps and who runs them when the CMS is a vendor SaaS without Pact support, compared with an in-house gateway in front of the CMS.StepIn-house gatewayVendor SaaS CMSWrite consumer testsfrontend teamfrontend teamPublish pactsfrontend CIfrontend CIVerify providergateway CI on every changescheduled job against staging APIcan-i-deploy gateboth pipelinesfrontend pipeline onlyProvider statesgateway test fixturesseeded test environment
With a SaaS CMS, the provider side is usually your own gateway or a scheduled verification job against the vendor API.

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.

Configuration Reference

Setting Value Purpose
consumer / provider stable service names Keys the broker uses to link pacts and verifications.
spec 3 or 4 V3 matchers cover CMS needs; V4 adds message pacts for webhooks.
--consumer-app-version commit SHA Makes can-i-deploy precise per build.
--tag / branch branch name Lets feature branches verify without affecting main.
Provider states an article exists with id 42 Seeds the provider before replay; with a SaaS CMS, map states to seeded entries.
Matchers like, eachLike, iso8601DateTime Validate shape and format, never exact values.

When the CMS is a hosted product that will not run your verification in its own CI, the practical pattern is a thin gateway or proxy that you own, which is the Pact provider, plus a scheduled job that verifies the gateway against the vendor’s staging environment every night. Vendor-side drift then fails the nightly verification, and can-i-deploy blocks the next frontend deploy until someone looks.

Gotchas & Edge Cases

  • Over-specified matchers. Matching the exact number of items in an array or an exact string turns every content edit into a contract failure. Use eachLike with a minimum and type matchers everywhere except enums.
  • Provider states on a shared CMS. “An article exists with id 42” is only true if the test environment is seeded. Keep a seed script keyed by provider state and run it before verification.
  • Webhook payloads are contracts too. Revalidation routes depend on the shape of webhook bodies. Pact message pacts can describe them, so a vendor changing the payload fails verification rather than silently breaking revalidation.
  • GraphQL queries as request bodies. Pact compares request bodies, so whitespace changes in a query break the match. Use persisted query ids or normalize the query text before sending.
  • Broker access in CI. Store the broker token as a CI secret and give consumer pipelines publish rights only. Verification results should come from the provider pipeline, not from consumers.

Worked Example

An agency maintained three storefronts that read product stories from one Storyblok space through a shared gateway. The gateway team removed a deprecated teaserImage field that one storefront still used for category tiles. Before Pact, the change reached production on a Friday and the tiles rendered empty across a whole region. After Pact, each storefront published its interactions, and the gateway pipeline ran verification against all three before deploying. The next removal of a field still in use failed verification in the gateway’s pull request, naming the storefront and the interaction, and the teams scheduled the migration instead of discovering it from customer reports.

Rollout Checklist

  • Put a gateway or proxy you own between the frontends and the CMS, and make it the Pact provider.
  • Write consumer tests only for interactions the frontend actually performs, one per query or endpoint.
  • Use type and format matchers everywhere, and exact values only for enums and status codes.
  • Publish pacts from every consumer pipeline with the commit SHA and branch.
  • Verify the provider on every gateway change and nightly against the vendor’s staging API.
  • Add can-i-deploy to both pipelines before the deploy step, never after it.

Frequently Asked Questions

Is Pact overkill compared with a schema diff?

A schema diff catches type-level changes; Pact catches behavioural ones, such as a field that is still typed as a string but switches format, or an endpoint that starts returning 404 where it used to return an empty list. Use the schema diff first because it is cheap, and add Pact when several frontends depend on the same CMS-facing API.

Can Pact test GraphQL CMS APIs?

Yes. Pact has GraphQL interaction helpers that set the method, path and body for a query and match the response like any JSON body. Keep one interaction per operation the frontend actually uses, not one per field.

What if the CMS vendor makes a breaking change anyway?

Pact cannot stop a vendor from shipping, but it tells you before your next deploy and pinpoints the interaction that broke. Combined with runtime validation in fetchers, the live site degrades gracefully while you adapt.

How many interactions should a consumer define?

One per distinct request the frontend makes, with its main variations: an entry that exists, one that does not, and a listing with and without results. That is usually a few dozen for a content site. Hundreds of interactions usually mean the tests are asserting content rather than shape.