Testing Contentful Content Models and Delivery API Integration
Contentful doesn't have a built-in testing framework, but its Delivery API client, content model structure, and rich text renderer are all testable in Jest. This guide covers mocking the Contentful SDK, testing content model shape assumptions, validating rich text output, and writing integration tests against the Preview API.
Key Takeaways
Mock the Contentful client at the module boundary. The SDK returns Promise-based results with nested sys/fields objects. Mock the client, not individual HTTP calls, for reliable tests.
Test content model assumptions explicitly. Content models evolve — a field can be renamed or removed. Tests that assert field presence catch breaking changes before they reach production.
Use the Preview API for integration tests. The Content Delivery API (CDA) returns only published content. The Preview API returns drafts too, making it safe for integration tests without polluting production data.
Rich text testing requires the renderer. Rich text fields return an AST, not HTML. Test the rendered output, not the raw AST, to catch renderer configuration bugs.
Snapshot test sparingly. Snapshot tests for Contentful responses become stale quickly as content changes. Prefer structural assertions over full response snapshots.
The Contentful Testing Problem
Contentful is a headless CMS that delivers content via REST and GraphQL APIs. Testing Contentful integration involves three distinct concerns:
- Content model integrity — do required fields exist with the right types?
- Delivery API integration — does your application correctly query and transform Contentful responses?
- Rich text rendering — does the rich text renderer produce correct HTML from Contentful's AST format?
Each concern has different testing strategies.
Setting Up for Contentful Tests
Install the SDK and Jest:
npm install --save-dev jest @jest/globals
npm install contentful @contentful/rich-text-react-renderer
# Or for Node rendering:
npm install @contentful/rich-text-html-rendererProject structure:
src/
contentful/
client.js ← SDK initialization
queries.js ← Entry fetching functions
transformers.js ← Response-to-model transformations
__tests__/
contentful.test.js
rich-text.test.jsInitializing the Client
Isolate the Contentful client creation so it can be mocked:
// src/contentful/client.js
import { createClient } from 'contentful';
let client;
export function getClient() {
if (!client) {
client = createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'master',
});
}
return client;
}Mocking the Contentful Client
Mock the entire module to avoid real API calls in unit tests:
// src/__tests__/contentful.test.js
import { jest } from '@jest/globals';
jest.mock('../contentful/client.js');
import { getClient } from '../contentful/client.js';
import { getArticleBySlug, getAllArticles } from '../contentful/queries.js';
const mockEntry = {
sys: { id: 'entry-1', contentType: { sys: { id: 'article' } } },
fields: {
title: 'Test Article',
slug: 'test-article',
body: { nodeType: 'document', content: [] },
publishedAt: '2026-05-01T00:00:00.000Z',
author: {
sys: { id: 'author-1' },
fields: { name: 'Jane Smith', bio: 'Engineer' },
},
},
};
beforeEach(() => {
const mockClient = {
getEntries: jest.fn(),
getEntry: jest.fn(),
};
getClient.mockReturnValue(mockClient);
});Testing Query Functions
describe('getArticleBySlug', () => {
it('fetches article with correct query params', async () => {
const client = getClient();
client.getEntries.mockResolvedValue({
items: [mockEntry],
total: 1,
});
const result = await getArticleBySlug('test-article');
expect(client.getEntries).toHaveBeenCalledWith({
content_type: 'article',
'fields.slug': 'test-article',
include: 2,
limit: 1,
});
expect(result.slug).toBe('test-article');
expect(result.title).toBe('Test Article');
});
it('returns null when article not found', async () => {
const client = getClient();
client.getEntries.mockResolvedValue({ items: [], total: 0 });
const result = await getArticleBySlug('non-existent');
expect(result).toBeNull();
});
it('throws when API request fails', async () => {
const client = getClient();
client.getEntries.mockRejectedValue(
new Error('Contentful API error: 429 Too Many Requests')
);
await expect(getArticleBySlug('any-slug')).rejects.toThrow(
'Contentful API error'
);
});
});Testing Content Model Transformers
Transformers convert Contentful's sys/fields structure into domain models. These are pure functions — easy to test:
// src/contentful/transformers.js
export function transformArticle(entry) {
const { fields, sys } = entry;
return {
id: sys.id,
title: fields.title,
slug: fields.slug,
publishedAt: fields.publishedAt ? new Date(fields.publishedAt) : null,
author: fields.author ? transformAuthor(fields.author) : null,
body: fields.body,
tags: (fields.tags ?? []).map(tag => tag.fields.name),
};
}
export function transformAuthor(entry) {
return {
id: entry.sys.id,
name: entry.fields.name,
bio: entry.fields.bio ?? '',
};
}Tests:
import { transformArticle, transformAuthor } from '../contentful/transformers.js';
describe('transformArticle', () => {
it('maps entry fields to domain model', () => {
const article = transformArticle(mockEntry);
expect(article.id).toBe('entry-1');
expect(article.title).toBe('Test Article');
expect(article.slug).toBe('test-article');
expect(article.publishedAt).toBeInstanceOf(Date);
expect(article.author.name).toBe('Jane Smith');
});
it('handles missing optional fields', () => {
const minimalEntry = {
sys: { id: 'entry-2', contentType: { sys: { id: 'article' } } },
fields: {
title: 'Minimal Article',
slug: 'minimal',
body: { nodeType: 'document', content: [] },
},
};
const article = transformArticle(minimalEntry);
expect(article.publishedAt).toBeNull();
expect(article.author).toBeNull();
expect(article.tags).toEqual([]);
});
});Testing Content Model Shape
One of the most valuable Contentful tests is verifying that your code's assumptions about field names match the actual content model. Use a content model fixture:
// src/__tests__/content-model.test.js
// A minimal representation of the expected content model
const EXPECTED_ARTICLE_FIELDS = [
'title',
'slug',
'body',
'publishedAt',
'author',
'tags',
'metaTitle',
'metaDescription',
];
describe('Article content model', () => {
it('has all required fields present in mock entry', () => {
// When content model is updated, update the mock and this list together
const mockFields = Object.keys(mockEntry.fields);
EXPECTED_ARTICLE_FIELDS.forEach(field => {
// Some fields may be optional (null) but must be present in the response
expect(mockFields).toContain(field);
});
});
it('body field has Contentful rich text structure', () => {
const { body } = mockEntry.fields;
expect(body).toHaveProperty('nodeType', 'document');
expect(body).toHaveProperty('content');
expect(Array.isArray(body.content)).toBe(true);
});
});This makes the implicit contract explicit. When a content model field is renamed, this test breaks immediately — rather than silently producing undefined in production.
Testing Rich Text Rendering
Contentful rich text is stored as an AST. Test that your renderer produces the expected HTML:
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types';
const richTextDocument = {
nodeType: BLOCKS.DOCUMENT,
content: [
{
nodeType: BLOCKS.PARAGRAPH,
content: [
{
nodeType: 'text',
value: 'Hello ',
marks: [],
},
{
nodeType: 'text',
value: 'world',
marks: [{ type: MARKS.BOLD }],
},
],
},
{
nodeType: BLOCKS.HEADING_2,
content: [{ nodeType: 'text', value: 'Section Title', marks: [] }],
},
],
};
describe('Rich text rendering', () => {
it('renders paragraph with bold text', () => {
const html = documentToHtmlString(richTextDocument);
expect(html).toContain('<p>Hello <b>world</b></p>');
});
it('renders h2 heading', () => {
const html = documentToHtmlString(richTextDocument);
expect(html).toContain('<h2>Section Title</h2>');
});
});Testing custom renderers for embedded entries:
import { renderRichText } from '../contentful/rich-text-renderer.js';
const documentWithEmbeddedEntry = {
nodeType: BLOCKS.DOCUMENT,
content: [
{
nodeType: BLOCKS.EMBEDDED_ENTRY,
data: {
target: {
sys: { id: 'embed-1', contentType: { sys: { id: 'codeBlock' } } },
fields: { code: 'console.log("hi")', language: 'javascript' },
},
},
content: [],
},
],
};
it('renders embedded code block with syntax highlight wrapper', () => {
const html = renderRichText(documentWithEmbeddedEntry);
expect(html).toContain('data-language="javascript"');
expect(html).toContain('console.log("hi")');
});Integration Tests with the Preview API
For integration tests that hit a real Contentful environment, use the Preview API (draft content, safe for testing):
// src/__tests__/contentful.integration.test.js
// Only run in CI with preview credentials set
const SKIP = !process.env.CONTENTFUL_PREVIEW_TOKEN;
describe.skipIf(SKIP)('Contentful Preview API integration', () => {
let previewClient;
beforeAll(() => {
previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN,
host: 'preview.contentful.com',
});
});
it('fetches articles from preview environment', async () => {
const response = await previewClient.getEntries({
content_type: 'article',
limit: 1,
});
expect(response.items.length).toBeGreaterThan(0);
const article = response.items[0];
expect(article.fields).toHaveProperty('title');
expect(article.fields).toHaveProperty('slug');
expect(article.fields).toHaveProperty('body');
});
});In CI, set CONTENTFUL_PREVIEW_TOKEN as a secret and run integration tests separately from unit tests:
# Unit tests (no external calls)
jest --testPathPattern=<span class="hljs-string">'src/__tests__/(?!.*integration)'
<span class="hljs-comment"># Integration tests (Preview API)
CONTENTFUL_PREVIEW_TOKEN=<span class="hljs-variable">${{ secrets.CONTENTFUL_PREVIEW_TOKEN }} \
jest --testPathPattern=<span class="hljs-string">'integration'GraphQL API Testing
If your app uses Contentful's GraphQL API instead of the REST Delivery API, mock fetch or the GraphQL client:
global.fetch = jest.fn();
it('fetches article via GraphQL', async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => ({
data: {
articleCollection: {
items: [{ title: 'GraphQL Article', slug: 'gql-article' }],
},
},
}),
});
const articles = await fetchArticlesGraphQL();
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('graphql.contentful.com'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: expect.any(String) }),
})
);
expect(articles[0].title).toBe('GraphQL Article');
});Error Handling Tests
Contentful API errors have a predictable shape — test your error handling:
it('handles Contentful rate limit error gracefully', async () => {
const client = getClient();
const rateLimitError = new Error('Too Many Requests');
rateLimitError.status = 429;
rateLimitError.sys = { type: 'Error', id: 'RateLimitExceeded' };
client.getEntries.mockRejectedValue(rateLimitError);
const result = await getArticlesWithFallback();
// Should return cached data instead of throwing
expect(result).toBeDefined();
expect(result.source).toBe('cache');
});CI Pipeline for Contentful Tests
name: Contentful Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm test -- --testPathPattern='(?!integration)'
integration-tests:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm test -- --testPathPattern='integration'
env:
CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
CONTENTFUL_PREVIEW_TOKEN: ${{ secrets.CONTENTFUL_PREVIEW_TOKEN }}Beyond Unit Tests
Unit and integration tests verify that your code correctly calls and transforms the Contentful API. They don't verify that the rendered output looks correct to users — headings appear in the right size, rich text renders without wrapping artifacts, embedded images load correctly.
For that layer, end-to-end tests against a staging environment using tools like HelpMeTest catch visual regressions from content model changes or renderer updates that unit tests can't see.
The complete Contentful testing strategy: Jest unit tests for transformers and queries, Preview API integration tests for schema validation, and browser-level E2E tests for rendered output on staging.