Bun HTTP Server Testing: Bun.serve Handlers, WebSocket Testing, and TLS

Bun HTTP Server Testing: Bun.serve Handlers, WebSocket Testing, and TLS

Bun.serve creates HTTP servers in a single function call. Testing them is equally direct: pass a Request object to the same handler function used by the server, or bind to a real port and use Bun's native fetch — no test superagent, no supertest, no http.createServer plumbing.

Key Takeaways

Test handlers directly without a running server. The fetch handler in Bun.serve is a plain async function — you can call it with a new Request(...) and assert the returned Response without binding to any port.

Real port testing requires explicit server.stop(). If you start a server on a real port in beforeAll, always call server.stop() in afterAll to release the port and prevent test suite interference.

WebSocket handlers use the same in-process pattern. Bun's WebSocket server API exposes open, message, close, and error hooks that you can invoke directly in tests without a real WebSocket connection.

Error handlers are just Response returns. A 404 or 500 response is still a Response object — assert response.status and the response body exactly as you would for a successful route.

Static file serving needs a real fetch. Testing Bun's built-in static file serving requires a real port because the static route resolution happens inside the Bun runtime, not in user code.

Testing Strategy for Bun.serve

Bun HTTP servers are defined around a single fetch(req: Request): Response | Promise<Response> function. This design is ideal for testing because the handler is not coupled to the server lifecycle — you can test it as a pure function.

There are two complementary approaches:

  1. Handler testing — call the fetch function directly with synthetic Request objects. Zero I/O, maximum speed.
  2. Integration testing — start the server on a real (or random) port, use Bun's fetch to hit it, then stop the server. Necessary for middleware stacks, TLS, WebSocket upgrades, and static files.

Defining a Testable Server

Structure your server so the handler is exported separately from the Bun.serve call:

// src/server.ts
import { Database } from "bun:sqlite";

interface AppContext {
  db: Database;
}

export function createHandler(ctx: AppContext) {
  return async function handler(req: Request): Promise<Response> {
    const url = new URL(req.url);

    if (url.pathname === "/health") {
      return Response.json({ status: "ok" });
    }

    if (url.pathname === "/users" && req.method === "GET") {
      const users = ctx.db
        .query("SELECT id, name, email FROM users")
        .all();
      return Response.json(users);
    }

    if (url.pathname === "/users" && req.method === "POST") {
      const body = await req.json() as { name: string; email: string };
      if (!body.name || !body.email) {
        return Response.json(
          { error: "name and email are required" },
          { status: 400 }
        );
      }
      const user = ctx.db
        .query("INSERT INTO users (name, email) VALUES (?, ?) RETURNING *")
        .get(body.name, body.email);
      return Response.json(user, { status: 201 });
    }

    return new Response("Not Found", { status: 404 });
  };
}

// Entry point — not exported, keeps the test surface clean
if (import.meta.main) {
  const db = new Database("./data.sqlite");
  Bun.serve({
    port: 3000,
    fetch: createHandler({ db }),
  });
}

Handler Testing (No Port Binding)

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { Database } from "bun:sqlite";
import { createHandler } from "./server";

describe("HTTP handler", () => {
  let db: Database;
  let handler: (req: Request) => Promise<Response>;

  beforeEach(() => {
    db = new Database(":memory:");
    db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT UNIQUE)");
    handler = createHandler({ db });
  });

  afterEach(() => db.close());

  test("GET /health returns 200 with status ok", async () => {
    const req = new Request("http://localhost/health");
    const res = await handler(req);

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body).toEqual({ status: "ok" });
  });

  test("GET /users returns empty array when no users exist", async () => {
    const req = new Request("http://localhost/users");
    const res = await handler(req);

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body).toEqual([]);
  });

  test("POST /users creates a user and returns 201", async () => {
    const req = new Request("http://localhost/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Alice", email: "alice@example.com" }),
    });
    const res = await handler(req);

    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body).toMatchObject({ name: "Alice", email: "alice@example.com" });
    expect(body.id).toBeGreaterThan(0);
  });

  test("POST /users returns 400 when body is missing fields", async () => {
    const req = new Request("http://localhost/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Alice" }),
    });
    const res = await handler(req);

    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toBe("name and email are required");
  });

  test("unknown route returns 404", async () => {
    const req = new Request("http://localhost/unknown-path");
    const res = await handler(req);

    expect(res.status).toBe(404);
  });
});

Integration Testing with Real Port Binding

For scenarios that require the full Bun.serve stack — middleware, request upgrade headers, static file routes — start a real server:

import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { Database } from "bun:sqlite";
import { createHandler } from "./server";
import type { Server } from "bun";

