Headless CMS Testing Strategy: Content Delivery APIs and Preview Modes
Headless CMS architectures decouple content management from content delivery, which creates a unique testing challenge: you must test both the CMS API layer and the frontend that consumes it. This guide covers testing content delivery APIs, draft/preview modes, webhook-triggered deployments, and multi-channel publishing from a single source of truth.
Key Takeaways
- Test content delivery at the API layer and at the consumer layer separately — a correct API response does not guarantee correct rendering.
- Draft preview modes require a separate authentication token and a dedicated test for each route that supports preview.
- Webhook-triggered builds are a side effect — test them with request capturing tools, not by waiting for a full build.
- Multi-channel publishing (web, mobile, email) should have contract tests that verify each channel receives the right subset of fields.
- Cache invalidation is one of the most commonly broken behaviors — write explicit tests that publish content and assert the cached response updates.
The headless CMS model — where content is stored and managed in one system and delivered via API to any number of frontend applications — introduces a category of bugs that traditional CMS testing does not encounter. A change to your content schema can break a mobile app, a web frontend, and an email renderer simultaneously. A broken preview mode can send editors into production with unreviewed content. A cache that is not invalidated on publish can leave readers looking at stale data for hours.
This guide builds a complete testing strategy for headless CMS architectures, using PayloadCMS and Directus as examples but covering patterns that apply to any headless CMS.
Layer 1: Content Delivery API Tests
The content delivery API is the contract between your CMS and every consumer. Testing it means verifying the shape and content of responses — not just that they return 200.
Schema Contract Testing
Define the expected shape of your API responses as TypeScript types or JSON schemas, and assert every response against them:
// tests/contracts/article-response.test.ts
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv({ strict: true });
addFormats(ajv);
const articleSchema = {
type: 'object',
required: ['id', 'title', 'slug', 'status', 'publishedAt', 'content'],
properties: {
id: { type: 'string' },
title: { type: 'string', minLength: 1 },
slug: { type: 'string', pattern: '^[a-z0-9-]+$' },
status: { type: 'string', enum: ['published', 'draft', 'archived'] },
publishedAt: { type: 'string', format: 'date-time' },
content: { type: 'string', minLength: 1 },
author: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
avatar: { type: ['string', 'null'], format: 'uri' },
},
},
tags: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
};
const validate = ajv.compile(articleSchema);
describe('Article API response contract', () => {
it('GET /api/articles/:slug returns a conforming response', async () => {
const res = await fetch('http://localhost:3000/api/articles/my-test-article');
const data = await res.json();
expect(res.status).toBe(200);
const valid = validate(data);
if (!valid) {
throw new Error(`Schema validation failed: ${ajv.errorsText(validate.errors)}`);
}
});
it('returns 404 for unknown slugs', async () => {
const res = await fetch('http://localhost:3000/api/articles/does-not-exist-xyz');
expect(res.status).toBe(404);
});
});Field Projection Tests
Many headless CMS consumers request only a subset of fields to reduce payload size. Test that field projection works correctly and does not accidentally leak sensitive fields:
describe('Field projection', () => {
it('returns only requested fields', async () => {
const res = await fetch(
'http://localhost:8055/items/articles?fields=id,title,slug&filter[status][_eq]=published',
{ headers: { Authorization: `Bearer ${publicToken}` } }
);
const data = await res.json();
expect(res.status).toBe(200);
data.data.forEach((article: any) => {
expect(Object.keys(article)).toEqual(['id', 'title', 'slug']);
expect(article.content).toBeUndefined();
});
});
it('does not expose internal fields to public token', async () => {
const res = await fetch(
'http://localhost:8055/items/articles?fields=id,title,internal_notes',
{ headers: { Authorization: `Bearer ${publicToken}` } }
);
const data = await res.json();
data.data.forEach((article: any) => {
expect(article.internal_notes).toBeUndefined();
});
});
});Pagination and Sorting
Content delivery APIs frequently handle large datasets. Test pagination boundaries explicitly:
describe('Pagination', () => {
beforeAll(async () => {
for (let i = 0; i < 25; i++) {
await createArticle({ title: `Article ${i}`, status: 'published' });
}
});
it('returns the correct page size', async () => {
const res = await fetch('/api/articles?limit=10&offset=0');
const data = await res.json();
expect(data.items.length).toBe(10);
expect(data.total).toBe(25);
expect(data.hasNextPage).toBe(true);
});
it('returns the last partial page correctly', async () => {
const res = await fetch('/api/articles?limit=10&offset=20');
const data = await res.json();
expect(data.items.length).toBe(5);
expect(data.hasNextPage).toBe(false);
});
it('returns an empty array for offset beyond total', async () => {
const res = await fetch('/api/articles?limit=10&offset=100');
const data = await res.json();
expect(data.items.length).toBe(0);
});
});Layer 2: Preview Mode Testing
Preview mode lets editors see draft content before it is published. It is one of the most commonly broken features in headless CMS setups because it requires a secret preview token, the frontend to switch to a draft data source, and the preview state to be cleared when the editor exits.
// tests/preview/draft-preview.test.ts
const PREVIEW_SECRET = process.env.PREVIEW_SECRET ?? 'test-preview-secret';
const FRONTEND_URL = 'http://localhost:3000';
describe('Draft preview mode', () => {
let draftArticleSlug: string;
beforeAll(async () => {
const article = await createArticle({ title: 'Unpublished Draft', status: 'draft' });
draftArticleSlug = article.slug;
});
it('returns 404 for draft without preview token', async () => {
const res = await fetch(`${FRONTEND_URL}/articles/${draftArticleSlug}`);
expect(res.status).toBe(404);
});
it('returns the draft article with a valid preview token', async () => {
const res = await fetch(
`${FRONTEND_URL}/api/preview?secret=${PREVIEW_SECRET}&slug=${draftArticleSlug}`
);
expect(res.status).toBe(307);
expect(res.headers.get('location')).toContain(draftArticleSlug);
});
it('sets the preview cookie and returns draft content', async () => {
const previewRes = await fetch(
`${FRONTEND_URL}/api/preview?secret=${PREVIEW_SECRET}&slug=${draftArticleSlug}`,
{ redirect: 'manual' }
);
const cookie = previewRes.headers.get('set-cookie');
expect(cookie).toContain('__prerender_bypass');
const articleRes = await fetch(`${FRONTEND_URL}/articles/${draftArticleSlug}`, {
headers: { Cookie: cookie! },
});
expect(articleRes.status).toBe(200);
const html = await articleRes.text();
expect(html).toContain('Unpublished Draft');
});
it('returns 401 for an invalid preview token', async () => {
const res = await fetch(
`${FRONTEND_URL}/api/preview?secret=wrong-token&slug=${draftArticleSlug}`
);
expect(res.status).toBe(401);
});
it('exits preview mode and hides draft content again', async () => {
const exitRes = await fetch(`${FRONTEND_URL}/api/exit-preview`);
const cookie = exitRes.headers.get('set-cookie');
const articleRes = await fetch(`${FRONTEND_URL}/articles/${draftArticleSlug}`, {
headers: { Cookie: cookie ?? '' },
});
expect(articleRes.status).toBe(404);
});
});Layer 3: Cache Invalidation Testing
Caching is essential for performance, but a cache that is not invalidated on publish is a silent content bug. Test the full publish to invalidation to fresh response cycle:
// tests/cache/invalidation.test.ts
const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET ?? 'test-revalidate-secret';
describe('Cache invalidation on publish', () => {
it('serves fresh content after cache revalidation is triggered', async () => {
const article = await createArticle({
title: 'Original Title',
status: 'published',
slug: 'cache-test-article',
});
// Prime the cache
await fetch(`${FRONTEND_URL}/articles/cache-test-article`);
// Update the CMS record
await updateArticle(article.id, { title: 'Updated Title' });
// Trigger revalidation webhook
await fetch(
`${FRONTEND_URL}/api/revalidate?secret=${REVALIDATE_SECRET}&slug=cache-test-article`,
{ method: 'POST' }
);
// Allow ISR to complete
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`${FRONTEND_URL}/articles/cache-test-article`);
const html = await res.text();
expect(html).toContain('Updated Title');
expect(html).not.toContain('Original Title');
});
});Layer 4: Webhook Testing
Headless CMS setups frequently use webhooks to trigger frontend rebuilds or cache purges on content changes. Unit test your webhook handler logic in isolation from the CMS and the build system:
// tests/webhooks/handler.test.ts
import { handlePublishWebhook } from '../../src/webhooks/publishHandler';
describe('Publish webhook handler', () => {
it('triggers revalidation for the correct slug', async () => {
const mockRevalidate = jest.fn().mockResolvedValue(true);
const eventPayload = {
event: 'items.update',
collection: 'articles',
data: { id: 42, slug: 'my-article', status: 'published' },
};
await handlePublishWebhook(eventPayload, { revalidatePath: mockRevalidate });
expect(mockRevalidate).toHaveBeenCalledWith('/articles/my-article');
});
it('ignores events for non-article collections', async () => {
const mockRevalidate = jest.fn();
await handlePublishWebhook(
{ event: 'items.create', collection: 'media', data: {} },
{ revalidatePath: mockRevalidate }
);
expect(mockRevalidate).not.toHaveBeenCalled();
});
it('handles missing slug gracefully without throwing', async () => {
const mockRevalidate = jest.fn();
await expect(
handlePublishWebhook(
{ event: 'items.update', collection: 'articles', data: { id: 42, status: 'published' } },
{ revalidatePath: mockRevalidate }
)
).resolves.not.toThrow();
});
});Layer 5: Multi-Channel Contract Tests
When the same CMS content is consumed by a web app, a mobile app, and an email renderer, each channel needs only a subset of fields and that subset must remain stable as the schema evolves.
// tests/contracts/mobile-consumer.test.ts
const MOBILE_REQUIRED_FIELDS = ['id', 'title', 'slug', 'summary', 'featuredImage', 'publishedAt'];
describe('Mobile app content contract', () => {
it('every published article satisfies the mobile app field contract', async () => {
const res = await fetch(
`http://localhost:8055/items/articles?filter[status][_eq]=published&fields=${MOBILE_REQUIRED_FIELDS.join(',')}`
);
const data = await res.json();
data.data.forEach((article: any) => {
MOBILE_REQUIRED_FIELDS.forEach((field) => {
expect(article[field]).toBeDefined();
if (typeof article[field] === 'string') {
expect(article[field].length).toBeGreaterThan(0);
}
});
});
});
});For PayloadCMS, the equivalent uses the local API with populated relationships:
// tests/contracts/email-consumer.test.ts
import payload from 'payload';
describe('Email renderer content contract', () => {
it('published articles expose all fields needed by the email renderer', async () => {
const result = await payload.find({
collection: 'articles',
where: { status: { equals: 'published' } },
depth: 1,
overrideAccess: true,
});
result.docs.forEach((article) => {
expect(article.title).toBeDefined();
expect(article.summary).toBeDefined();
expect(article.featuredImage).toBeDefined();
// Author must be populated, not just an ID string
expect(typeof article.author).toBe('object');
expect((article.author as any).name).toBeDefined();
});
});
});Browser-Level Testing with HelpMeTest
API-level tests cannot simulate what an editor actually experiences: navigating the CMS admin, clicking "Preview", verifying draft content renders correctly in a real browser, and then publishing. HelpMeTest fills this gap by running browser-based E2E tests written in plain English. Describe the editorial workflow — "log in as editor, open the draft article, click Preview, verify the page title matches the draft title" — and HelpMeTest runs it against every deploy without requiring any Playwright or Selenium code.
Summary
A complete headless CMS testing strategy operates at five layers: API contract tests to verify response shapes and field availability, field projection tests to confirm only the right data is exposed, preview mode tests to protect the editorial workflow, cache invalidation tests to prevent stale content from reaching readers, and webhook handler tests to confirm downstream triggers fire correctly. Multi-channel publishing requires consumer-driven contract tests that document what each channel expects. With all five layers in place, you can evolve your CMS schema and delivery logic knowing that no consumer is silently broken.