Skip to content

Guides

Test your website

Test pages, components, redirects and webhooks without calling the CMS, using the SDK's mock client and fixtures, and catch schema changes in CI by generating types.

How-to guide8 min read

Tests should not depend on the CMS being reachable or on what editors published today. The SDK has a mock client that answers from data you give it, and fixtures that build pages, entries and blocks in one line. This page shows how to use them with Vitest and React Testing Library. Jest works the same way.

Set up

Shell
npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
TypeScript
// vitest.config.mtsimport react from '@vitejs/plugin-react'import { defineConfig } from 'vitest/config'import { fileURLToPath } from 'node:url'export default defineConfig({  plugins: [react()],  resolve: { alias: { '@': fileURLToPath(new URL('.', import.meta.url)) } },  test: { environment: 'jsdom' },})

Add "test": "vitest run" to the scripts in package.json.

lib/cms.ts imports server-only, which throws outside a React Server Components build. Tests never import lib/cms.ts if your code takes the client as an argument, as below. Otherwise alias it in the config: alias: { 'server-only': fileURLToPath(new URL('./tests/empty.ts', import.meta.url)) }, with an empty tests/empty.ts.

Make your code testable

Pass the client in, rather than importing it, wherever you load content:

lib/load-page.tsTypeScript
import type { CmsReader } from '@bifrost-orbit-cms/sdk/testing'export async function loadPage(cms: CmsReader, path: string, preview = false) {  return cms.resolve({ path, preview })}

CmsReader is the part of the client the mock implements: getPage, findPage, resolve, getNavigation, findNavigation, getGlobal, findGlobal, getRegions and list. The real client fits it too, so your page route calls loadPage(cms, path) with the real one, and tests call it with the mock.

The mock client

TypeScript
import { createMockClient, fixtures } from '@bifrost-orbit-cms/sdk/testing'const cms = createMockClient({  pages: {    '/': fixtures.page({ url: '/', name: 'Home', components: [fixtures.block('hero', { heading: 'Welcome' })] }),    '/about': fixtures.page({ url: '/about', name: 'About' }),  },  redirects: { '/old-about': { to: '/about' } },  gone: ['/discontinued'],  navigation: { main: { key: 'main', name: 'Main menu', items: [] } },  globals: { footer: { key: 'footer', name: 'Footer', contentType: 'footer', locale: 'de-DE', fields: { text: '© Acme Outdoors' }, components: [] } },  regions: [{ key: 'germany', name: 'Germany', locales: [{ code: 'de-DE', name: 'German', isDefault: true }] }],  entries: [fixtures.entry({ contentType: 'faqEntry', name: 'Can I cancel?', fields: { question: 'Can I cancel?', category: 'pricing' } })],})
pages
Holds
Pages by address
Used by
getPage, findPage, resolve
redirects
Holds
Old address to { to, permanent? }. Permanent unless permanent: false.
Used by
resolve answers redirect with status 301, or 302 when not permanent
gone
Holds
Addresses that answer gone
Used by
resolve
navigation
Holds
Menus by key
Used by
getNavigation, findNavigation
globals
Holds
Global entries by key
Used by
getGlobal, findGlobal
regions
Holds
The regions list
Used by
getRegions
entries
Holds
Entries for lists
Used by
list, with type, filter, tag, category, q, page, perPage and include
defaultRegion
Holds
The region used when a call names none. ie if not set.
Used by
Page lookups

Page addresses with regions. A page is found under its address with the region in front, such as '/de/about', or under its plain address, '/about', which matches any region. The region is the call's region, or defaultRegion. Use region-prefixed keys to test that a page exists in one region only.

Answers the mock gives:

  • resolve checks pages, then redirects, then gone, and answers notFound for anything else. Redirects always have hops: 1.
  • getPage, getNavigation and getGlobal throw CmsNotFoundError when there is nothing; the find… methods answer null.
  • list answers like the API: items without fields unless include is given, seo: null, total, page and hasMore. perPage is between 1 and 100, 20 by default.

Not mocked: getBanners, getPopups, getProducts, getTaxonomy, the sitemap and robots methods, getSchema and graphql. Code that needs them should take its own small function as an argument, which tests replace.

Fixtures

TypeScript
fixtures.page({ url: '/about', name: 'About', components: [], fields: { summary: 'Who we are' } })fixtures.entry({ contentType: 'faqEntry', name: 'Can I cancel?', fields: { question: 'Can I cancel?' } })fixtures.block('cardGrid', { heading: 'Services' }, { cards: [fixtures.block('card', { title: 'Hiking boots' })] })
fixtures.page(input)
Builds
A complete CmsPage. url is required.
Defaults
Name Page, content type page, region global, language en, published, version 1, no components, SEO with the name as the title and indexable
fixtures.entry(input)
Builds
A CmsEntry for lists and references. contentType and fields are required.
Defaults
A random id, name Entry, no address
fixtures.block(type, fields?, children?)
Builds
A component, with children by slot name
Defaults
A random id, schema version 1, no variant

Anything you pass overrides the defaults, including seo, draft and meta. For a fallback test: fixtures.page({ url: '/about', locale: 'de-DE', meta: { requestedLocale: 'en-DE', fallbackApplied: true } }).

A block's fields can hold anything a real page does, such as an image: fixtures.block('hero', { heading: 'Hi', image: { id: 'i1', url: 'https://example.com/a.jpg', mimeType: 'image/jpeg', width: 1200, height: 800, alt: 'A room', title: null } }).

Test pages render

