workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
503 lines (368 loc) • 22.4 kB
text/mdx
---
title: Testing
description: Unit test individual steps and integration test entire workflows using Vitest.
---
Test steps like any other JavaScript function, or use the Workflow SDK Vitest plugin to run complete workflows in-process without a server.
This guide covers two approaches:
1. **Unit testing**: Test individual steps as plain functions without the workflow runtime.
2. **Integration testing**: Test entire workflows in-process using the `workflow()` Vitest plugin. Use integration tests for workflow-specific code paths that use [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), or retries.
## Unit testing steps
Without the workflow compiler, the `"use step"` directive is a no-op. Your step functions run as regular JavaScript functions, so you can unit test them without special configuration.
### Example steps
Given a workflow file with step functions like this:
```typescript title="workflows/user-signup.ts" lineNumbers
import { sleep } from "workflow";
export async function handleUserSignup(email: string) {
"use workflow";
const user = await createUser(email);
await sendWelcomeEmail(user);
await sleep("5d");
await sendOnboardingEmail(user);
return { userId: user.id, status: "onboarded" };
}
export async function createUser(email: string) {
"use step"; // [!code highlight]
return { id: crypto.randomUUID(), email };
}
export async function sendWelcomeEmail(user: { id: string; email: string }) {
"use step"; // [!code highlight]
// Send email logic
}
export async function sendOnboardingEmail(user: { id: string; email: string }) {
"use step"; // [!code highlight]
// Send email logic
}
```
### Writing unit tests for steps
You can import and test step functions directly with Vitest. No special configuration or workflow plugin is needed:
```typescript title="workflows/user-signup.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { createUser, sendWelcomeEmail } from "./user-signup"; // [!code highlight]
describe("createUser step", () => {
it("should create a user with the given email", async () => {
const user = await createUser("test@example.com");
expect(user.email).toBe("test@example.com");
expect(user.id).toBeDefined();
});
});
describe("sendWelcomeEmail step", () => {
it("should send a welcome email without throwing", async () => {
const user = { id: "user-1", email: "test@example.com" };
await expect(sendWelcomeEmail(user)).resolves.not.toThrow();
});
});
```
This approach is ideal for verifying the business logic inside individual steps in isolation.
<Callout type="info">
Unit testing works well for individual steps. A workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features.
</Callout>
## Integration testing with the Vitest plugin
For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `/vitest` plugin handles everything automatically: it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required.
<Callout type="warn">
`vi.mock()` cannot reach code inside workflow functions at all, and reaches step code only under specific conditions. Read [Mocking](#mocking) before reaching for it.
</Callout>
### Installation
```package-install
npm i -D /vitest
```
<Callout type="warn">
**Keep `/vitest` on the same major as `workflow`.** The plugin builds and runs your workflows against the copy of `@workflow/core` it was installed with, so it checks this for you. At the start of each run it compares that copy with the one your app resolves: a different major fails the run with the install command that fixes it, and any other difference logs a warning. Set [`WORKFLOW_VITEST_VERSION_CHECK=off`](/docs/configuration/build-and-diagnostics#workflow_vitest_version_check) to skip the check.
</Callout>
### Vitest configuration
Create a separate Vitest config for integration tests that includes the `workflow()` plugin:
```typescript title="vitest.integration.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflow } from "@workflow/vitest"; // [!code highlight]
export default defineConfig({
plugins: [workflow()], // [!code highlight]
test: {
include: ["**/*.integration.test.ts"],
testTimeout: 60_000, // Workflows may take longer than default timeout
},
});
```
That's it. The plugin automatically:
1. Transforms `"use workflow"` and `"use step"` directives via SWC
2. Builds workflow and step bundles before tests run
3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker; all workflow data is cleared automatically between test files for full isolation
<Callout type="info">
Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above.
</Callout>
### Writing integration tests
Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returns) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails):
```typescript title="workflows/calculate.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start } from "workflow/api"; // [!code highlight]
import { calculateWorkflow } from "./calculate";
describe("calculateWorkflow", () => {
it("should compute the correct result", async () => {
const run = await start(calculateWorkflow, [2, 7]); // [!code highlight]
expect(run.runId).toMatch(/^wrun_/);
// Blocks until the workflow completes or fails
const result = await run.returnValue; // [!code highlight]
expect(result).toEqual({
sum: 9,
product: 14,
combined: 23,
});
const status = await run.status; // [!code highlight]
expect(status).toEqual("completed");
});
});
```
### Testing hooks and waits
Integration testing is most useful for workflow-only features. You can resume hooks and waits programmatically using the [`workflow/api`](/docs/api-reference/workflow-api) functions to simulate external events in your tests.
Given a workflow that waits for approval via a hook, then sleeps before publishing:
```typescript title="workflows/approval.ts" lineNumbers
import { createHook, sleep } from "workflow";
export async function approvalWorkflow(documentId: string) {
"use workflow";
const prepared = await prepareDocument(documentId);
using hook = createHook<{ approved: boolean; reviewer: string }>({ // [!code highlight]
token: `approval:${documentId}`, // [!code highlight]
}); // [!code highlight]
const decision = await hook; // [!code highlight]
if (decision.approved) {
// Wait 24 hours before publishing (e.g. grace period for retractions)
await sleep("24h"); // [!code highlight]
await publishDocument(prepared);
return { status: "published", reviewer: decision.reviewer };
}
return { status: "rejected", reviewer: decision.reviewer };
}
async function prepareDocument(documentId: string) {
"use step";
return { id: documentId, content: "..." };
}
async function publishDocument(doc: { id: string; content: string }) {
"use step";
console.log(`Publishing document ${doc.id}`);
}
```
You can write an integration test that starts the workflow, waits for the hook and sleep to be reached, then resumes them programmatically:
```typescript title="workflows/approval.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start, getRun, resumeHook } from "workflow/api"; // [!code highlight]
import { waitForHook, waitForSleep } from "@workflow/vitest"; // [!code highlight]
import { approvalWorkflow } from "./approval";
describe("approvalWorkflow", () => {
it("should publish when approved", async () => {
const run = await start(approvalWorkflow, ["doc-123"]); // [!code highlight]
// Wait for the hook to be created, then resume it
await waitForHook(run, { token: "approval:doc-123" }); // [!code highlight]
await resumeHook("approval:doc-123", { // [!code highlight]
approved: true, // [!code highlight]
reviewer: "alice", // [!code highlight]
}); // [!code highlight]
// Wait for the first pending sleep to be reached.
// waitForSleep() returns the sleep's correlation ID, which can be
// passed to wakeUp() later to target a specific sleep in the workflow.
const sleepId = await waitForSleep(run); // [!code highlight]
// Calling wakeUp() without correlationIds would resume all active sleeps
await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlight]
const result = await run.returnValue;
expect(result).toEqual({
status: "published",
reviewer: "alice",
});
});
it("should reject when not approved", async () => {
const run = await start(approvalWorkflow, ["doc-456"]);
await waitForHook(run, { token: "approval:doc-456" });
await resumeHook("approval:doc-456", {
approved: false,
reviewer: "bob",
});
// No wakeUp() needed here; the rejected path has no sleep
const result = await run.returnValue;
expect(result).toEqual({
status: "rejected",
reviewer: "bob",
});
});
});
```
<Callout type="info">
[`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) are the key API functions for integration testing. Use `start()` to trigger a workflow, `resumeHook()` to simulate external events, and `wakeUp()` to skip `sleep()` calls so tests run instantly. [`waitForSleep()`](/docs/api-reference/vitest#waitforsleep) and [`waitForHook()`](/docs/api-reference/vitest#waitforhook) from `/vitest` let you wait for the workflow to reach a specific point before resuming. See the [API Reference](/docs/api-reference/workflow-api) for the full list of available functions.
</Callout>
<Callout type="info">
`waitForSleep()` returns the first **pending** sleep, one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it.
</Callout>
### Testing webhooks
Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload, with no HTTP server needed:
```typescript title="workflows/ingest.ts" lineNumbers
import { createWebhook } from "workflow";
export async function ingestWorkflow(endpointId: string) {
"use workflow";
// Webhook tokens are always generated for you
using webhook = createWebhook(); // [!code highlight]
const request = await webhook; // [!code highlight]
const body = await request.text();
const data = await parsePayload(body);
return { endpointId, received: data };
}
async function parsePayload(body: string) {
"use step";
return JSON.parse(body);
}
```
```typescript title="workflows/ingest.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start, resumeWebhook } from "workflow/api"; // [!code highlight]
import { waitForHook } from "@workflow/vitest"; // [!code highlight]
import { ingestWorkflow } from "./ingest";
describe("ingestWorkflow", () => {
it("should process webhook data", async () => {
const run = await start(ingestWorkflow, ["ep-1"]);
// Discover the generated webhook token
const hook = await waitForHook(run); // [!code highlight]
// Resume the webhook with a Request object
await resumeWebhook( // [!code highlight]
hook.token, // [!code highlight]
new Request("https://example.com/webhook", { // [!code highlight]
method: "POST", // [!code highlight]
body: JSON.stringify({ event: "order.created", orderId: "123" }), // [!code highlight]
}) // [!code highlight]
); // [!code highlight]
const result = await run.returnValue;
expect(result).toEqual({
endpointId: "ep-1",
received: { event: "order.created", orderId: "123" },
});
});
});
```
### Referencing workflows by name
Importing the workflow function is the best way to start it: [`start()`](/docs/api-reference/workflow-api/start) keeps its argument and return types. When a test cannot import it, because the workflow lives in a module the test does not pull in or because the test drives runs by name, look it up with [`getWorkflowRef()`](/docs/api-reference/vitest#getworkflowref) instead of hand-writing the generated `workflow//...` id:
```typescript title="workflows/approval.integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { getWorkflowRef, listWorkflowRefs } from "@workflow/vitest"; // [!code highlight]
describe("approvalWorkflow", () => {
it("is part of the test build", () => {
expect(listWorkflowRefs().map((ref) => ref.name)).toContain( // [!code highlight]
"approvalWorkflow"
);
});
it("can be started by name", async () => {
const run = await start(getWorkflowRef("approvalWorkflow"), ["doc-1"]); // [!code highlight]
expect(run.runId).toMatch(/^wrun_/);
});
});
```
The names come from the manifest the test build writes next to its bundles, so they follow your code when files move. When one exported name appears in several files, qualify it with the file, `getWorkflowRef("workflows/approval.ts#approvalWorkflow")`, which also matches by path suffix. An unknown or ambiguous name throws with the workflows the build does contain.
<Callout type="info">
A reference is matched by name, so the run is typed as `Run<unknown>`. Import the workflow function when you want the argument and return types checked.
</Callout>
### Mocking
Steps do not run from your test file's module graph. They run from the bundles the plugin builds, and workflow bodies run from a code string inside the sandboxed VM, so `vi.mock()` reaches less here than in an ordinary Vitest test:
| What you mock | Step code sees it | Workflow body sees it |
| --- | --- | --- |
| An npm package a step file imports | Only when the generated bundles load through Vitest's module runner (see below) | No |
| An npm package imported by a local module that a step file imports | Same as above | No |
| A project-local module a step file imports | No: it is bundled into the step bundle, so there is no module left to replace | No |
| A step function imported and called directly, with no `workflow()` plugin | Yes, ordinary Vitest rules | n/a |
Workflow bodies execute in a VM that has no module registry, so nothing can intercept their imports. That is the same reason side effects belong in steps: if a dependency needs mocking, it belongs on the step side.
Whether step code sees an npm mock depends on how Vitest loaded the plugin. In a normal install `/vitest` is external to Vitest, Node loads the generated bundle directly, and steps get the real package. Ask for the other behavior explicitly:
```typescript title="vitest.integration.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflow } from "@workflow/vitest";
export default defineConfig({
plugins: [workflow()],
test: {
// Route the generated bundles through Vitest's module runner, so vi.mock()
// applies to the npm packages your steps import.
server: { deps: { inline: [/@workflow\/vitest/] } }, // [!code highlight]
},
});
```
The cost is that the generated bundle goes through Vite's transform pipeline in every test worker.
Three approaches work regardless of how the plugin was loaded:
- **Unit test the step directly.** Without the plugin, `"use step"` is a no-op and the function is an ordinary async function, so `vi.mock()` behaves normally.
- **Pass the dependency in.** A step that takes its collaborator as an argument is controlled by the caller, with no module interception involved.
- **Feed values through hooks.** Resume a [hook](/docs/foundations/hooks) with the data you want instead of mocking whatever would have produced it.
### Manual setup
If you need more control over the test lifecycle, the plugin also exports the individual setup functions:
```typescript title="vitest.integration.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflowTransformPlugin } from "@workflow/rollup";
export default defineConfig({
plugins: [workflowTransformPlugin()],
test: {
include: ["**/*.integration.test.ts"],
testTimeout: 60_000,
globalSetup: "./vitest.integration.setup.ts",
setupFiles: ["./vitest.integration.env.ts"],
},
});
```
```typescript title="vitest.integration.setup.ts"
import { buildWorkflowTests } from "@workflow/vitest";
export async function setup() {
await buildWorkflowTests();
}
```
```typescript title="vitest.integration.env.ts"
import { beforeAll, afterAll } from "vitest";
import {
setupWorkflowTests,
teardownWorkflowTests,
} from "@workflow/vitest";
beforeAll(async () => {
await setupWorkflowTests();
});
afterAll(async () => {
await teardownWorkflowTests();
});
```
<Callout type="info">
`setupWorkflowTests()` automatically clears all workflow data (runs, events, hooks) on each invocation, ensuring full test isolation between test files.
</Callout>
<Callout type="info">
For advanced setups that require a running server (e.g. testing against your actual framework's HTTP layer), see [Server-based integration testing](/docs/testing/server-based).
</Callout>
## Debugging test runs
When integration tests fail, the [Workflow SDK CLI and web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you use in development.
Launch the web UI to explore your test workflow runs:
```bash
npx workflow web
```
Or use the CLI to inspect runs in the terminal:
```bash
# List recent workflow runs
npx workflow inspect runs
# Inspect a specific run
npx workflow inspect run <run-id>
```
The web UI shows each step, its inputs and outputs, retry attempts, hook state, and timing. Use it to diagnose hooks that were not resumed, steps that failed unexpectedly, or workflows that timed out.

<Callout type="info">
See the [Observability](/docs/observability) docs for the full set of CLI commands and Web UI features.
</Callout>
## Best practices
### Separate unit and integration tests
Keep two test configurations:
- **Unit tests**: Standard Vitest config with no workflow plugin. These tests require no infrastructure.
- **Integration tests**: Vitest config with the `workflow()` plugin. These tests cover the full workflow lifecycle, including hooks, sleeps, and retries.
### Use custom hook tokens for deterministic testing
When testing workflows with hooks, use [custom tokens](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) based on predictable values (like document IDs or test identifiers). This lets you resume the correct hook in your test code.
### Set appropriate timeouts
Workflows may take longer to execute than typical unit tests, especially when they involve multiple steps or retries. Set a generous `testTimeout` in your integration test config.
### Test error and retry scenarios
Integration tests are the right place to verify that your workflows handle errors correctly, including retryable errors, fatal errors, and timeout scenarios.
### Upgrade `/vitest` with the SDK
`/vitest` carries its own copy of the Workflow runtime, so treat it as part of the same upgrade as `workflow`: bump both together, and keep them on the same major. See [Installation](#installation).
## Further reading
- [Hooks & Webhooks](/docs/foundations/hooks) - Pausing and resuming workflows with external data
- [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows programmatically
- [`resumeHook()` API Reference](/docs/api-reference/workflow-api/resume-hook) - Resume hooks with data
- [`resumeWebhook()` API Reference](/docs/api-reference/workflow-api/resume-webhook) - Resume webhooks with Request objects
- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Check workflow run status and wake up sleeping runs
- [`/vitest` API Reference](/docs/api-reference/vitest) - Test helpers: `waitForSleep()`, `waitForHook()`, `getWorkflowRef()`, and plugin setup
- [Vite Integration](/docs/getting-started/vite) - Set up the Vite plugin
- [Observability](/docs/observability) - Inspect and debug workflow runs with the CLI and Web UI
- [Server-based testing](/docs/testing/server-based) - Integration testing with a running server
---
<Callout type="info">
This guide was inspired by the testing approach described in Mux's article [*Launching durable AI workflows for video with /ai*](https://www.mux.com/blog/launching-durable-ai-workflows-for-video-with-mux-ai#testing), which demonstrates how Mux uses the `workflow/vite` plugin with Vitest to integration test their durable AI video workflows built on Workflow SDK.
</Callout>