Server-Based Testing
Integration test workflows against a running server when you need to test the full HTTP layer.
The Vitest plugin runs workflows entirely in-process and is the recommended approach for most testing scenarios. However, there are cases where you may want to test against a running server:
- 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, 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:
import { defineConfig } from "vitest/config";
import { workflow } from "workflow/vite";
export default defineConfig({
plugins: [workflow()],
test: {
include: ["**/*.server.test.ts"],
testTimeout: 60_000,
globalSetup: "./vitest.server.setup.ts",
env: {
WORKFLOW_LOCAL_BASE_URL: "http://localhost:4000",
},
},
});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.
Global Setup Script
The globalSetup script starts a dev server before tests run and tears it down afterwards. This example uses Nitro, but you can use any server framework that supports the workflow runtime.
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() {
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}`;
emitSetupLog("server_ready", {
baseUrl: process.env.WORKFLOW_LOCAL_BASE_URL,
});
}
export async function teardown() {
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.
Writing Tests
Tests are written the same way as in-process integration tests. You can use the same programmatic APIs — start(), resumeHook(), resumeWebhook(), and getRun().wakeUp() — to control workflow execution:
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",
});
});
});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.
Running Tests
Add a script to your package.json:
{
"scripts": {
"test": "vitest",
"test:server": "vitest --config vitest.server.config.ts"
}
}When to Use This Approach
| Scenario | Recommended approach |
|---|---|
| Testing workflow logic, steps, hooks, retries | In-process plugin |
| Testing HTTP middleware or authentication | Server-based |
| Testing webhook endpoints with real HTTP | Server-based |
| CI/CD pipeline testing | In-process plugin |
| Reproducing framework-specific behavior | Server-based |