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.
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";
export async function setup() {
console.log("Starting server for workflow execution...");
server = spawn("npx", ["nitro", "dev", "--port", PORT], {
stdio: "pipe",
detached: false,
env: process.env,
});
// Wait for the server to be ready
const ready = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), 15_000);
server?.stdout?.on("data", (data) => {
const output = data.toString();
console.log("[server]", output);
if (output.includes("listening") || output.includes("ready")) {
clearTimeout(timeout);
resolve(true);
}
});
server?.stderr?.on("data", (data) => {
console.error("[server]", data.toString());
});
server?.on("error", (error) => {
console.error("Failed to start server:", error);
clearTimeout(timeout);
resolve(false);
});
});
if (!ready) {
throw new Error("Server failed to start within 15 seconds");
}
await delay(2_000); // Allow full initialization
// Point the workflow runtime at the local server
process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${PORT}`;
console.log("Server ready for workflow execution");
}
export async function teardown() {
if (server) {
console.log("Stopping server...");
server.kill("SIGTERM");
await delay(1_000);
if (!server.killed) {
server.kill("SIGKILL");
}
}
}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:
import { describe, it, expect } from "vitest";
import { start } from "workflow/api";
import { calculateWorkflow } from "./calculate";
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,
});
});
});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 |