Automated Testing for Headless Integrations
Headless decoupling introduces failure modes that compile cleanly and break in production: schema drift, rate limiting, and cache invalidation all corrupt the UI without ever tripping a type error. This topic belongs to the Data Fetching & Caching Strategies section, because solid fetching and caching strategies need a testing layer underneath them, or you ship blind to contract violations, malformed payloads, and rendering regressions. That layer spans three boundaries: transport contracts, deterministic runtime simulation, and visual verification against real content permutations.
Integration Contract
Every headless integration rests on a contract that the CMS vendor controls and your code consumes: the shape of the delivery API’s responses, the webhook payloads, the authentication model and the caching headers. Tests exist to detect when either side breaks that contract. It helps to write down the contract in testable terms before choosing tools:
- Schema. Which content types, fields and references the frontend reads, and which fields it treats as required. The generated GraphQL SDL or OpenAPI document is the machine-readable version.
- Behaviour. What the API returns for missing entries (404 or an empty collection), for unpublished content, and for locales without a translation. These behaviours differ by platform and are rarely typed.
- Events. The webhook topics your revalidation routes subscribe to, their payload shapes and their signing scheme.
- Limits. Rate limits, maximum page sizes and query complexity limits, which turn into failures only under load.
Test configuration lives next to that contract. A typical setup keeps credentials for a dedicated test space or environment that nobody edits by hand, seeded from fixtures on every run:
# .env.test: never point tests at the production space
CMS_API_URL=https://cdn.contentful.com/spaces/abc123/environments/ci
CMS_DELIVERY_TOKEN=ci_read_only
CMS_MANAGEMENT_TOKEN=ci_seed_and_teardown
CMS_WEBHOOK_SECRET=ci_webhook_secret
PLAYWRIGHT_BASE_URL=http://localhost:3000
MSW_FIXTURES_DIR=tests/fixtures/cms
Step-by-Step Implementation
The four layers that turn CMS volatility into deterministic test inputs:
Each layer must be isolated from production dependencies so test runs stay deterministic.
1. Lock Down the Transport Contract
Extract the CMS schema — OpenAPI for REST, SDL for GraphQL — and generate type-safe clients at build time with openapi-typescript or the GraphQL Code Generator. Commit the baseline schema and diff it on every pull request. A breaking field change, removed required property, or altered type signature fails the build, forcing explicit migration before code reaches staging.
2. Isolate Network Calls
Hitting live staging or production endpoints during tests adds flakiness from latency, rate limits, and shifting data. Replace them with deterministic fixtures via Mock Service Worker, mapped to your generated TypeScript types so structural mismatches surface before the component layer. Mirror the production client’s auth headers and cache-control directives. For structuring these in CD, see Mocking headless CMS APIs in CI/CD pipelines.
3. Define Content Permutations
Real content rarely matches the ideal shape. Build a fixture matrix across the full response spectrum:
- Empty/null states: missing hero images, unpublished locale variants, optional rich-text blocks returning
null. - Boundary conditions: maximum payload sizes, deeply nested component trees, rich text with embedded media or custom shortcodes.
- Localization edge cases: RTL text flows, fallback locale chains, region-specific date/number formats.
Iterating these guarantees the UI degrades gracefully instead of throwing.
4. Capture UI State Deterministically
Render each fixture and snapshot the DOM for a baseline. Use pixel-diff tools that ignore anti-aliasing noise but flag layout shifts, missing assets, typography overflow, and broken breakpoints. Screenshot comparison depends on careful baseline management and environment parity — see Visual regression testing for CMS-driven UI components.
Testing Cache & Invalidation Behaviour
Caching bugs are the hardest headless bugs to reproduce by hand, because they depend on timing and on the order of events. They are also very testable once each cache tier is treated as a unit with inputs and outputs. For the client tier, seed a fresh cache, fire an invalidation and assert which queries refetched. For the server tier, call the revalidation route with a signed fixture payload and assert which tags it invalidated, using a spy on revalidateTag. For the CDN tier, run a staging check that requests a page, publishes a change through the management API and polls until the new content appears, recording the latency.
The ISR topic and the CDN routing topic describe the behaviour these tests pin down. The publish-to-visible latency from the end-to-end test is also the best single number to trend over time: a slow drift upwards usually means a webhook or purge step has started failing silently.
Testing Schema Changes
Content models change more often than APIs. An editor-facing change in the CMS, such as renaming a field, making a reference optional or splitting a rich text field into blocks, is a breaking API change for the frontend, and it can happen without any code change at all. Two tests catch it. A schema diff in CI compares the committed SDL or OpenAPI snapshot with the live test environment and fails on removed or retyped fields the frontend uses. A runtime validation layer, with zod or valibot schemas in every fetcher, turns an unexpected shape into a typed error instead of a crash, and a test asserts that each fetcher rejects the malformed fixtures in your permutation matrix. The schema drift guide covers the CI side in detail.
Testing Preview & Draft Flows
Preview is usually the least tested path and the one editors use most. Three assertions cover it. The draft-mode route must reject requests without a valid secret and set its cookie only for valid ones. Pages in draft mode must fetch with the preview token and must not write to the ISR or data cache. And published pages must never render draft content, even when an editor’s browser holds the preview cookie on another tab. A Playwright test that enables draft mode, edits an entry through the management API without publishing, and checks both the preview page and the public page covers all three in one run. The rules for draft/publish state define what to assert.
Choosing Tools
| Need | Tool | Notes |
|---|---|---|
| Schema contract | GraphQL Code Generator, openapi-typescript | Snapshot the schema; diff in CI. |
| Consumer-driven contracts | Pact | Useful when a gateway team owns the CMS-facing API. |
| Network isolation | Mock Service Worker | Same handlers in unit tests, Storybook and Playwright. |
| Component states | Testing Library | Assert loading, error, empty and success renders. |
| Visual diffs | Playwright screenshots, Chromatic, Percy | Stabilize fonts, dates and animations first. |
| Load | k6, Artillery | Target the proxy, never the CMS vendor’s production API. |
| Resilience | MSW fault handlers, toxiproxy | Inject latency, 5xx and truncated bodies. |
Framework-Specific Implementation: React Query
Tests against React Query for CMS Data must cover every query state (loading, error, success, stale) and cache hydration. Wrap each test in a fresh QueryClientProvider to prevent cross-test cache pollution.
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ArticleCard } from './ArticleCard';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
// Isolated QueryClient with aggressive cache clearing for deterministic tests
const createTestClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
describe('ArticleCard', () => {
it('renders loading state then transitions to success', async () => {
const client = createTestClient();
render(
<QueryClientProvider client={client}>
<ArticleCard slug="test-article" />
</QueryClientProvider>
);
// Initial loading state
expect(screen.getByText('Loading article...')).toBeInTheDocument();
// Wait for mocked response to resolve
await waitFor(() => {
expect(screen.getByText('Headless Architecture Patterns')).toBeInTheDocument();
expect(screen.getByText(/Published on/)).toBeInTheDocument();
});
});
it('handles API errors gracefully', async () => {
// Override default handler with a 500 response
server.use(
http.get('/api/cms/articles/:slug', () => {
return new HttpResponse(null, { status: 500 });
})
);
const client = createTestClient();
render(
<QueryClientProvider client={client}>
<ArticleCard slug="broken-article" />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Failed to load content. Please try again.')).toBeInTheDocument();
});
});
});
Implementation Directives
- Cache hydration: With SWR Stale-While-Revalidate Patterns or server-side hydration, pre-populate the cache with
dehydrate/hydratebefore mounting, or snapshot assertions hit hydration mismatches. - Auth & headers: Interceptors must mirror production auth. Use scoped test keys or JWT fixtures to exercise
401/403paths without real credentials, and assert outgoing requests carry the requiredAuthorizationandAcceptheaders. - End-to-end: Unit and integration tests stop at component boundaries; full contract verification needs a real CMS in staging. See End-to-end testing for headless CMS API contracts.
Enforce schema contracts, isolate the network, and validate content permutations, and unpredictable API dependencies become deterministic, version-controlled assets — the silent failures stop reaching production.
Test Data Management
Fixtures are the most underestimated part of a headless test suite. They are copies of CMS responses, so they age as the content model evolves, and they encode assumptions about optional fields, locales and reference depth that nobody wrote down. Treat them as code. Keep them next to the tests that use them, name them after the scenario rather than the entry (article-no-hero.json, not entry-4kQ9.json), and validate every fixture against the same runtime schemas the fetchers use, so an invalid fixture fails immediately instead of producing a misleading green test.
Record, do not hand-write. A small script that runs the frontend’s real queries against a dedicated test environment and writes the responses to disk produces fixtures that match the real API exactly, including the parts nobody would think to type out, such as sys metadata, link resolution wrappers and locale fields. Strip volatile values before committing: update timestamps, revision numbers and request ids change on every recording and make fixture diffs unreadable. Re-record on a schedule and review the diff like any other change. A field that disappears from a fixture diff is a content model change that the frontend team needs to know about.
The test environment itself needs protection. Give it a name that makes its purpose obvious, restrict write access to the seeding script and a few maintainers, and reset it from a seed file before large test runs. Editors who wander into a test environment and “fix” a fixture entry cause some of the most confusing CI failures a team will see.
Organising Tests in a Headless Repository
A layout that scales keeps each test type close to what it tests and separates the slow suites so CI can schedule them differently:
src/
lib/cms/
fetchers.ts # typed fetchers with schema validation
fetchers.test.ts # unit: schemas reject bad fixtures
schemas.ts # shared with CI contract job
components/blocks/
Hero.tsx
Hero.test.tsx # component states + structural snapshot
tests/
fixtures/cms/ # recorded, schema-validated responses
msw/handlers.ts # shared by unit tests, Storybook and Playwright
e2e/publish.spec.ts # staging: publish-to-visible latency
visual/blocks.spec.ts # screenshots across the fixture matrix
contract/validate.ts # pre-build job against the preview API
Accessibility and SEO Checks on CMS Output
Content changes can break accessibility and search metadata without any code change, which makes them a natural part of a headless test suite. Run axe against every page type rendered with realistic fixtures, including the empty and oversized permutations, and fail on new violations. Missing alt text on CMS images, heading levels skipped by a rich-text block and links with empty names are the most common findings. For search, assert that each page type renders exactly one h1, a unique title and a description within length limits, and that canonical and hreflang tags match the locale routing. These checks are cheap, deterministic and catch problems that editors cannot see from inside the CMS.
A scheduled job against production complements them. Crawl the sitemap weekly, run the same checks on live pages and open an issue listing any page that fails, with a link to the CMS entry. That closes the loop with the content team, who can fix alt text or titles directly without waiting for a deploy.
Resilience & Error Handling Tests
Headless frontends depend on an external service in the hot path, so failure behaviour needs its own tests. The chaos engineering guide injects latency, 5xx responses and truncated payloads with MSW handlers and asserts that circuit breakers, timeouts and stale-content fallbacks engage. At minimum, every page type should have a test that renders it with the CMS returning 503, and asserts on a usable fallback rather than an error boundary crash.
Observability for the Test Suite
A test suite for a headless integration is itself a system to monitor. Track flake rate per test, because tests that touch timing, such as revalidation or debounce windows, are the usual offenders. Track fixture age: fixtures recorded a year ago drift from what the CMS returns today, and a monthly job that re-records them from the test environment and opens a pull request with the diff keeps them honest. And track the publish-to-visible latency from staging end-to-end runs on a dashboard, next to production’s number from synthetic checks.
Measuring the Suite’s Value
A test suite earns its runtime by catching problems before readers do. Keep a short log of incidents and near misses with one field that matters: which layer caught it, or which layer should have. After a few months the pattern is usually clear. Schema diffs and runtime validation catch most content model changes, the publish round trip catches webhook and caching faults, and visual tests catch layout regressions from content extremes. Layers that never catch anything are candidates for trimming, and incidents that no layer caught show where the next test belongs.
Frequently Asked Questions
Should tests run against a real CMS or mocks?
Both, at different layers. Component and hook tests use MSW fixtures for speed and determinism. A small end-to-end suite runs against a dedicated CMS environment to verify the real contract, webhooks and caching. Never test against the production space, where editors’ changes make results unpredictable.
How do I keep fixtures in sync with the CMS?
Record them from the test environment with a script that fetches each fixture query and writes the JSON, and validate them against the same schemas the fetchers use. Re-record on a schedule and review the diff like any other change.
What is the most valuable single test for a headless site?
The end-to-end publish test: change an entry through the management API, publish, and assert the new content appears on the public URL within a latency budget. It exercises webhooks, signature verification, revalidation, CDN purges and rendering in one run.
How do I test webhook handlers without the CMS?
Store real webhook bodies as fixtures, sign them with the test secret, and post them to the route handler in an integration test. Assert on the response code and on calls to revalidateTag or your purge client. Include a fixture with a bad signature to confirm it is rejected.
How do I stop flaky tests around revalidation timing?
Replace sleeps with polling on an observable signal, such as the page text, a response header or a log line, with a generous timeout. Use fake timers in unit tests of debounce and retry logic, so the clock is controlled rather than raced. Flakiness in these tests almost always comes from waiting a fixed time for something asynchronous.
Which tests should block a merge and which only warn?
Block on schema diffs, unit and hook tests, visual regressions and the pre-build contract job, because they are deterministic. Treat staging end-to-end runs as deploy blockers rather than merge blockers, and send production synthetic check failures to on-call, not to the pull request.