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:
- Handler testing — call the fetch function directly with synthetic
Requestobjects. Zero I/O, maximum speed. - Integration testing — start the server on a real (or random) port, use Bun's
fetchto 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