Skip to content

Vitest

RedwoodSDK supports integration testing using Vitest and Cloudflare Workers Pool.

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

  1. Test Side: Uses an invoke helper to send a POST request with the action name and arguments.
  2. Worker Side: A handleTestRequest handler receives the request, executes the actual Server Action within the worker’s context (with full access to ctx, D1, KV, etc.), and returns the result.

You can interpret this as “RPC from Test Runner to Worker”.

You will need two things in your vitest.config.ts:

  1. Use defineWorkersConfig from @cloudflare/vitest-pool-workers/config.
  2. Point the pool to your built wrangler.json.
vitest.config.ts
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",
},
},
},
},
});

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.

src/worker.tsx
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)]),
]);

Create the test-bridge.ts handler.

src/lib/test-bridge.ts
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 });
}
}

Create a helpers.ts file to act as the client-side proxy for your actions. This uses SELF.fetch to talk to your worker.

src/tests/helpers.ts
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;
}

Use the invoke helper to call your exposed actions.

src/tests/example.test.ts
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);
});
});