Skip to content

Testing

Three tiers, and the one thing that still needs a browser.

Updated View as Markdown

Almost everything here is a function, and functions are tested by calling them. Any test runner works — Bun’s, Vitest, Jest — because nothing below needs a runtime of ours. The examples use bun:test.

Actions, queries and api routes are functions

"use server" is a directive for the bundler. In a test file it is a string, and the function it marks is importable:

tests/actions.test.tsts
import { placeOrder } from '../src/actions'
import { getListings } from '../src/queries'

test('placeOrder accepts an item', async () => {
  expect(await placeOrder('a rubber duck')).toEqual({ ok: true })
})

test('getListings answers with a list', async () => {
  expect(await getListings('stay')).toBeInstanceOf(Array)
})

An action built on the action client runs its whole middleware chain when called, so a check that refuses a stranger is testable by calling it as one. It returns its failures, so assert on the result:

const result = await createPost({ title: '' })

expect(result.validationErrors).toEqual({ title: ['too short'] })

An api route is the same. Hand it a Request and the context the engine would:

tests/api.test.tsts
import { GET } from '../src/app/api/greet/[name]/route'

test('greets by name', async () => {
  const res = await GET(new Request('https://app.test/api/greet/ada'), {
    params: Promise.resolve({ name: 'ada' }),   // a promise, as the engine gives it - RouteContext types it so
  })

  expect(await res.json()).toEqual({ greeting: 'Hello, ada' })
})

One line a port brings needs a stub: import 'server-only'. Keep it — the build honours it, and a client file that imports the module fails to build rather than shipping a secret. But under Vite the package resolves to an empty module on the server, and under bun test it is the real package, which throws on import; an action file that carries the line is not callable in a unit test until a preload stubs it. The scaffold ships the two files:

bunfig.tomltoml
[test]
preload = ["./tests/preload.ts"]
tests/preload.tsts
import { mock } from 'bun:test'

mock.module('server-only', () => ({}))
mock.module('next/headers', () => import('@rsc-kit/core/request'))

The second line is the same kind of gap. The build answers next/headers with this package’s headers() and cookies(), which is how a library written for Next — the Flags SDK’s flags/next — runs here without Next. A test is not built, so the import finds Next’s own module, which throws outside a Next request, or finds nothing. The line answers it the way the build does, and a flag read in an action or a page evaluates under withRequest() as it does in the app.

Reading the request

cookies(), headers() and the rest read from a scope the host opens per request. In a test there is no host, so open it yourself:

tests/session.test.tsts
import { withRequest } from '@rsc-kit/core/request'
import { currentUser } from '../src/session'

test('a signed-in cookie is a user', async () => {
  const user = await withRequest(
    new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }),
    currentUser,
  )

  expect(user?.name).toBe('Ada')
})

test('and no cookie is nobody', async () => {
  expect(await withRequest(new Request('https://app.test/'), currentUser)).toBeNull()
})

The scope is per call, so two requests in flight in the same test do not see each other’s cookies.

The whole app, as it is deployed

The tier between a function and a browser, and the one that is ours to offer: the app as Request → Response, through the real router, the real middleware, the real api routes and the pages the build stored — with no port and no process.

tests/app.test.tsts
import { createTestApp } from '@rsc-kit/core/testing'

const app = await createTestApp()

test('a stored page is served', async () => {
  const res = await app.fetch('/orders')

  expect(res.status).toBe(200)
})

test('a guarded page turns a stranger away', async () => {
  const res = await app.fetch('/admin', { redirect: 'manual' })

  expect(res.status).toBe(307)
  expect(res.headers.get('Location')).toBe('/login')
})

test('a method a route does not export is 405', async () => {
  const res = await app.fetch('/api/health', { method: 'DELETE' })

  expect(res.headers.get('Allow')).toContain('GET')
})

This is where a route that renders fine and is served wrong shows up: a guard that never ran, a stored page that should not have been, a 404 that came back 200.

createTestApp() builds when anything the build is made from is newer than the last build — your source, your Vite config, package.json, the lockfile, the installed @rsc-kit/core — and not otherwise: the first run pays for it, the rest do not, and an edit or an upgrade is picked up. It runs your own build script — bun run build under Bun, npm run build under Node — so what is tested is what ships, on the runtime it ships on. Files the build wrote to .output/public — the hashed assets, sw.js, manifest.webmanifest, the icons — are answered too, the way Nitro’s static layer answers them in production, so a test can prove a page’s links resolve and not only that the page renders. Pass { build: false } in a ci step that already built.

