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:
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:
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:
[test]
preload = ["./tests/preload.ts"]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:
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.
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:
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 = trueimport './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 |