Testing
Strategies for testing workflow and step functions at every level.
Testing workflows requires thinking about two levels: testing individual step functions (unit tests) and testing the full orchestration logic (integration tests). Since step functions behave like regular async functions when called outside a workflow, they are straightforward to test with any test framework.
Testing Step Functions
When a step function is called outside a workflow context, the "use step" directive is a no-op. The function runs as a normal async function in your test process with full Node.js access. This means you can test step functions directly using standard tools like Vitest.
export async function fetchUser(userId: string) {
"use step";
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`User not found: ${userId}`);
}
return response.json();
}
export async function formatName(first: string, last: string) {
"use step";
return `${first.trim()} ${last.trim()}`;
}import { describe, it, expect } from "vitest";
import { fetchUser, formatName } from "./steps";
describe("formatName", () => {
it("trims and joins names", async () => {
const result = await formatName(" Alice ", " Smith ");
expect(result).toBe("Alice Smith");
});
});No special setup is required. Call the step, assert on the result.
Mocking External Services
Use Vitest's mocking to isolate steps from external dependencies.
import { describe, it, expect, vi } from "vitest";
import { fetchUser } from "./steps";
// Mock the global fetch
vi.stubGlobal("fetch", vi.fn());
describe("fetchUser", () => {
it("returns user data on success", async () => {
const mockUser = { id: "123", name: "Alice" };
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify(mockUser), { status: 200 })
);
const user = await fetchUser("123");
expect(user).toEqual(mockUser);
});
it("throws on not found", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response("", { status: 404 })
);
await expect(fetchUser("missing")).rejects.toThrow("User not found");
});
});Step functions called outside a workflow do not have retry semantics or observability. If you need to test retry behavior, use an integration test that runs the full workflow. See Testing Error Handling below.
Testing Workflow Orchestration
To test a workflow end-to-end, use start() to run it against a live workflow runtime and assert on the Run object. This requires a running workflow environment (for example, the local world via pnpm dev or pnpm workflow dev).
async function validateOrder(orderId: string) {
"use step";
return { orderId, valid: true };
}
async function chargePayment(orderId: string) {
"use step";
return { orderId, charged: true };
}
export async function processOrderWorkflow(orderId: string) {
"use workflow";
const validation = await validateOrder(orderId);
if (!validation.valid) {
throw new Error("Invalid order");
}
const payment = await chargePayment(orderId);
return { orderId, status: "completed", charged: payment.charged };
}import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { processOrderWorkflow } from "./order";
describe("processOrderWorkflow", () => {
it("processes a valid order", async () => {
const run = await start(processOrderWorkflow, ["order-123"]);
const result = await run.returnValue;
expect(result).toEqual({
orderId: "order-123",
status: "completed",
charged: true,
});
const status = await run.status;
expect(status).toBe("completed");
});
});Integration tests that call start() need a running workflow runtime. Start your dev server before running these tests. For example, with the CLI: pnpm workflow dev, or with Next.js: pnpm dev.
Testing Hooks and Webhooks
Workflows that pause for external input can be tested by starting the workflow, then programmatically resuming it with resumeHook() or sending an HTTP request to the webhook URL.
Testing Hooks
import { createHook } from "workflow";
export async function approvalWorkflow(documentId: string) {
"use workflow";
const hook = createHook<{ approved: boolean; comment: string }>({
token: `approval:${documentId}`,
});
const result = await hook;
return { documentId, approved: result.approved, comment: result.comment };
}import { describe, it, expect } from "vitest";
import { start, resumeHook, getHookByToken } from "workflow/api";
import { approvalWorkflow } from "./approval";
describe("approvalWorkflow", () => {
it("resumes with approval data", async () => {
const run = await start(approvalWorkflow, ["doc-456"]);
const token = "approval:doc-456";
// Poll until the hook is registered
let hook;
for (let i = 0; i < 10 && !hook; i++) {
hook = await getHookByToken(token).catch(() => null);
if (!hook) await new Promise((r) => setTimeout(r, 1000));
}
// Resume with the payload
await resumeHook(token, { approved: true, comment: "Looks good" });
const result = await run.returnValue;
expect(result).toEqual({
documentId: "doc-456",
approved: true,
comment: "Looks good",
});
});
});Testing Webhooks
For webhooks, send an HTTP request to the generated webhook.url.
import { createWebhook } from "workflow";
async function extractBody(request: Request) {
"use step";
return request.json();
}
export async function webhookWorkflow(token: string) {
"use workflow";
const webhook = createWebhook({ token });
const request = await webhook;
const body = await extractBody(request);
return { method: request.method, body };
}import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { webhookWorkflow } from "./webhook";
describe("webhookWorkflow", () => {
it("receives webhook data", async () => {
const token = `test-${Math.random().toString(36).slice(2)}`;
const run = await start(webhookWorkflow, [token]);
// Wait for the webhook to register
await new Promise((resolve) => setTimeout(resolve, 3000));
// Send an HTTP request to the webhook endpoint
const webhookUrl = `${process.env.DEPLOYMENT_URL}/.well-known/workflow/v1/webhook/${token}`;
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "payment.completed" }),
});
expect(res.status).toBe(202);
const result = await run.returnValue;
expect(result.method).toBe("POST");
expect(result.body).toEqual({ event: "payment.completed" });
});
});Testing Error Handling
Test that workflows correctly handle step failures, including FatalError and RetryableError.
import { FatalError } from "workflow";
async function riskyStep(shouldFail: boolean) {
"use step";
if (shouldFail) {
throw new FatalError("Permanent failure - do not retry");
}
return "success";
}
export async function resilientWorkflow(shouldFail: boolean) {
"use workflow";
try {
const result = await riskyStep(shouldFail);
return { status: "completed", result };
} catch (error) {
return { status: "failed", error: String(error) };
}
}import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { resilientWorkflow } from "./resilient";
describe("resilientWorkflow", () => {
it("completes when step succeeds", async () => {
const run = await start(resilientWorkflow, [false]);
const result = await run.returnValue;
expect(result.status).toBe("completed");
expect(result.result).toBe("success");
});
it("catches FatalError from step", async () => {
const run = await start(resilientWorkflow, [true]);
const result = await run.returnValue;
expect(result.status).toBe("failed");
expect(result.error).toContain("Permanent failure");
});
});Use the static is() method for type-safe error checking: FatalError.is(error) returns true if the error is a FatalError, even across module boundaries. This is more reliable than instanceof in bundled environments.
Testing Uncaught Failures
When a workflow does not catch a step error, the run fails. Use run.returnValue in a catch block or check run.status to verify failure behavior.
import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { WorkflowRunFailedError } from "workflow/internal/errors";
import { unhandledErrorWorkflow } from "./unhandled";
describe("unhandledErrorWorkflow", () => {
it("fails the run when error is uncaught", async () => {
const run = await start(unhandledErrorWorkflow, []);
// returnValue rejects with WorkflowRunFailedError
try {
await run.returnValue;
expect.fail("Should have thrown");
} catch (error) {
expect(WorkflowRunFailedError.is(error)).toBe(true);
// The original error is available via .cause
expect(error.cause?.message).toContain("expected error text");
}
const status = await run.status;
expect(status).toBe("failed");
});
});Testing Workflow Cancellation
Test that workflows can be cancelled and that the run status reflects it.
import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { longRunningWorkflow } from "./long-running";
describe("cancellation", () => {
it("cancels a running workflow", async () => {
const run = await start(longRunningWorkflow, []);
await run.cancel();
const status = await run.status;
expect(status).toBe("cancelled");
});
});Stress Testing
Run many concurrent workflows with Promise.all to verify behavior under load. This pattern is useful for catching race conditions and verifying that the runtime handles parallel execution correctly.
import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { addTenWorkflow } from "./math";
describe("stress tests", () => {
it("runs many workflows concurrently", async () => {
const count = 10;
const runs = await Promise.all(
Array.from({ length: count }, (_, i) =>
start(addTenWorkflow, [i])
)
);
const results = await Promise.all(
runs.map((run) => run.returnValue)
);
// Each input i should return i + 10
results.forEach((result, i) => {
expect(result).toBe(i + 10);
});
});
});For Promise.race stress testing, verify that the fastest step always wins regardless of concurrent execution:
async function delayedValue(delay: number, value: number) {
"use step";
await new Promise((resolve) => setTimeout(resolve, delay));
return value;
}
export async function raceStressWorkflow() {
"use workflow";
const results = [];
for (let i = 0; i < 5; i++) {
const winner = await Promise.race([
delayedValue(100, i),
delayedValue(5000, -1),
]);
results.push(winner);
}
return results;
}Best Practices
- Keep steps small and focused. Smaller steps are easier to test in isolation and produce clearer test failures.
- Use dependency injection for external services. Pass API clients or configuration as step parameters rather than importing globals. This makes mocking straightforward.
- Test idempotency. Run steps multiple times with the same input and verify consistent results. See Idempotency for background.
- Test replay safety. Workflows replay from the event log after restarts. Avoid side effects in workflow functions (only in steps) to ensure replay produces the same result.
- Set appropriate timeouts. Integration tests that run full workflows may need longer timeouts than unit tests. Use Vitest's
timeoutoption:it("long test", { timeout: 60_000 }, async () => { ... }). - Use unique tokens in hook/webhook tests. Generate random tokens to avoid conflicts between test runs.
Related Documentation
- Workflows and Steps - The building blocks you are testing
- Errors & Retrying - Error types and retry semantics
- Common Patterns - Patterns to test including parallel execution and timeouts
- Hooks & Webhooks - How hooks and webhooks work
start()API Reference - Starting workflows programmaticallyresumeHook()API Reference - Resuming hooks in tests