Bun + Elysia.js Testing: REST API, Plugins, and Eden Treaty Type-Safe Client
Elysia.js exposes app.handle(request) — a single method that processes any Request object through the full middleware stack and returns a Response. Combined with Eden Treaty, Elysia's type-safe fetch client, you get end-to-end type checking from route definition all the way through the test assertion.
Key Takeaways
app.handle() runs the full Elysia stack without a server. No port binding, no network I/O — pass a Request, get a Response. All middleware, guards, and lifecycle hooks run exactly as in production.
Eden Treaty gives you typed requests in tests. treaty(app) returns a client where every method is inferred from the route definitions — a type error in a test call means the API contract changed.
Plugin isolation is possible by composing a minimal app. Test a plugin by creating a bare new Elysia().use(myPlugin) instance — no need to bring in the entire application.
Elysia's validation errors are structured JSON. A schema violation returns a 422 with a type, at, and message body — assert the exact shape to catch validation regressions.
WebSocket routes use app.handle() for the upgrade request. A 101 Switching Protocols response confirms the upgrade path works; test message handling separately via mock ws objects.
Why Elysia.js for Bun
Elysia.js is a TypeScript-first HTTP framework built exclusively for Bun. It uses the TypeBox schema library for request/response validation and exposes a compile-time type system that propagates route types to the Eden Treaty client. This means type errors in the API surface are caught at test authoring time, not at runtime.
Application Structure for Testing
Keep route definitions in composable plugins, and expose the app instance for testing:
// src/plugins/users.ts
import Elysia, { t } from "elysia";
import { Database } from "bun:sqlite";
interface User {
id: number;
name: string;
email: string;
}
export const usersPlugin = (db: Database) =>
new Elysia({ prefix: "/users" })
.get("/", () => {
return db.query<User>("SELECT * FROM users ORDER BY id").all();
})
.get("/:id", ({ params, error }) => {
const user = db
.query<User>("SELECT * FROM users WHERE id = ?")
.get(Number(params.id));
if (!user) return error(404, { message: "User not found" });
return user;
})
.post(
"/",
({ body }) => {
const user = db
.query<User>(
"INSERT INTO users (name, email) VALUES (?, ?) RETURNING *"
)
.get(body.name, body.email);
return user;
},
{
body: t.Object({
name: t.String({ minLength: 1 }),
email: t.String({ format: "email" }),
}),
response: {
200: t.Object({
id: t.Number(),
name: t.String(),
email: t.String(),
}),
},
}
)
.delete("/:id", ({ params }) => {
const result = db.run("DELETE FROM users WHERE id = ?", [
Number(params.id),
]);
return { deleted: result.changes > 0 };
});// src/app.ts
import Elysia from "elysia";
import { Database } from "bun:sqlite";
import { usersPlugin } from "./plugins/users";
export function createApp(db: Database) {
return new Elysia()
.use(usersPlugin(db))
.get("/health", () => ({ status: "ok" }));
}Testing with app.handle()
app.handle() accepts a Request and returns a Promise<Response>. It runs through the complete Elysia lifecycle — guards, middleware, validation, route handlers, and error handlers.
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { Database } from "bun:sqlite";
import Elysia from "elysia";
import { createApp } from "./app";
describe("Users API — app.handle()", () => {
let db: Database;
let app: ReturnType<typeof createApp>;
beforeEach(() => {
db = new Database(":memory:");
db.run(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE)"
);
app = createApp(db);
});
afterEach(() => db.close());
test("GET /health returns 200", async () => {
const res = await app.handle(new Request("http://localhost/health"));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "ok" });
});
test("GET /users returns empty array initially", async () => {
const res = await app.handle(new Request("http://localhost/users"));
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
test("POST /users creates a user", async () => {
const res = await app.handle(
new Request("http://localhost/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice", email: "alice@example.com" }),
})
);
expect(res.status).toBe(200);
const user = await res.json();
expect(user).toMatchObject({ name: "Alice", email: "alice@example.com" });
expect(user.id).toBeGreaterThan(0);
});
test("GET /users/:id returns the correct user", async () => {
db.run(
"INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com')"
);
const userId = (
db.query<{ id: number }>("SELECT last_insert_rowid() as id").get(1) as { id: number }
).id;
const res = await app.handle(
new Request(`http://localhost/users/${userId}`)
);
expect(res.status).toBe(200);
const user = await res.json();
expect(user.name).toBe("Bob");
});
test("GET /users/:id returns 404 for missing user", async () => {
const res = await app.handle(
new Request("http://localhost/users/9999")
);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.message).toBe("User not found");
});
test("DELETE /users/:id deletes the user", async () => {
db.run(
"INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com')"
);
const res = await app.handle(
new Request("http://localhost/users/1", { method: "DELETE" })
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ deleted: true });
const missing = await app.handle(new Request("http://localhost/users/1"));
expect(missing.status).toBe(404);
});
});Testing Validation Errors
Elysia validates request bodies against TypeBox schemas and returns a 422 Unprocessable Entity with a structured error body when validation fails:
test("POST /users returns 422 for missing name", async () => {
const res = await app.handle(
new Request("http://localhost/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@example.com" }),
})
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.type).toBe("validation");
expect(body.at).toContain("name");
});
test("POST /users returns 422 for invalid email format", async () => {
const res = await app.handle(
new Request("http://localhost/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice", email: "not-an-email" }),
})
);
expect(res.status).toBe(422);
});Testing with Eden Treaty
Eden Treaty generates a typed HTTP client from the Elysia app instance. Using it in tests gives you compile-time safety — if a route is renamed or its schema changes, the test call will produce a TypeScript error before the test even runs.
Install Eden:
bun add elysia @elysiajs/edenimport { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { Database } from "bun:sqlite";
import { treaty } from "@elysiajs/eden";
import { createApp } from "./app";
describe("Users API — Eden Treaty", () => {
let db: Database;
let client: ReturnType<typeof treaty<ReturnType<typeof createApp>>>;
beforeEach(() => {
db = new Database(":memory:");
db.run(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE)"
);
const app = createApp(db);
// Pass the app directly — no URL needed for in-process testing
client = treaty(app);
});
afterEach(() => db.close());
test("GET /users returns typed array", async () => {
const { data, error, status } = await client.users.get();
expect(status).toBe(200);
expect(error).toBeNull();
expect(data).toEqual([]);
});
test("POST /users creates user with typed response", async () => {
const { data, status } = await client.users.post({
name: "Alice",
email: "alice@example.com",
});
expect(status).toBe(200);
// `data` is typed as { id: number; name: string; email: string }
expect(data?.name).toBe("Alice");
expect(data?.id).toBeGreaterThan(0);
});
test("GET /users/:id returns typed user", async () => {
db.run(
"INSERT INTO users (id, name, email) VALUES (42, 'Carol', 'carol@example.com')"
);
const { data, status } = await client.users({ id: "42" }).get();
expect(status).toBe(200);
expect(data?.name).toBe("Carol");
});
});Testing Elysia Plugins in Isolation
Isolate plugin tests by mounting only the plugin under test on a bare Elysia instance:
// src/plugins/auth.ts
import Elysia, { t } from "elysia";
export const authPlugin = new Elysia({ name: "auth" })
.derive(({ headers }) => {
const token = headers.authorization?.replace("Bearer ", "");
return { token };
})
.macro(({ onBeforeHandle }) => ({
requireAuth: (value: boolean) => {
if (!value) return;
onBeforeHandle(({ token, error }) => {
if (!token || token !== "secret-token") {
return error(401, { message: "Unauthorized" });
}
});
},
}));import { test, expect, beforeEach } from "bun:test";
import Elysia from "elysia";
import { authPlugin } from "./auth";
describe("authPlugin", () => {
let app: Elysia;
beforeEach(() => {
app = new Elysia()
.use(authPlugin)
.get("/protected", () => ({ ok: true }), { requireAuth: true })
.get("/public", () => ({ ok: true }));
});
test("protected route allows valid token", async () => {
const res = await app.handle(
new Request("http://localhost/protected", {
headers: { Authorization: "Bearer secret-token" },
})
);
expect(res.status).toBe(200);
});
test("protected route rejects missing token", async () => {
const res = await app.handle(
new Request("http://localhost/protected")
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toBe("Unauthorized");
});
test("protected route rejects wrong token", async () => {
const res = await app.handle(
new Request("http://localhost/protected", {
headers: { Authorization: "Bearer wrong-token" },
})
);
expect(res.status).toBe(401);
});
test("public route works without token", async () => {
const res = await app.handle(new Request("http://localhost/public"));
expect(res.status).toBe(200);
});
});Testing WebSocket Routes
Elysia's WebSocket upgrade goes through app.handle(). A 101 Switching Protocols response confirms the upgrade negotiation works:
// src/plugins/chat.ts
import Elysia, { t } from "elysia";
export const chatPlugin = new Elysia()
.ws("/ws/chat", {
body: t.Object({ type: t.String(), message: t.String() }),
open(ws) {
ws.subscribe("chat");
ws.send({ type: "connected" });
},
message(ws, body) {
if (body.type === "message") {
ws.publish("chat", { type: "broadcast", message: body.message });
}
},
close(ws) {
ws.unsubscribe("chat");
},
});import { test, expect } from "bun:test";
import Elysia from "elysia";
import { chatPlugin } from "./chat";
test("WebSocket upgrade returns 101", async () => {
const app = new Elysia().use(chatPlugin);
const res = await app.handle(
new Request("http://localhost/ws/chat", {
headers: {
Upgrade: "websocket",
Connection: "Upgrade",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version": "13",
},
})
);
expect(res.status).toBe(101);
});For message handling logic, test the handlers directly using mock WebSocket objects — same pattern as testing Bun.serve WebSocket hooks.
Testing Middleware Ordering
When multiple plugins add lifecycle hooks, test that they run in the correct order:
import { test, expect } from "bun:test";
import Elysia from "elysia";
test("middleware hooks run in order", async () => {
const order: string[] = [];
const app = new Elysia()
.onRequest(() => { order.push("request"); })
.onBeforeHandle(() => { order.push("beforeHandle"); })
.onAfterHandle(() => { order.push("afterHandle"); })
.get("/", () => {
order.push("handler");
return "ok";
});
await app.handle(new Request("http://localhost/"));
expect(order).toEqual(["request", "beforeHandle", "handler", "afterHandle"]);
});What to Test vs. What to Skip
Test:
- Every route's happy path via
app.handle()— at minimum: correct status code and response shape - Validation error responses — 422 with the correct
atfield for each invalid input case - Auth plugin behavior — valid token, missing token, expired token, wrong scope
- Plugin isolation — test each plugin independently before testing the composed app
- Eden Treaty type compatibility — a failing type check in a treaty call is a broken API contract
- Middleware ordering — when hooks interact, assert the order is stable
Skip:
- Elysia framework internals — don't test that TypeBox validation works; test that your schemas reject the inputs you expect them to reject
- Every possible HTTP status code — test the ones your code generates, not the ones Elysia generates on your behalf
- Eden Treaty itself — it's a library; test your routes, not that the treaty client correctly serializes a GET request
- WebSocket message framing — test your message handler logic; don't test that the WebSocket protocol frames binary messages correctly
- Response header correctness — unless your code sets custom headers, the default Content-Type headers are framework behavior, not your responsibility to assert