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:
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:
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:
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:
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:
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()withMatchersV3.like()allowsnullwhile 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.