A closer look inside Workflows
Cloudflare Workflows, now generally available, aim to make durable execution practical for multi-step applications on the Developer Platform. The engine handles state persistence, retries, and long waits so developers can focus on business logic. But for all its power, Workflows has been difficult to test: you could await a final status, but the intermediate steps were an impenetrable black box. There was no way to inspect whether a payment step succeeded or whether a downstream API call received the right payload.
The bigger problem was the side effect on the rest of your test suite. Adding a Workflow to a project forced you to disable isolated storage, a vitest-pool-workers feature that guarantees each test runs in a clean environment without state leakage from other tests. Without it, tests became flaky and unpredictable. For projects using Workers, Durable Objects, and R2 alongside Workflows, that meant choosing between project-wide isolation or skipping tests entirely. That friction was a real obstacle to adopting Workflows in well-tested applications.
New APIs for Workflow testing
To address this, we're introducing a set of APIs for comprehensive, granular, and isolated testing of Workflows. These run locally and offline with vitest-pool-workers, our testing framework that runs tests in the Workers runtime workerd. They're available through the cloudflare:test module, with @cloudflare/vitest-pool-workers version 0.9.0 and above.
The module provides two primary functions:
introspectWorkflowInstance: for unit tests where the instance ID is knownintrospectWorkflow: for integration tests where IDs are generated dynamically
A block comment example
Here's a sample test using introspectWorkflowInstance:
import { env, introspectWorkflowInstance } from "cloudflare:test";
it("should mock a an ambiguous score, approve comment and complete", async () => {
// CONFIG
await using instance = await introspectWorkflowInstance(
env.MODERATOR,
"my-workflow-instance-id-123"
);
await instance.modify(async (m) => {
await m.mockStepResult({ name: "AI content scan" }, { violationScore: 50 });
await m.mockEvent({
type: "moderation-approval",
payload: { action: "approved" },
});
await m.mockStepResult({ name: "publish comment" }, { status: "published" });
});
await env.MODERATOR.create({ id: "my-workflow-instance-id-123" });
// ASSERTIONS
expect(await instance.waitForStepResult({ name: "AI content scan" })).toEqual(
{ violationScore: 50 }
);
expect(
await instance.waitForStepResult({ name: "publish comment" })
).toEqual({ status: "published" });
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
});
This test uses AI content scan and comment publishing steps for external APIs.
When IDs are unknown
Your test may trigger a worker request that generates a Workflow instance ID you didn't predict. Call introspectWorkflow(env.MY_WORKFLOW):
it("workflow mock a non-violation score and be successful", async () => {
// CONFIG
await using introspector = await introspectWorkflow(env.MODERATOR);
await introspector.modifyAll(async (m) => {
await m.disableSleeps();
await m.mockStepResult({ name: "AI content scan" }, { violationScore: 0 });
});
await SELF.fetch(`https://mock-worker.local/moderate`);
const instances = introspector.get();
expect(instances.length).toBe(1);
// ASSERTIONS
const instance = instances[0];
expect(await instance.waitForStepResult({ name: "AI content scan" })).toEqual({ violationScore: 0 });
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
});
Both examples use await using, which is the Explicit Resource Management syntax of modern JavaScript. When out of scope, this automatically calls the introspector's disposal method, ensuring isolated storage between tests. The modify and modifyAll functions accept a callback with a modifier object that lets you inject behavior such as mocking step outcomes and disabling sleeps.
Behind the scenes
To understand how isolation now works, let's look at how a local Workflow executes. When you run wrangler dev, your Workflows run on the Miniflare simulator and workerd. Every instance is backed by a SQLite Durable Object, the "Engine DO," which executes steps, persists state, manages lifecycle, and lives inside the isolated Workers runtime.
The vitest-pool-workers runner is a separate Node.js process. vitest-pool-workers has a Runner Worker that runs tests with your wrangler.json bindings and exposes the cloudflare:test APIs. It communicates with Node.js through a special DO called Runner Object via WebSocket/RPC.
Our first implementation idea was direct access: bind each Workflow's Engine DO namespace to the Runner Worker and call engine methods directly.
That approach had drawbacks:
- We would have added a new unsafe field to Miniflare's Durable Objects. This would specify the service name of our Engines and prevent Miniflare from applying a default user prefix that would otherwise interfere with finding Durable Objects.
- vitest-pool-workers would have to bind every Engine DO from the project's Workflows — even untested ones — requiring cleanup so they're not exposed to the user's test env.
It was too invasive for one feature.
The simpler path forward
Instead, we relied on a combination of privileged local-only APIs and remote procedure calls (RPC). First, we added a set of unsafe functions to the local implementation of the Workflows binding. These exist only in the development runtime, not production, and are accessible from the test environment. They let the test runner get a stub to a specific Engine DO by providing its instance ID.
Using that stub, vitest-pool-workers communicates with the Engine DO via a special RpcTarget called WorkflowInstanceModifier. Any class extending RpcTarget has objects replaced by a stub at the call boundary; calling a method there makes an RPC back to the original object.

This approach stays inside the Workflows environment, so future changes remain safely isolated.
Handling dynamic IDs
The introspectWorkflowInstance path is straightforward: you know the instance ID, from which we derive the Engine DO ID and introspect it.
Dynamic IDs require another trick: JavaScript Proxy objects. When you call introspectWorkflow(binding), we wrap the Workflow binding in a proxy that intercepts only .create() and .createBatch(). Inside those calls, we capture the instance ID — whether you provided one or it was randomly generated — and set up introspection with your modifyAll modifications. The actual creation call then continues as usual.
env[workflow] = new Proxy(env[workflow], {
get(target, prop) {
if (prop === "create") {
return new Proxy(target.create, {
async apply(_fn, _this, [opts = {}]) {
// 1. Ensure an ID exists
const optsWithId = "id" in opts ? opts : { id: crypto.randomUUID(), ...opts };
// 2. Apply test modifications before creation
await introspectAndModifyInstance(optsWithId.id);
// 3. Call the original 'create' method
return target.create(optsWithId);
},
});
}
// Same logic for createBatch()
}
}
After the await using block finishes, or the dispose() method is called, the proxy is removed, leaving the binding in its original state.
Testing your Workflows
You can start testing today:
- Update dependencies. Ensure you have
@cloudflare/vitest-pool-workers0.9.0 or newer:npm install @cloudflare/vitest-pool-workers@latest. - Configure your test environment. If you're new to Worker testing, follow our guide to write your first test.
- Write tests. Import
introspectWorkflowInstanceorintrospectWorkflowfromcloudflare:testand use the patterns above to mock, control, and assert on Workflow behavior. API reference.



