Skip to content

Commit 14013dd

Browse files
[ Workers/ Workflows ] Add Workflows test support APIs (#24832)
* Add Workflows test support APIs * Update src/content/docs/workers/testing/vitest-integration/test-apis.mdx Co-authored-by: Diogo Ferreira <[email protected]> * Update src/content/docs/workers/testing/vitest-integration/test-apis.mdx Co-authored-by: Diogo Ferreira <[email protected]> * Update src/content/docs/workers/testing/vitest-integration/test-apis.mdx Co-authored-by: Diogo Ferreira <[email protected]> * Add suggestions * Add new testing Workflows cleanup logic * CleanUp is now Dispose * Add version and changelog --------- Co-authored-by: Diogo Ferreira <[email protected]>
1 parent 1815145 commit 14013dd

File tree

4 files changed

+180
-0
lines changed

4 files changed

+180
-0
lines changed

src/content/docs/workers/testing/vitest-integration/recipes.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Recipes are examples that help demonstrate how to write unit tests and integrati
1717
- [Isolated tests using KV, R2 and the Cache API](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/kv-r2-caches)
1818
- [Isolated tests using D1 with migrations](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1)
1919
- [Isolated tests using Durable Objects with direct access](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/durable-objects)
20+
- [Isolated tests using Workflows](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/workflows)
2021
- [Tests using Queue producers and consumers](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/queues)
2122
- [Tests using Hyperdrive with a Vitest managed TCP server](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/hyperdrive)
2223
- [Tests using declarative/imperative outbound request mocks](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/request-mocking)

src/content/docs/workers/testing/vitest-integration/test-apis.mdx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,169 @@ The Workers Vitest integration provides runtime helpers for writing tests in the
258258

259259
* Applies all un-applied [D1 migrations](/d1/reference/migrations/) stored in the `migrations` array to database `db`, recording migrations state in the `migrationsTableName` table. `migrationsTableName` defaults to `d1_migrations`. Call the [`readD1Migrations()`](/workers/testing/vitest-integration/configuration/#readd1migrationsmigrationspath) function from the `@cloudflare/vitest-pool-workers/config` package inside Node.js to get the `migrations` array. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations.
260260

261+
262+
### Workflows
263+
264+
265+
266+
:::caution[Workflows with `isolatedStorage`]
267+
268+
To ensure proper test isolation in Workflows with isolated storage, introspectors should be disposed at the end of each test.
269+
This is accomplished by either:
270+
* Using an `await using` statement on the introspector.
271+
* Explicitly calling the introspector `dispose()` method.
272+
273+
:::
274+
275+
:::note[Version]
276+
277+
Available in `@cloudflare/vitest-pool-workers` version **0.9.0**!
278+
279+
:::
280+
281+
* `introspectWorkflowInstance(workflow: Workflow, instanceId: string)`: Promise\<WorkflowInstanceIntrospector>
282+
* Creates an **introspector** for a specific Workflow instance, used to **modify** its behavior, **await** outcomes, and **clear** its state during tests. This is the primary entry point for testing individual Workflow instances with a known ID.
283+
<br/>
284+
285+
```ts
286+
import { env, introspectWorkflowInstance } from "cloudflare:test";
287+
288+
it("should disable all sleeps, mock an event and complete", async () => {
289+
// 1. CONFIGURATION
290+
await using instance = await introspectWorkflowInstance(env.MY_WORKFLOW, "123456");
291+
await instance.modify(async (m) => {
292+
await m.disableSleeps();
293+
await m.mockEvent({
294+
type: "user-approval",
295+
payload: { approved: true, approverId: "user-123" },
296+
});
297+
});
298+
299+
// 2. EXECUTION
300+
await env.MY_WORKFLOW.create({ id: "123456" });
301+
302+
// 3. ASSERTION
303+
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
304+
305+
// 4. DISPOSE: is implicit and automatic here.
306+
});
307+
```
308+
* The returned `WorkflowInstanceIntrospector` object has the following methods:
309+
* `modify(fn: (m: WorkflowInstanceModifier) => Promise<void>): Promise<void>`: Applies modifications to the Workflow instance's behavior.
310+
* `waitForStepResult(step: { name: string; index?: number }): Promise<unknown>`: Waits for a specific step to complete and returns a result. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to target a specific occurrence.
311+
* `waitForStatus(status: InstanceStatus["status"]): Promise<void>`: Waits for the Workflow instance to reach a specific [status](/workflows/build/workers-api/#instancestatus) (e.g., 'running', 'complete').
312+
* `dispose(): Promise<void>`: Disposes the Workflow instance, which is crucial for test isolation. If this function isn't called and `await using` is not used, isolated storage will fail and the instance's state will persist across subsequent tests. For example, an instance that becomes completed in one test will already be completed at the start of the next.
313+
* `[Symbol.asyncDispose](): Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`.
314+
315+
* `introspectWorkflow(workflow: Workflow)`: Promise\<WorkflowIntrospector>
316+
* Creates an **introspector** for a Workflow where instance IDs are unknown beforehand. This allows for defining modifications that will apply to **all subsequently created instances**.
317+
<br/>
318+
319+
```ts
320+
import { env, introspectWorkflow, SELF } from "cloudflare:test";
321+
322+
it("should disable all sleeps, mock an event and complete", async () => {
323+
// 1. CONFIGURATION
324+
await using introspector = await introspectWorkflow(env.MY_WORKFLOW);
325+
await introspector.modifyAll(async (m) => {
326+
await m.disableSleeps();
327+
await m.mockEvent({
328+
type: "user-approval",
329+
payload: { approved: true, approverId: "user-123" },
330+
});
331+
});
332+
333+
// 2. EXECUTION
334+
await env.MY_WORKFLOW.create();
335+
336+
// 3. ASSERTION
337+
const instances = introspector.get();
338+
for(const instance of instances) {
339+
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
340+
}
341+
342+
// 4. DISPOSE: is implicit and automatic here.
343+
});
344+
```
345+
The workflow instance doesn't have to be created directly inside the test. The introspector will capture **all** instances created after it is initialized. For example, you could trigger the creation of **one or multiple** instances via a single `fetch` event to your Worker:
346+
```js
347+
// This also works for the EXECUTION phase:
348+
await SELF.fetch("https://example.com/trigger-workflows");
349+
```
350+
351+
* The returned `WorkflowIntrospector` object has the following methods:
352+
* `modifyAll(fn: (m: WorkflowInstanceModifier) => Promise<void>): Promise<void>`: Applies modifications to all Workflow instances created after calling `introspectWorkflow`.
353+
* `get(): Promise<WorkflowInstanceIntrospector[]>`: Returns all `WorkflowInstanceIntrospector` objects from instances created after `introspectWorkflow` was called.
354+
* `dispose(): Promise<void>`: Disposes the Workflow introspector. All `WorkflowInstanceIntrospector` from created instances will also be disposed. This is crucial to prevent modifications and captured instances from leaking between tests. After calling this method, the `WorkflowIntrospector` should not be reused.
355+
* `[Symbol.asyncDispose](): Promise<void>`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`.
356+
357+
* `WorkflowInstanceModifier`
358+
* This object is provided to the `modify` and `modifyAll` callbacks to mock or alter the behavior of a Workflow instance's steps, events, and sleeps.
359+
* `disableSleeps(steps?: { name: string; index?: number }[])`: Disables sleeps, causing `step.sleep()` and `step.sleepUntil()` to resolve immediately. If `steps` is omitted, all sleeps are disabled.
360+
* `mockStepResult(step: { name: string; index?: number }, stepResult: unknown)`: Mocks the result of a `step.do()`, causing it to return the specified value instantly without executing the step's implementation.
361+
* `mockStepError(step: { name: string; index?: number }, error: Error, times?: number)`: Forces a `step.do()` to throw an error, simulating a failure. `times` is an optional number that sets how many times the step should error. If `times` is omitted, the step will error on every attempt, making the Workflow instance fail.
362+
* `forceStepTimeout(step: { name: string; index?: number }, times?: number)`: Forces a `step.do()` to fail by timing out immediately. `times` is an optional number that sets how many times the step should timeout. If `times` is omitted, the step will timeout on every attempt, making the Workflow instance fail.
363+
* `mockEvent(event: { type: string; payload: unknown })`: Sends a mock event to the Workflow instance, causing a `step.waitForEvent()` to resolve with the provided payload. `type` must match the `waitForEvent` type.
364+
* `forceEventTimeout(step: { name: string; index?: number })`: Forces a `step.waitForEvent()` to time out instantly, causing the step to fail.
365+
366+
<br/>
367+
```ts
368+
import { env, introspectWorkflowInstance } from "cloudflare:test";
369+
370+
// This example showcases explicit disposal
371+
it("should apply all modifier functions", async () => {
372+
// 1. CONFIGURATION
373+
const instance = await introspectWorkflowInstance(env.COMPLEX_WORKFLOW, "123456");
374+
375+
try {
376+
// Modify instance behavior
377+
await instance.modify(async (m) => {
378+
// Disables all sleeps to make the test run instantly
379+
await m.disableSleeps();
380+
381+
// Mocks the successful result of a data-fetching step
382+
await m.mockStepResult(
383+
{ name: "get-order-details" },
384+
{ orderId: "abc-123", amount: 99.99 }
385+
);
386+
387+
// Mocks an incoming event to satisfy a `step.waitForEvent()`
388+
await m.mockEvent({
389+
type: "user-approval",
390+
payload: { approved: true, approverId: "user-123" },
391+
});
392+
393+
// Forces a step to fail once with a specific error to test retry logic
394+
await m.mockStepError(
395+
{ name: "process-payment" },
396+
new Error("Payment gateway timeout"),
397+
1 // Fail only the first time
398+
);
399+
400+
// Forces a `step.do()` to time out immediately
401+
await m.forceStepTimeout({ name: "notify-shipping-partner" });
402+
403+
// Forces a `step.waitForEvent()` to time out
404+
await m.forceEventTimeout({ name: "wait-for-fraud-check" });
405+
});
406+
407+
// 2. EXECUTION
408+
await env.COMPLEX_WORKFLOW.create({ id: "123456" });
409+
410+
// 3. ASSERTION
411+
expect(await instance.waitForStepResult({ name: "get-order-details" })).toEqual({
412+
orderId: "abc-123",
413+
amount: 99.99,
414+
});
415+
// Given the forced timeouts, the workflow will end in an errored state
416+
await expect(instance.waitForStatus("errored")).resolves.not.toThrow();
417+
418+
} catch {
419+
// 4. DISPOSE
420+
await instance.dispose();
421+
}
422+
});
423+
```
424+
425+
When targeting a step, use its `name`. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to specify the occurrence.
426+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
title: Test Workflows
3+
pcx_content_type: navigation
4+
external_link: /workers/testing/vitest-integration/test-apis/#workflows
5+
sidebar:
6+
order: 11
7+
---

src/content/release-notes/workflows.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ link: "/workflows/reference/changelog/"
33
productName: Workflows
44
productLink: "/workflows/"
55
entries:
6+
- publish_date: "2025-09-12"
7+
title: "Test Workflows locally"
8+
description: |-
9+
Workflows can now be tested with new test APIs available in the "cloudflare:test" module.
10+
11+
More information available in the Vitest integration [docs](/workers/testing/vitest-integration/test-apis/#workflows).
612
- publish_date: "2025-05-07"
713
title: "Search for specific Workflows"
814
description: |-

0 commit comments

Comments
 (0)