# 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.

Source: https://docs.bifrostorbit.com/cms/developers/test-your-website/

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

```bash
npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
```

```ts
// vitest.config.mts
import 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:

```ts
// lib/load-page.ts
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

```ts
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' } })],
})
```

| 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:**

- `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

```ts
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' })] })
```

| 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

```tsx
// tests/pages.test.tsx
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

```tsx
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:

```ts
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`:

```ts
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:

```yaml
# .github/workflows/ci.yml
name: CI
on: [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:

```bash
curl -sI https://www.example.com/ | head -1                      # HTTP/2 200
curl -sI https://www.example.com/this-does-not-exist | head -1   # HTTP/2 404
curl -s https://www.example.com/robots.txt
curl -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.
