workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
227 lines (177 loc) • 8.03 kB
text/mdx
---
title: Server-Based Testing
description: Integration test workflows against a running server when you need to test the full HTTP layer.
---
The [Vitest plugin](/docs/testing
- Testing the full HTTP layer (middleware, authentication, request handling)
- Reproducing behavior that only occurs in a specific framework's runtime (e.g. Next.js, Nitro)
- Testing webhook endpoints that receive real HTTP requests
This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point — customize the server setup to match your own deployment environment.
## Vitest Configuration
Create a Vitest config with the `workflow()` Vite plugin for code transforms and a `globalSetup` script that manages the server lifecycle:
```typescript title="vitest.server.config.ts" lineNumbers
import { defineConfig } from "vitest/config";
import { workflow } from "workflow/vite"; // [!code highlight]
export default defineConfig({
plugins: [workflow()], // [!code highlight]
test: {
include: ["**/*.server.test.ts"],
testTimeout: 60_000,
globalSetup: "./vitest.server.setup.ts", // [!code highlight]
env: {
WORKFLOW_LOCAL_BASE_URL: "http://localhost:4000", // [!code highlight]
},
},
});
```
<Callout type="info">
Note the import path: `workflow/vite` (not `@workflow/vitest`). The Vite plugin handles code transforms but does not set up in-process execution. The server handles workflow execution instead.
</Callout>
The `globalSetup` script starts a dev server before tests run and tears it down afterwards. This example uses [Nitro](https://v3.nitro.build), but you can use any server framework that supports the workflow runtime.
```typescript title="vitest.server.setup.ts" lineNumbers
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import type { ChildProcess } from "node:child_process";
let server: ChildProcess | null = null;
const PORT = "4000";
function emitSetupLog(event: string, fields: Record<string, unknown> = {}) {
console.log(
JSON.stringify({
scope: "workflow-server-test",
event,
port: PORT,
...fields,
})
);
}
export async function setup() { // [!code highlight]
const stdout: string[] = [];
const stderr: string[] = [];
emitSetupLog("server_starting", {
command: `npx nitro dev --port ${PORT}`,
});
server = spawn("npx", ["nitro", "dev", "--port", PORT], {
stdio: "pipe",
detached: false,
env: process.env,
});
const ready = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), 15_000);
server?.stdout?.on("data", (data) => {
const output = data.toString();
stdout.push(output);
emitSetupLog("server_stdout", { message: output.trim() });
if (output.includes("listening") || output.includes("ready")) {
clearTimeout(timeout);
resolve(true);
}
});
server?.stderr?.on("data", (data) => {
const output = data.toString();
stderr.push(output);
emitSetupLog("server_stderr", { message: output.trim() });
});
server?.on("error", (error) => {
emitSetupLog("server_process_error", {
name: error.name,
message: error.message,
});
clearTimeout(timeout);
resolve(false);
});
server?.on("exit", (code, signal) => {
emitSetupLog("server_exit", { code, signal });
});
});
if (!ready) {
const recentStdout = stdout.join("").trim().slice(-2000);
const recentStderr = stderr.join("").trim().slice(-2000);
throw new Error(
[
"Server failed to start within 15 seconds.",
`Command: npx nitro dev --port ${PORT}`,
`WORKFLOW_LOCAL_BASE_URL: http://localhost:${PORT}`,
`Recent stdout:\n${recentStdout || "(empty)"}`,
`Recent stderr:\n${recentStderr || "(empty)"}`,
].join("\n\n")
);
}
await delay(2_000);
process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${PORT}`; // [!code highlight]
emitSetupLog("server_ready", {
baseUrl: process.env.WORKFLOW_LOCAL_BASE_URL,
});
}
export async function teardown() { // [!code highlight]
if (!server) return;
emitSetupLog("server_stopping");
server.kill("SIGTERM");
await delay(1_000);
if (!server.killed) {
emitSetupLog("server_force_kill");
server.kill("SIGKILL");
}
}
```
These JSON log lines are intentional. They give CI jobs, local tooling, and agents stable events to watch for (`server_starting`, `server_stdout`, `server_stderr`, `server_ready`, `server_exit`), and the thrown timeout error includes the command, expected `WORKFLOW_LOCAL_BASE_URL`, and buffered stdout/stderr so a failed setup is actionable without interactive debugging.
The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends step execution requests to the running server.
<Callout type="info">
You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server.
</Callout>
Tests are written the same way as [in-process integration tests](/docs/testing
```typescript title="workflows/calculate.server.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { start, getRun, resumeHook } from "workflow/api";
import { calculateWorkflow } from "./calculate";
import { approvalWorkflow } from "./approval";
describe("calculateWorkflow", () => {
it("should compute the correct result", async () => {
const run = await start(calculateWorkflow, [2, 7]);
const result = await run.returnValue;
expect(result).toEqual({
sum: 9,
product: 14,
combined: 23,
});
});
});
describe("approvalWorkflow", () => {
it("should publish when approved", async () => {
const run = await start(approvalWorkflow, ["doc-1"]);
// Use resumeHook and wakeUp to control workflow execution
await resumeHook("approval:doc-1", {
approved: true,
reviewer: "alice",
});
await getRun(run.runId).wakeUp();
const result = await run.returnValue;
expect(result).toEqual({
status: "published",
reviewer: "alice",
});
});
});
```
<Callout type="info">
In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly — you may need to add short delays or polling to ensure the workflow has reached the desired state before resuming.
</Callout>
Add a script to your `package.json`:
```json title="package.json"
{
"scripts": {
"test": "vitest",
"test:server": "vitest --config vitest.server.config.ts"
}
}
```
| Scenario | Recommended approach |
| --- | --- |
| Testing workflow logic, steps, hooks, retries | [In-process plugin](/docs/testing) |
| Testing HTTP middleware or authentication | Server-based |
| Testing webhook endpoints with real HTTP | Server-based |
| CI/CD pipeline testing | [In-process plugin](/docs/testing) |
| Reproducing framework-specific behavior | Server-based |