Testing Workers where they run

Cloudflare has shipped a Vitest integration for Workers that executes tests directly in workerd, the same runtime that powers Workers in production. The @cloudflare/vitest-pool-workers package supports both unit tests, which import and call Worker functions directly, and integration tests that exercise Workers through HTTP requests via Cron Triggers, fetch() events, KV, R2, D1, Queues, Service Bindings, and Durable Objects. All standard Vitest features—snapshots, mocks, timers, spies—work; tests also get per-test isolated storage, watch mode by default, and hot-module-reloading.

Improved Cloudflare Workers testing via Vitest and workerd

Getting set up

The quickest path is scaffolding a new project with create-cloudflare, which pre-configures the integration and includes example unit and integration tests:

npm create cloudflare@latest hello-world -- --type=hello-world

For existing projects, install @cloudflare/vitest-pool-workers from npm. Note the package has a peer dependency on a specific Vitest version; modern npm installs this automatically, but the current supported version is listed in the getting started guide. TypeScript users should add @cloudflare/vitest-pool-workers to tsconfig.json’s types for cloudflare:test module typings:

$ npm install --save-dev @cloudflare/vitest-pool-workers

Then configure the pool in the Vitest configuration file:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["esnext"],
    "types": [
      "@cloudflare/workers-types/experimental",
      "@cloudflare/vitest-pool-workers"
    ]
  }
}

In wrangler.toml, set a compatibility date after 2022-10-31 and enable nodejs_compat:

// vitest.config.js
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";

export default defineWorkersConfig({
  test: {
    poolOptions: {
      workers: {
        wrangler: { configPath: "./wrangler.toml" },
      },
    },
  },
});
# wrangler.toml
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]

Two testing styles, one runtime

Unit tests in this model import and invoke exported Worker functions directly, asserting on return values. For a simple Worker handler:

export function add(a, b) {
  return a + b;
}

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const a = parseInt(url.searchParams.get("a"));
    const b = parseInt(url.searchParams.get("b"));
    return new Response(add(a, b));
  }
}

A matching unit test looks like this:

import { env, createExecutionContext, waitOnExecutionContext, } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { add }, worker from "./src";