tests/pages.test.tsxTypeScript
import { render, screen } from '@testing-library/react'import { CmsPage } from '@bifrost-orbit-cms/sdk/react'import { createMockClient, fixtures } from '@bifrost-orbit-cms/sdk/testing'import { describe, expect, it } from 'vitest'import { registry } from '@/cms/registry'import { loadPage } from '@/lib/load-page'const cms = createMockClient({  pages: { '/': fixtures.page({ url: '/', components: [fixtures.block('hero', { heading: 'Welcome' })] }) },  redirects: { '/old': { to: '/' } },})describe('pages', () => {  it('renders the home page hero', async () => {    const result = await loadPage(cms, '/')    if (result.kind !== 'page') throw new Error('expected a page')    render(<CmsPage page={result.page} registry={registry} />)    expect(screen.getByRole('heading', { name: 'Welcome' })).toBeTruthy()  })  it('redirects old addresses', async () => {    expect(await loadPage(cms, '/old')).toMatchObject({ kind: 'redirect', to: '/', permanent: true })  })  it('answers not found for unknown addresses', async () => {    expect(await loadPage(cms, '/nothing')).toEqual({ kind: 'notFound' })  })})

Async server components cannot be rendered by React Testing Library. Test the functions that load data, as above, and render the components they feed.

Test a component

TypeScript
import { render } from '@testing-library/react'import { fixtures } from '@bifrost-orbit-cms/sdk/testing'import { Hero } from '@/components/blocks/hero'it('leaves out a link to an unpublished page', () => {  const block = fixtures.block('hero', {    heading: 'Welcome',    link: { kind: 'content', href: null, contentId: 'p1', assetId: null, label: 'More', target: null },  })  const { container } = render(<Hero id={block.id} variant={null} fields={block.fields} slots={{}} block={{ ...block.fields, type: 'hero', id: block.id, variant: null } as never} />)  expect(container.querySelector('a')).toBeNull()})

Render it through CmsBlocks instead to get the props built for you: render(<CmsBlocks blocks={[block]} registry={registry} />).

Check that every component works with only its required fields, since optional ones can be missing.

Test the webhook route

The handler is a plain function from Request to Response. Pass a revalidate that records tags instead of calling Next.js:

TypeScript
import { createHmac } from 'node:crypto'import { createRevalidateHandler } from '@bifrost-orbit-cms/sdk/next'import { expect, it } from 'vitest'function signed(body: object, secret: string, timestamp = Date.now()) {  const raw = JSON.stringify(body)  const signature = createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex')  return new Request('http://localhost/api/revalidate', {    method: 'POST',    headers: { 'x-orbit-timestamp': String(timestamp), 'x-orbit-signature': signature },    body: raw,  })}it('refreshes the published page', async () => {  const refreshed: string[] = []  const POST = createRevalidateHandler({ secret: 'whsec_test', revalidate: (tag) => refreshed.push(tag) })  const response = await POST(signed({ event: 'content.published', data: { url: '/about', region: 'germany' } }, 'whsec_test'))  expect(response.status).toBe(200)  expect(refreshed).toContain('cms:page:germany:/about')})it('refuses a bad signature', async () => {  const POST = createRevalidateHandler({ secret: 'whsec_test', revalidate: () => {} })  expect((await POST(signed({ event: 'content.published', data: {} }, 'wrong'))).status).toBe(401)})

tagsForEvent(event, data) from @bifrost-orbit-cms/sdk/next is a plain function too, if you only want to check which tags an event refreshes.

Test with a fake fetch

To test your own use of the real client, such as retries or error handling, give it a fake fetch:

TypeScript
import { createCmsClient, CmsUnavailableError } from '@bifrost-orbit-cms/sdk'const cms = createCmsClient({  baseUrl: 'https://cms.test',  apiKey: 'cms_pk_test',  retries: 0,  fetch: async () => new Response(JSON.stringify({ error: { message: 'Down' } }), { status: 503 }),})await expect(cms.getPage('/')).rejects.toBeInstanceOf(CmsUnavailableError)

A successful answer is { "data": … }, as from the real API.

Catch schema changes in CI

When someone changes a component in the CMS, the generated types change. Generate them in CI and fail if they differ from the committed file, or just type-check against the fresh ones:

.github/workflows/ci.ymlYAML
name: CIon: [push, pull_request]jobs:  check:    runs-on: ubuntu-latest    env:      CMS_API_URL: https://api.bifrostorbit.com/cms      CMS_PUBLIC_KEY: ${{ secrets.CMS_PUBLIC_KEY }}    steps:      - uses: actions/checkout@v4      - uses: actions/setup-node@v4        with:          node-version: 22          cache: npm      - run: npm ci      - run: npx cms-orbit generate --out cms/types.ts      - run: git diff --exit-code cms/types.ts      - run: npx tsc --noEmit      - run: npm test
  • git diff --exit-code fails when the schema changed and nobody regenerated the types. Remove it if you would rather only fail on real type errors.
  • tsc --noEmit fails when a component is missing from the registry or a field your code uses was removed or renamed.
  • Use the public key of the environment your website reads, usually Production. Store it as a repository secret.
  • A scheduled run, such as daily, catches schema changes made between your own pushes.

Check a deployed website

After each deploy, a few requests confirm the basics:

Shell
curl -sI https://www.example.com/ | head -1                      # HTTP/2 200curl -sI https://www.example.com/this-does-not-exist | head -1   # HTTP/2 404curl -s https://www.example.com/robots.txtcurl -s https://www.example.com/sitemap.xml | head -5

Then publish a small change in the CMS and check it appears within a few seconds.

Was this page helpful?

Read next