Testing Contentful Content Models and Delivery API Integration

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:

  1. Content model integrity — do required fields exist with the right types?
  2. Delivery API integration — does your application correctly query and transform Contentful responses?
  3. 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-renderer

Project 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.js

Initializing 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(&quot;hi&quot;)');
});

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.

Read more