---
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#integration-testing-with-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](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>

## Global Setup Script

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>

## Writing Tests

Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs — [`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) — to control workflow execution:

```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>

## Running Tests

Add a script to your `package.json`:

```json title="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](/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 |