One build and one loaded module per test run, shared across files. That is both the fast path and the correct one: two copies of the server bundle in one process would be two client-reference registries.

Components

<Form>, useField, useOnline and the rest are client components. Test them with React’s own tools and any DOM — the package’s own suite uses happy-dom with react-dom/client and act, and so can yours. Testing Library works the same way.

Your own component, and the handler it wrote, is tested by mounting it and clicking, not by calling React’s hooks yourself. Register a DOM in a module that the test file imports first, replace the module the stubs come from, and click inside act:

tests/dom.tsts
import { GlobalRegistrator } from '@happy-dom/global-registrator'

GlobalRegistrator.register({ url: 'https://example.test/' })
// Tells React it is in a test renderer, so act() flushes effects and updates.
globalThis.IS_REACT_ACT_ENVIRONMENT = true
tests/pause.test.tsxtsx
import './dom' // first: see below
import { GlobalRegistrator } from '@happy-dom/global-registrator'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterAll, expect, mock, test } from 'bun:test'

let appsPause = async (): Promise<unknown> => {}

// The module the component imports its stubs from. `vi.mock` does the same.
mock.module('../src/server-actions.generated', () => ({ appsPause: () => appsPause() }))

const { Pause } = await import('../src/app/Pause')

afterAll(() => GlobalRegistrator.unregister())

const clicked = async () => {
  const host = document.createElement('div')

  await act(async () => createRoot(host).render(<Pause />))
  await act(async () => {
    host.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
  })

  return host.textContent
}

test('says paused when the call worked', async () => {
  expect(await clicked()).toBe('Paused')
})

test('says nothing when the call redirected', async () => {
  appsPause = async () => ({ redirected: '/login' })

  expect(await clicked()).toBe('Pause')
})

The DOM has to exist before any component is imported, which is why ./dom is the first line and not a beforeAll. Imports run in order, and a library that asks for a DOM when it is imported keeps the answer: Base UI picks its layout effect from typeof document once, so a component loaded before the DOM existed never opens its dialogs. The component itself is imported with await import() after mock.module, so the stubs are replaced before it loads.

The second test is the one that matters for a backend action: any call to one can answer { redirected }, so a handler that reports success without checking isRedirected is a bug a test like this finds.

What still needs a browser

One thing: a server action called over the wire. The function is testable directly, and every url is testable through createTestApp() — but the wire call carries an id that React keeps private, so a test holding the source function cannot address the built one.

That is the same limit Next has, and it is narrower here: it is the encoding of one round trip, not the action. Everything the action does is a unit test above. For the round trip itself, and for anything that depends on hydration — useField re-rendering, a navigation, an optimistic update reverting — use Playwright against vite preview, which serves the real build.

What a page shows

fetch() returns the document as it is sent, and the document carries the page’s payload for the browser to hydrate from — everything the page was given, a client component’s props included, whether or not it is on screen. To assert that something is not shown, read markup(): the same document with every <script> taken out.

expect(await app.markup('/teams/acme')).not.toContain('internal note')

A region that streamed in later is in the markup where it arrived; a <Suspense> fallback it replaced is still there too, as the server sent it.

What a page watches

watched() renders a page and says what each region refreshes on - page for the page’s own refreshOn, a section’s name for each section’s - without reading the signed names out of the payload:

expect(await app.watched('/teams/acme')).toEqual({ page: ['team:acme'], apps: ['team:acme:apps'] })

With a Go or Laravel backend stood in by host, testChanges() answers the version questions the way the backend would, and its changed says a name moved - what the backend’s webhook would do:

import { createTestApp, testChanges } from '@rsc-kit/core/testing'

const names = testChanges()
const app = await createTestApp({ host: { ...names.host, 'Repos.list': () => repos } })

names.changed('team:1:repos')

What to test where

what how
an action’s logic, validation, middleware call it
a query call it
an api route call GET/POST with a Request
anything that reads cookies or headers withRequest()
routing, guards, stored pages, status codes createTestApp().fetch()
what a page shows, or does not createTestApp().markup()
what a page refreshes on createTestApp().watched()
a client component React + a DOM
an action over the wire, hydration, navigation Playwright
Navigation

Type to search…

↑↓ navigate↵ selectEsc close