UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

308 lines (231 loc) • 12 kB
# Testing > Three tiers, and the one thing that still needs a browser. 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: ```ts title="tests/actions.test.ts" 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](/guides/authorization/) 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: ```ts 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: ```ts title="tests/api.test.ts" 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' }) }) ``` :::note[This is more than Next offers] Next's advice for server actions is end-to-end, because the interesting part there is the round trip. Here the function *is* the interesting part — the validation, the middleware, the authorisation — and it is a unit test. ::: 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: ```toml title="bunfig.toml" [test] preload = ["./tests/preload.ts"] ``` ```ts title="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](/guides/feature-flags)'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: ```ts title="tests/session.test.ts" 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. ```ts title="tests/app.test.ts" 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`: ```ts title="tests/dom.ts" 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 ``` ```tsx title="tests/pause.test.tsx" 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. <Aside type="caution" title="Never register a DOM globally, or in a preload"> `GlobalRegistrator` replaces `Request`, `Response`, `Headers`, `fetch`, `Blob`, `FormData`, `URL` and `AbortController` for every test in the process. A browser's `Request` will not carry a `Cookie` header a script set, so with a DOM in a shared preload every signed-in request `createTestApp` makes arrives with no session and bounces to sign-in. `Bun.write(path, new Blob([bytes]))` writes the text `[object Blob]`. `createTestApp()` refuses to run while a DOM is registered, and says so. Register in the component test file, as above, and `unregister()` after it. Run the suites isolated, `bun test --isolate`: `mock.module` replaces a module for the whole process, so without it one file's stubs are the next file's. </Aside> 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`](/guides/redirects) 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. ```ts 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: ```ts 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: ```ts 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 |