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
npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom// 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:
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
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' } })],})pagesgetPage, findPage, resolveredirects{ to, permanent? }. Permanent unless permanent: false.resolve answers redirect with status 301, or 302 when not permanentgonegoneresolvenavigationgetNavigation, findNavigationglobalsgetGlobal, findGlobalregionsgetRegionsentrieslist, with type, filter, tag, category, q, page, perPage and includedefaultRegionie if not set.| Option | Holds | Used by |
|---|---|---|
pages | Pages by address | getPage, findPage, resolve |
redirects | Old address to { to, permanent? }. Permanent unless permanent: false. | resolve answers redirect with status 301, or 302 when not permanent |
gone | Addresses that answer gone | resolve |
navigation | Menus by key | getNavigation, findNavigation |
globals | Global entries by key | getGlobal, findGlobal |
regions | The regions list | getRegions |
entries | Entries for lists | list, with type, filter, tag, category, q, page, perPage and include |
defaultRegion | The region used when a call names none. ie if not set. | 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:
resolvechecks pages, then redirects, then gone, and answersnotFoundfor anything else. Redirects always havehops: 1.getPage,getNavigationandgetGlobalthrowCmsNotFoundErrorwhen there is nothing; thefind…methods answernull.listanswers like the API:itemswithout fields unlessincludeis given,seo: null,total,pageandhasMore.perPageis 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
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)CmsPage. url is required.Page, content type page, region global, language en, published, version 1, no components, SEO with the name as the title and indexablefixtures.entry(input)CmsEntry for lists and references. contentType and fields are required.Entry, no addressfixtures.block(type, fields?, children?)| Fixture | Builds | Defaults |
|---|---|---|
fixtures.page(input) | A complete CmsPage. url is required. | 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) | A CmsEntry for lists and references. contentType and fields are required. | A random id, name Entry, no address |
fixtures.block(type, fields?, children?) | A component, with children by slot name | 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
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
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:
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:
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:
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 testgit diff --exit-codefails when the schema changed and nobody regenerated the types. Remove it if you would rather only fail on real type errors.tsc --noEmitfails 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:
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 -5Then publish a small change in the CMS and check it appears within a few seconds.
Was this page helpful?