describe("Hello World worker", () => {
  it(“adds two numbers”, async () => {
    expect(add(2,3).toBe(5);
  });
  it("sends request (unit style)", async () => {
    const request = new Request("http://example.com/?a=3&b=4");
    const ctx = createExecutionContext();
    const response = await worker.fetch(request, env, ctx);
    await waitOnExecutionContext(ctx);
    expect(await response.text()).toMatchInlineSnapshot(`"7"`);
  });
});

Integration tests send actual HTTP requests to the Worker and assert on responses. Import SELF from the cloudflare:test utility, and because the Worker code runs in the same context as the test runner, mocks can control Worker behavior:

// test/index.spec.ts
import { SELF } from "cloudflare:test";
import { it, expect } from "vitest";
import "../src";

// an integration test using SELF
it("sends request (integration style)", async () => {
   const response = await SELF.fetch("http://example.com/?a=3&b=4");
   expect(await response.text()).toMatchInlineSnapshot(`"7"`);
});

Applications that depend on Cloudflare platform products can be exercised in both styles. The workers-sdk repository’s examples directory (under fixtures/vitest-pool-workers-examples) shows tested patterns for KV, R2, D1, Queues, and Durable Objects.

Architecture: from Miniflare v2 to workerd

Vitest’s default “threads” pool spawns Node.js worker threads to isolate test files. When a thread imports a module, the host’s Vite Node Server either transforms and returns raw JavaScript, or resolves an external module path. Raw code executes via node:vm’s runInThisContext(); external modules load with dynamic import(). Vite transformation enables hot-module-reloading by invalidating and re-fetching changed modules.

Miniflare v2’s Vitest custom environment ran tests inside a Workers sandbox, injecting reimplemented Workers runtime APIs. But Miniflare v3 runs Worker code in actual workerd, which lives in a separate process from Node.js worker threads—JavaScript classes cannot cross that boundary.

overview of Vitest’s architecture using Miniflare v2’s environments

Vitest custom pools in workerd

The solution uses Vitest’s custom pools to run the test runner itself inside Workers running locally on workerd. The pool receives test files and decides execution; running the runner inside a Worker gives tests direct access to Workers runtime APIs. Since native JavaScript can’t cross the process boundary, serialisable RPC over WebSockets connects the Node.js host to the workerd process. Crucially, the pool executes the same vitest code originally written for Node inside a Worker, which requires Node built-in modules, dynamic code evaluation, and arbitrary module loading from disk with Node resolution. The nodejs_compat flag supplies some Node built-ins but not the rest:

our solution for Miniflare v3, make the tests run in workerd, and use WebSockets for communication

Two workerd capabilities

Cloudflare Workers normally prohibits eval() and new Function() — untrusted deployed code can't use dynamic evaluation, and workerd requires all modules declared ahead of execution. But test code doesn't yet exist when the runner starts. Two new local-only workerd features address this: “unsafe-eval bindings” and “module-fallback services.”

Unsafe-eval bindings expose eval(), new Function(), new AsyncFunction(), and new WebAssembly.Module() only to modules that explicitly receive the binding, preserving security control. This enabled polyfilling the required vm.runInThisContext(). Module-fallback services, by contrast, intercept unresolved imports and turn them into HTTP requests carrying the specifier, referrer, and import type. The service responds with either the module definition or a redirect when the resolved location differs from the specifier. Synchronous require() calls block the main thread until resolution completes. The pool’s fallback service implements Node-like resolution plus CommonJS/ESM interoperability, avoiding a JavaScript-side rebuild of workerd’s module system.

problem with Miniflare v3, the runtime APIs are defined in a separate process to the test environments, and JavaScript objects cannot cross process boundaries

By running tests in the production runtime rather than a simulator—even for local development—developers get meaningful assurances that test results predict production behavior.

Running Vitest inside workerd

With arbitrary code imports working, the next challenge was getting Vitest's thread worker to execute inside workerd. Request contexts are isolated: I/O objects like streams, request/response bodies, and WebSockets created in one context can't be used from another. For WebSocket-based RPC between the pool and workerd processes, this means all RPC traffic must stay within a single request context.

The solution is a singleton Durable Object that accepts the RPC connection and orchestrates test execution. All RPC-dependent operations—module resolution, result reporting, console logging—go through this one object. Miniflare's "magic proxy" system provides a reference to the singleton's stub from Node.js, and a WebSocket upgrade request is sent directly to it. With a few Node.js polyfills and a basic cloudflare:test module exposing bindings and an ExecutionContext factory, basic Workers unit tests become possible.

Integration testing with hot module reloading

Integration tests use a special SELF service binding from cloudflare:test. This binding points to an export default { fetch(...) {...} } handler that uses Vite to import the Worker's main module. Because Vite's transformation pipeline is involved, hot-module-reloading (HMR) works automatically: when code changes, the module cache invalidates, tests rerun, and subsequent requests execute the new code. The same wrapping technique applies to Durable Objects, giving them the same HMR behavior.

Integration tests call SELF.fetch(), which dispatches a fetch() event to user code in the same global scope as the test, but in a different request context. This means global mocks apply to the Worker's execution, and request context lifetime restrictions are enforced—forgetting ctx.waitUntil() produces an appropriate error. This is different from unit tests that call the Worker's handler directly: those run in the runner singleton's request context, whose lifetime extends automatically.

Per-test storage isolation

Most Workers bind to at least one storage service—KV, R2, or D1. Tests should be self-contained, runnable in any order, and isolated from each other. Manual storage cleanup is error-prone: you have to track every key written and restore values at test end, even on failure. The onTestFinished() hook simplifies this slightly, but you'd still need to manage KV, R2, Durable Objects, caches, and any other storage independently.

// helpers.ts
interface Env {
  NAMESPACE: KVNamespace;
}
// Get the current list stored in a KV namespace
export async function get(env: Env, key: string): Promise<string[]> {
  return await env.NAMESPACE.get(key, "json") ?? [];
}
// Add an item to the end of the list
export async function append(env: Env, key: string, item: string) {
  const value = await get(env, key);
  value.push(item);
  await env.NAMESPACE.put(key, JSON.stringify(value));
}
// helpers.spec.ts
import { env } from "cloudflare:test";
import { beforeAll, beforeEach, afterEach, it, expect } from "vitest";
import { get, append } from "./helpers";

let startingList1: string | null;
let startingList2: string | null;
beforeEach(async () => {
  // Store values before each test
  startingList1 = await env.NAMESPACE.get("list 1");
  startingList2 = await env.NAMESPACE.get("list 2");
});
afterEach(async () => {
  // Restore starting values after each test
  if (startingList1 === null) {
    await env.NAMESPACE.delete("list 1");
  } else {
    await env.NAMESPACE.put("list 1", startingList1);
  }
  if (startingList2 === null) {
    await env.NAMESPACE.delete("list 2");
  } else {
    await env.NAMESPACE.put("list 2", startingList2);
  }
});

beforeAll(async () => {
  await append(env, "list 1", "one");
});

it("appends to one list", async () => {
  await append(env, "list 1", "two");
  expect(await get(env, "list 1")).toStrictEqual(["one", "two"]);
});

it("appends to two lists", async () => {
  await append(env, "list 1", "three");
  await append(env, "list 2", "four");
  expect(await get(env, "list 1")).toStrictEqual(["one", "three"]);
  expect(await get(env, "list 2")).toStrictEqual(["four"]);
});

The Workers Vitest pool handles this automatically with the isolatedStorage option, enabled by default. Storage writes from each test are undone when the test finishes. The implementation uses a stack: before each suite or test, a new frame is pushed; all writes from the test and its beforeEach()/afterEach() hooks go into that frame. After the suite or test completes, the top frame pops, undoing its writes. This supports data seeding in beforeAll() hooks, including in nested describe() blocks.

Miniflare's storage simulators run on top of Durable Objects with a separate blob store. Locally, workerd uses SQLite for Durable Object storage, so the implementation maintains an on-disk stack of .sqlite database files—backing up databases on push, restoring on pop. Blobs in the separate store persist through stack operations and get cleaned up per test run. The current approach copies many .sqlite files; SQLite SAVEPOINTS may offer a faster alternative in the future.

Declarative fetch mocking

Most Workers make outbound fetch() requests, and tests often need to mock those responses. Miniflare supports routing all requests through an undici MockAgent, which provides a declarative interface for defining mocked requests and responses. The cloudflare:test module exposes one as fetchMock.

import { fetchMock } from "cloudflare:test";
import { beforeAll, afterEach, it, expect } from "vitest";

beforeAll(() => {
  // Enable outbound request mocking...
  fetchMock.activate();
  // ...and throw errors if an outbound request isn't mocked
  fetchMock.disableNetConnect();
});
// Ensure we matched every mock we defined
afterEach(() => fetchMock.assertNoPendingInterceptors());

it("mocks requests", async () => {
  // Mock the first request to `https://example.com`
  fetchMock
    .get("https://example.com")
    .intercept({ path: "/" })
    .reply(200, "body");

  const response = await fetch("https://example.com/");
  expect(await response.text()).toBe("body");
});

The implementation bundles a stripped-down undici containing just the MockAgent code, plus a custom Dispatcher that uses the Worker's global fetch() instead of undici's HTTP stack built on llhttp and node:net.

Direct Durable Object testing

Miniflare v2's custom Vitest environment let tests access Durable Object instance methods and state directly, allowing unit tests that mock specific methods or call handlers like alarm() immediately. In workerd, this relies on the existing wrapping of user Durable Objects for Vite transforms and HMR. Calling runInDurableObject(stub, callback) from cloudflare:test stores the callback in a global cache and sends a special fetch() request to the stub, intercepted by the wrapper. The wrapper runs the callback in the Durable Object's request context and caches the result, which runInDurableObject() then reads and returns.

This requires the Durable Object to be in the same isolate as the runInDurableObject() call—true for same-Worker Durable Objects locally, but not for Durable Objects defined in auxiliary workers, which can't be accessed this way.

Getting started

The @cloudflare/vitest-pool-workers package is now available on npm. The Write your first test guide covers setting up unit and integration tests. For existing tests, migration guides cover moving from unstable_dev and from Miniflare 2.

Issues and suggestions can be filed in the GitHub repo, or discussed in the Developer Discord.