Vitest
RedwoodSDK supports integration testing using Vitest and Cloudflare Workers Pool.
The “Test Bridge” Pattern
Section titled “The “Test Bridge” Pattern”Since tests run in an isolated worker process (powered by vitest-pool-workers), they cannot directly access your running application’s state or database bindings in the same way a unit test might.
To bridge this gap, this guide uses a pattern where the test runner communicates with your worker via a special HTTP route (/_test).
- Test Side: Uses an
invokehelper to send a POST request with the action name and arguments. - Worker Side: A
handleTestRequesthandler receives the request, executes the actual Server Action within the worker’s context (with full access toctx, D1, KV, etc.), and returns the result.
You can interpret this as “RPC from Test Runner to Worker”.
1. Configure Vitest
Section titled “1. Configure Vitest”You will need two things in your vitest.config.ts:
- Use
defineWorkersConfigfrom@cloudflare/vitest-pool-workers/config. - Point the pool to your built
wrangler.json.
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({ test: { include: ["src/**/*.test.{ts,tsx}"], poolOptions: { workers: { wrangler: { // Use the built worker output so `rwsdk/worker` and RSCs resolve correctly. configPath: "./dist/worker/wrangler.json", }, }, }, },});2. Setup the Test Bridge
Section titled “2. Setup the Test Bridge”Expose a /_test route in your src/worker.tsx to handle incoming test requests.
You can find a complete working example in our Vitest Playground.
import { render, route } from "rwsdk/router";import { defineApp } from "rwsdk/worker";import { handleTestRequest } from "./lib/test-bridge";import * as appActions from "./app/actions";import * as testUtils from "./app/test-utils";
export default defineApp([ // ... other middleware
// 1. Expose the test bridge route route("/_test", { post: ({ request }) => handleTestRequest(request, { ...appActions, ...testUtils // Optional: expose specific test utilities }), }),
// ... your application routeswe use render(Document, [route("/", Home)]),]);3. Implement the Bridge Handler
Section titled “3. Implement the Bridge Handler”Create the test-bridge.ts handler.
export async function handleTestRequest(request: Request, actions: Record<string, Function>) { // Security Guard: Strictly block execution in non-test environments if (import.meta.env.PROD) { return new Response("Test bridge disabled in production", { status: 403 }); }
const { name, args } = (await request.json()) as { name: string; args: any[]; };
const actionArgs = args.map((arg) => { // Deserialize FormData if needed if (arg && typeof arg === "object" && arg.__type === "FormData") { const fd = new FormData(); (arg.entries as [string, string][]).forEach(([k, v]) => fd.append(k, v)); return fd; } return arg; });
const action = actions[name];
if (!action) { return Response.json({ error: `Action ${name} not found` }, { status: 404 }); }
try { const result = await action(...actionArgs); return Response.json({ result }); } catch (e: any) { // Return original error and stack trace so Vitest identifies the line of failure return Response.json({ error: e.message, stack: e.stack }, { status: 500 }); }}4. Implement the Client Helper
Section titled “4. Implement the Client Helper”Create a helpers.ts file to act as the client-side proxy for your actions. This uses SELF.fetch to talk to your worker.
import { SELF } from "cloudflare:test";
export async function invoke<T>(actionName: string, ...args: any[]): Promise<T> { const serializedArgs = args.map(arg => { // Handle special types like FormData if needed if (arg instanceof FormData) { return { __type: "FormData", entries: Array.from(arg.entries()) }; } return arg; });
// Call the bridge route const response = await SELF.fetch("http://localhost/_test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: actionName, args: serializedArgs }), });
if (!response.ok) { const data = await response.json() as { error: string, stack?: string }; const err = new Error(data.error); err.stack = data.stack; throw err; }
const data = await response.json() as { result: T }; return data.result;}5. Write a Test
Section titled “5. Write a Test”Use the invoke helper to call your exposed actions.
import { expect, it, describe, beforeAll } from "vitest";import { invoke } from "./helpers"; // Client-side helper
describe("Integration Test", () => { it("should create an item", async () => { // 1. Call a server action via the bridge const id = await invoke<number>("createItem", "Test Item");
// 2. Verify result expect(id).toBeGreaterThan(0);
// 3. Verify side effects (e.g. ask DB for count) const count = await invoke<number>("getItemCount"); expect(count).toBe(1); });});