describe("Integration: HTTP server on real port", () => {
  let db: Database;
  let server: Server;
  let baseUrl: string;

  beforeAll(() => {
    db = new Database(":memory:");
    db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT UNIQUE)");
    db.run("INSERT INTO users (name, email) VALUES ('Seed User', 'seed@example.com')");

    server = Bun.serve({
      port: 0, // OS assigns a free port
      fetch: createHandler({ db }),
    });

    baseUrl = `http://localhost:${server.port}`;
  });

  afterAll(() => {
    server.stop();
    db.close();
  });

  test("GET /users returns seeded user", async () => {
    const res = await fetch(`${baseUrl}/users`);
    const users = await res.json();

    expect(res.status).toBe(200);
    expect(users).toHaveLength(1);
    expect(users[0]).toMatchObject({ name: "Seed User", email: "seed@example.com" });
  });

  test("full request lifecycle: create then retrieve", async () => {
    // Create
    const createRes = await fetch(`${baseUrl}/users`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Bob", email: "bob@example.com" }),
    });
    expect(createRes.status).toBe(201);
    const created = await createRes.json();

    // Retrieve all
    const listRes = await fetch(`${baseUrl}/users`);
    const users = await listRes.json();
    expect(users.some((u: { id: number }) => u.id === created.id)).toBe(true);
  });
});

Testing Error Handlers

test("malformed JSON returns 400", async () => {
  const req = new Request("http://localhost/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: "{ not valid json",
  });

  // The handler should catch the JSON parse error
  const res = await handler(req);
  expect(res.status).toBe(400);
});

test("unhandled exception returns 500", async () => {
  // Force a database error by closing the db before the handler runs
  db.close();

  const req = new Request("http://localhost/users");
  const res = await handler(req);
  expect(res.status).toBe(500);
});

WebSocket Server Testing

Bun's WebSocket API exposes lifecycle hooks (open, message, close, error) as plain functions. Test them directly without establishing a real WebSocket connection:

// src/wsServer.ts
import type { ServerWebSocket } from "bun";

interface WsData {
  userId: string;
}

export const wsHandlers = {
  open(ws: ServerWebSocket<WsData>) {
    ws.subscribe(`user:${ws.data.userId}`);
    ws.send(JSON.stringify({ type: "connected", userId: ws.data.userId }));
  },

  message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
    const data = JSON.parse(typeof message === "string" ? message : message.toString());
    if (data.type === "ping") {
      ws.send(JSON.stringify({ type: "pong" }));
    }
  },

  close(ws: ServerWebSocket<WsData>) {
    ws.unsubscribe(`user:${ws.data.userId}`);
  },
};
import { test, expect, mock } from "bun:test";
import { wsHandlers } from "./wsServer";
import type { ServerWebSocket } from "bun";

function createMockWs(userId: string): ServerWebSocket<{ userId: string }> {
  return {
    data: { userId },
    send: mock(() => {}),
    subscribe: mock(() => {}),
    unsubscribe: mock(() => {}),
    close: mock(() => {}),
    publish: mock(() => {}),
    readyState: 1,
  } as unknown as ServerWebSocket<{ userId: string }>;
}

test("open sends connected message and subscribes", () => {
  const ws = createMockWs("user-123");
  wsHandlers.open(ws);

  expect(ws.subscribe).toHaveBeenCalledWith("user:user-123");
  expect(ws.send).toHaveBeenCalledWith(
    JSON.stringify({ type: "connected", userId: "user-123" })
  );
});

test("message responds to ping with pong", () => {
  const ws = createMockWs("user-123");
  wsHandlers.message(ws, JSON.stringify({ type: "ping" }));

  expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: "pong" }));
});

test("close unsubscribes the user channel", () => {
  const ws = createMockWs("user-123");
  wsHandlers.close(ws);

  expect(ws.unsubscribe).toHaveBeenCalledWith("user:user-123");
});

Testing Static File Serving

Static file serving requires a real server because the resolution happens inside Bun's runtime:

import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import type { Server } from "bun";
import { mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";

describe("static file serving", () => {
  let server: Server;
  const publicDir = join(import.meta.dir, "test-public");

  beforeAll(() => {
    mkdirSync(publicDir, { recursive: true });
    writeFileSync(join(publicDir, "hello.txt"), "Hello, static world!");

    server = Bun.serve({
      port: 0,
      fetch(req) {
        const url = new URL(req.url);
        const file = Bun.file(join(publicDir, url.pathname));
        if (file.size > 0) return new Response(file);
        return new Response("Not Found", { status: 404 });
      },
    });
  });

  afterAll(() => {
    server.stop();
    rmSync(publicDir, { recursive: true });
  });

  test("serves existing static file", async () => {
    const res = await fetch(`http://localhost:${server.port}/hello.txt`);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("Hello, static world!");
  });

  test("returns 404 for missing file", async () => {
    const res = await fetch(`http://localhost:${server.port}/missing.txt`);
    expect(res.status).toBe(404);
  });
});

What to Test vs. What to Skip

Test:

  • Every route's happy path — method, path, expected status, and response shape
  • Validation errors — missing fields, wrong types, oversized payloads
  • Error propagation — what happens when a downstream call (DB, external API) fails
  • WebSocket lifecycle hooks — open, message, and close are pure functions once the mock socket is set up
  • Authentication middleware — pass a valid token, an expired token, and no token; assert status codes

Skip:

  • Bun.serve configuration validity — if the server starts, the config is valid; no need to unit test config objects
  • TLS certificate validation — test that your server accepts HTTPS connections in a staging environment; don't try to generate self-signed certs in unit tests
  • Load and concurrency — stress testing belongs in a separate benchmark suite, not in bun test
  • HTTP header parsing edge cases — these are Bun internals, not your code

Read more