Mocking Headless CMS APIs in CI/CD Pipelines
Within Automated Testing for Headless Integrations, mocking the CMS API in CI replaces a flaky live dependency — rate-limited gateways, multi-region propagation lag, async draft state — with frozen fixtures that mirror the production schema. The payoff is deterministic, millisecond-fast builds with no 429s and no webhook race conditions across parallel jobs.
The Determinism Gap in CI/CD
Contentful, Sanity, Strapi, and Prismic are eventually consistent. Multi-region propagation, draft/published segregation, and quota enforcement all clash with the stateless, synchronous CI runner. Hitting live endpoints produces three compounding failures:
- Ephemeral IPs and rate limiting. GitHub Actions, GitLab CI, and Vercel/Netlify nodes share rotating IP ranges. CMS limiters read concurrent jobs as distributed scraping and return
429 Too Many Requestsor403 Forbidden. - State drift during builds. Webhook-triggered deploys often fire before content propagates to edge caches, so
next buildorvite buildfetches stale or null payloads — hydration mismatches, broken SSG, ISR cache poisoning. - Non-deterministic payloads. Draft fields, locale fallback chains, and reference resolution differ between preview and production. An editor changing a value mid-pipeline produces flaky assertion failures.
The fix: intercept at the runtime boundary, freeze deterministic payloads, and enforce contract validation.
Step-by-Step Resolution
1. Transport-Layer Interception
Intercept fetch/axios with Mock Service Worker (MSW) or nock. MSW hooks the undici interceptor in Node, capturing outbound requests before they hit the network — zero external traffic, no DNS overhead, millisecond responses regardless of CMS uptime.
2. Schema Extraction and Fixture Freezing
Query the preview endpoint once in a controlled run, strip volatile metadata (sys.updatedAt, timestamps, draft flags, cache-control headers), and commit the result as versioned JSON fixtures next to the test suite. The baseline survives content updates while preserving exact field shapes.
3. Strict Contract Validation
Validate frozen fixtures against TypeScript interfaces or Zod schemas derived from your content models. Diverge from the expected types and the pipeline fails — schema drift is caught before a broken component reaches staging.
4. Environment-Driven Routing
Set CI_MOCK_CMS=true and route fetches through a conditional interceptor factory that only activates outside production. Local dev and production keep live API behavior; CI gets mocks.
5. Context Isolation for Builds and Tests
Load mocks only during test execution and static generation, never during runtime SSR/CSR, or fixtures leak into the production bundle. Build-time resolution and runtime hydration stay strictly separated, as in the rest of your Data Fetching & Caching Strategies.
Production Implementation
MSW Node.js Interceptor Setup
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { z } from 'zod';
// Zod schema matching CMS content model
export const ArticleSchema = z.object({
sys: z.object({ id: z.string(), type: z.literal('Entry') }),
fields: z.object({
title: z.string(),
slug: z.string(),
body: z.string(),
publishDate: z.string().datetime(),
}),
});
// Load frozen fixtures
import articleFixture from './fixtures/article-v1.json';
export const handlers = [
http.get('https://cdn.contentful.com/spaces/:spaceId/environments/:envId/entries', async ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('content_type');
if (query === 'article') {
const validated = ArticleSchema.safeParse(articleFixture);
if (!validated.success) {
return HttpResponse.json({ error: 'Fixture schema mismatch' }, { status: 500 });
}
return HttpResponse.json({ items: [validated.data] }, { status: 200 });
}
return HttpResponse.json({ items: [] }, { status: 200 });
}),
];
export const server = setupServer(...handlers);
Conditional Fetch Wrapper
// src/lib/cms-client.ts
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';
const isCI = process.env.CI === 'true';
const mockEnabled = process.env.CI_MOCK_CMS === 'true';
let serverInstance: ReturnType<typeof setupServer> | null = null;
if (isCI && mockEnabled) {
serverInstance = setupServer(...handlers);
serverInstance.listen({ onUnhandledRequest: 'error' });
}
export const fetchCMS = async <T>(endpoint: string, options?: RequestInit): Promise<T> => {
const res = await fetch(endpoint, options);
if (!res.ok) throw new Error(`CMS request failed: ${res.status}`);
return res.json() as Promise<T>;
};
export const teardownMocks = () => serverInstance?.close();
Pipeline Configuration (GitHub Actions)
name: CI Pipeline
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
env:
CI: true
CI_MOCK_CMS: true
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test:ci
- run: npm run build
Pipeline Integration & Verification
Add a pre-build schema check so any CMS model change is reflected in the frozen fixtures before deployment.
// scripts/validate-fixtures.ts
import { ArticleSchema } from '../src/mocks/handlers';
import fixture from '../src/mocks/fixtures/article-v1.json';
try {
ArticleSchema.parse(fixture);
console.log('✅ Fixture contract valid');
process.exit(0);
} catch (err) {
console.error('❌ Fixture contract violation:', err);
process.exit(1);
}
Run this before the test suite. As part of Automated Testing for Headless Integrations, it catches type mismatches, missing locale fallbacks, and broken reference chains at the pull-request stage instead of in production.
Edge Cases and Mitigation
| Failure Vector | Mitigation Strategy |
|---|---|
| Locale Fallback Mismatch | Include explicit locale keys in fixtures (en-US, de-DE). Validate fallback chains using Zod .refine() or custom assertion functions. |
| Reference Resolution Cycles | Flatten nested references in fixtures. Use __ref ID patterns instead of deeply nested objects to prevent circular serialization during build steps. |
| Rate Limit Bypass During Local Dev | Scope MSW activation strictly to process.env.CI_MOCK_CMS === 'true'. Local developers should hit staging APIs to catch real-world latency and caching behavior. |
| ISR/SSG Cache Poisoning | Ensure mock responses include Cache-Control: no-store headers during CI builds. This prevents Next.js/Vite from persisting mock payloads into .next/cache or dist artifacts. |
Intercept at the transport layer, freeze validated fixtures, and gate mock activation behind an environment toggle: the build goes from flaky and network-dependent to deterministic and fast, scaling across parallel jobs while staying in exact parity with production content models.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
CI_MOCK_CMS |
true in CI only |
Single switch for interception; never set in production. |
onUnhandledRequest |
"error" |
Any CMS call without a handler fails loudly instead of reaching the network. |
| Fixture location | src/mocks/fixtures/*.json |
Versioned with the code that consumes them. |
| Volatile fields stripped | sys.updatedAt, sys.revision, request ids |
Keeps diffs meaningful when re-recording. |
| Re-record schedule | weekly | Balances determinism with realism. |
Gotchas & Edge Cases
- Mocks in the production bundle. Importing
msw/nodefrom a module that the app imports at runtime can pull MSW into server bundles. Load the interceptor from a test setup file or an instrumentation hook that is excluded outside CI. - Builds that bake fixtures into output. A CI build with mocks produces pages with fixture content. That is fine for testing but must never be the artifact you deploy. Build the deployable artifact in a separate job with live data.
- Handlers that match too broadly. A handler for
/entriesthat ignores query parameters returns the article fixture for every content type. Branch on the parameters that matter and return an empty collection otherwise, as the handler above does. - Fixtures for error paths. Freeze 404, 429 and malformed responses as well, and run a test per page type against each, so fallback behaviour stays covered.
Worked Example
A team running thirty parallel Playwright shards against a live Contentful space saw one in thirteen pipelines fail with 429 responses, and a few more fail because an editor had changed test content mid-run. They recorded fixtures for the forty queries the site used, wrote MSW handlers keyed by content type, and turned on onUnhandledRequest: "error". The first run failed on three queries nobody knew the site made, a leftover analytics fetch and two duplicate navigation queries, which they removed. After that, pipeline failures from the CMS disappeared, median build time dropped by nearly two thirds, and the weekly re-record job surfaced a renamed field two days before the frontend would have broken.
Rollout Checklist
- Record fixtures once from a dedicated test environment and strip volatile fields.
- Validate every fixture against the production schemas in a pre-test step.
- Enable interception only through
CI_MOCK_CMSand fail on unhandled requests. - Add fixtures for 404, 429, 503 and malformed bodies, and test each page type against them.
- Keep the deployable build in a separate job that uses live data.
- Re-record fixtures weekly through a pull request so drift is reviewed, not absorbed.
Frequently Asked Questions
MSW or nock for CMS mocking?
MSW works in Node, in the browser and in Playwright with the same handlers, which lets one fixture set serve unit tests, component stories and end-to-end runs. nock is Node-only but has no service-worker setup. For headless frontends, MSW’s reuse across environments usually wins.
How do I mock GraphQL CMS APIs?
Use MSW’s graphql.query("ArticlePage", resolver) handlers, which match by operation name, so query text formatting does not matter. Validate fixture responses against the generated TypeScript types from the CMS schema.
Do mocks make the end-to-end suite redundant?
No. Mocks prove your code handles known payloads; they cannot prove the CMS still sends them. Keep a small live suite against a test environment, as described in end-to-end testing for CMS API contracts.
How do I mock webhooks as well as API reads?
Store real webhook bodies as fixtures next to the read fixtures, sign them with the test secret in the test itself, and post them to the revalidation route. That exercises verification, payload parsing and tag mapping without a CMS in the loop.