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.
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:
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.
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
eachLikewith 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-deployto 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.