← Back to DevBytes

Testing Elysia Components: From Unit to E2E Tests

Testing Elysia Components: From Unit to E2E Tests

Elysia is a fast and ergonomic web framework built on top of Bun, designed for type safety and developer experience. Like any modern framework, it shines brightest when paired with a robust testing strategy. In this tutorial, you'll learn how to test Elysia components at every layer — from isolated unit tests to full end-to-end (E2E) flows — using Bun's built-in test runner and Elysia's native testing utilities.

Why Testing Elysia Matters

Testing is what separates a prototype from a production-grade application. Elysia's tight integration with TypeScript and Bun makes it tempting to rely solely on type checks, but types alone cannot catch runtime bugs, broken business logic, or integration failures. A layered testing strategy gives you:

Setting Up the Project

Start by creating a new Elysia project with Bun. If you don't have Bun installed, install it from bun.sh. Then scaffold a project:

bun create elysia my-app
cd my-app
bun install

Bun ships with a built-in test runner, so you don't need Jest or Vitest. Create a tests folder to organize your test files:

mkdir tests

Your package.json should include a test script:

{
  "scripts": {
    "test": "bun test",
    "test:watch": "bun test --watch"
  }
}

Building a Sample Elysia Application

To make the tutorial practical, let's build a small task management API. Create src/index.ts:

import { Elysia } from "elysia";
import { taskRoutes } from "./routes/tasks";

const app = new Elysia()
  .use(taskRoutes)
  .get("/health", () => ({ status: "ok" }));

export type App = typeof app;
export default app;

Now create src/routes/tasks.ts with a few endpoints and a service layer:

import { Elysia, t } from "elysia";
import { TaskService } from "../services/taskService";

export const taskRoutes = (service = new TaskService()) =>
  new Elysia({ prefix: "/tasks" })
    .get("/", () => service.getAll())
    .get("/:id", ({ params, set }) => {
      const task = service.getById(params.id);
      if (!task) {
        set.status = 404;
        return { error: "Task not found" };
      }
      return task;
    })
    .post("/", ({ body, set }) => {
      const task = service.create(body.title);
      set.status = 201;
      return task;
    }, {
      body: t.Object({
        title: t.String({ minLength: 1 })
      })
    })
    .delete("/:id", ({ params, set }) => {
      const deleted = service.delete(params.id);
      if (!deleted) {
        set.status = 404;
        return { error: "Task not found" };
      }
      set.status = 204;
      return null;
    });

And the service layer in src/services/taskService.ts:

export interface Task {
  id: string;
  title: string;
  done: boolean;
}

export class TaskService {
  private tasks = new Map<string, Task>();

  getAll(): Task[] {
    return Array.from(this.tasks.values());
  }

  getById(id: string): Task | undefined {
    return this.tasks.get(id);
  }

  create(title: string): Task {
    const id = crypto.randomUUID();
    const task: Task = { id, title, done: false };
    this.tasks.set(id, task);
    return task;
  }

  delete(id: string): boolean {
    return this.tasks.delete(id);
  }
}

Notice that taskRoutes accepts an optional service argument. This dependency injection pattern is the key to writing clean unit tests.

Unit Testing the Service Layer

Unit tests verify individual functions or classes in isolation. The TaskService is a perfect candidate because it contains pure business logic with no HTTP concerns. Create tests/taskService.test.ts:

import { test, expect, beforeEach } from "bun:test";
import { TaskService } from "../src/services/taskService";

let service: TaskService;

beforeEach(() => {
  service = new TaskService();
});

test("create adds a task and returns it", () => {
  const task = service.create("Write tests");

  expect(task.id).toBeDefined();
  expect(task.title).toBe("Write tests");
  expect(task.done).toBe(false);
});

test("getAll returns all created tasks", () => {
  service.create("Task 1");
  service.create("Task 2");

  expect(service.getAll()).toHaveLength(2);
});

test("getById returns the correct task", () => {
  const created = service.create("Find me");
  const found = service.getById(created.id);

  expect(found).toEqual(created);
});

test("getById returns undefined for missing id", () => {
  expect(service.getById("nonexistent")).toBeUndefined();
});

test("delete removes a task and returns true", () => {
  const task = service.create("Delete me");
  const result = service.delete(task.id);

  expect(result).toBe(true);
  expect(service.getById(task.id)).toBeUndefined();
});

test("delete returns false for missing id", () => {
  expect(service.delete("missing")).toBe(false);
});

Run the tests with bun test. Because each test gets a fresh TaskService instance via beforeEach, there is no shared state between tests, making them deterministic and independent.

Unit Testing Route Handlers

Route handlers can be unit tested by injecting a mock or fake service. Since taskRoutes accepts a service argument, you can pass a stubbed version. Create tests/routes.test.ts:

import { test, expect, mock } from "bun:test";
import { taskRoutes } from "../src/routes/tasks";

const fakeService = {
  getAll: mock(() => [{ id: "1", title: "Stubbed", done: false }]),
  getById: mock((id: string) =>
    id === "1" ? { id: "1", title: "Stubbed", done: false } : undefined
  ),
  create: mock((title: string) => ({ id: "2", title, done: false })),
  delete: mock((id: string) => id === "1")
};

const app = taskRoutes(fakeService as any);

test("GET /tasks returns all tasks", async () => {
  const res = await app.handle(new Request("http://localhost/tasks"));
  const body = await res.json();

  expect(res.status).toBe(200);
  expect(body).toHaveLength(1);
  expect(fakeService.getAll).toHaveBeenCalledTimes(1);
});

test("GET /tasks/:id returns 404 for missing task", async () => {
  const res = await app.handle(new Request("http://localhost/tasks/999"));
  const body = await res.json();

  expect(res.status).toBe(404);
  expect(body.error).toBe("Task not found");
});

test("POST /tasks creates a task", async () => {
  const res = await app.handle(
    new Request("http://localhost/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "New task" })
    })
  );
  const body = await res.json();

  expect(res.status).toBe(201);
  expect(body.title).toBe("New task");
  expect(fakeService.create).toHaveBeenCalledWith("New task");
});

test("POST /tasks rejects empty title", async () => {
  const res = await app.handle(
    new Request("http://localhost/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "" })
    })
  );

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

The app.handle() method is Elysia's way of processing a Request object without starting a real server. This is perfect for unit testing because it avoids network overhead while still exercising the full request lifecycle, including validation.

Integration Testing with Eden Treaty

While app.handle() works well, Elysia offers a more powerful tool: Eden Treaty. Eden generates a fully typed client from your Elysia app's type, so your tests can call endpoints as if they were local functions. If the server contract changes, the test code won't compile.

Install Eden:

bun add @elysiajs/eden

Create tests/integration.test.ts:

import { test, expect, beforeEach } from "bun:test";
import { treaty } from "@elysiajs/eden";
import { taskRoutes } from "../src/routes/tasks";
import { TaskService } from "../src/services/taskService";
import type { App } from "../src/index";

let service: TaskService;
let app: ReturnType<typeof taskRoutes>;
let api: ReturnType<typeof treaty<App>>;

beforeEach(() => {
  service = new TaskService();
  app = taskRoutes(service);
  api = treaty(app);
});

test("create and retrieve a task", async () => {
  const { data: created, error: createError } = await api.tasks.post({
    title: "Integration test"
  });

  expect(createError).toBeNull();
  expect(created?.title).toBe("Integration test");

  const { data: found } = await api.tasks[":id"].get({
    params: { id: created!.id }
  });

  expect(found?.title).toBe("Integration test");
});

test("returns 404 for missing task", async () => {
  const { error, status } = await api.tasks[":id"].get({
    params: { id: "missing" }
  });

  expect(status).toBe(404);
  expect(error?.value).toBe("Task not found");
});

test("lists all tasks", async () => {
  await api.tasks.post({ title: "One" });
  await api.tasks.post({ title: "Two" });

  const { data } = await api.tasks.get();

  expect(data).toHaveLength(2);
});

Eden Treaty returns a structured response with data, error, and status fields. This makes assertions clean and readable. Because the client is typed, you get autocomplete for route paths, parameters, and body shapes directly in your test file.

End-to-End Testing

End-to-end tests verify the entire application stack — including middleware, plugins, error handlers, and the real HTTP server. For E2E tests, you start the actual server and make real HTTP requests.

Create tests/e2e.test.ts:

import { test, expect, afterAll, beforeAll } from "bun:test";
import app from "../src/index";

const port = 3001;
const baseUrl = `http://localhost:${port}`;
let server: ReturnType<typeof app.listen>;

beforeAll(() => {
  server = app.listen(port);
});

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

test("health endpoint returns ok", async () => {
  const res = await fetch(`${baseUrl}/health`);
  const body = await res.json();

  expect(res.status).toBe(200);
  expect(body.status).toBe("ok");
});

test("full task lifecycle", async () => {
  // Create
  const createRes = await fetch(`${baseUrl}/tasks`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: "E2E task" })
  });
  const created = await createRes.json();

  expect(createRes.status).toBe(201);
  expect(created.id).toBeDefined();

  // Read
  const getRes = await fetch(`${baseUrl}/tasks/${created.id}`);
  const fetched = await getRes.json();

  expect(getRes.status).toBe(200);
  expect(fetched.title).toBe("E2E task");

  // Delete
  const deleteRes = await fetch(`${baseUrl}/tasks/${created.id}`, {
    method: "DELETE"
  });

  expect(deleteRes.status).toBe(204);

  // Verify deletion
  const verifyRes = await fetch(`${baseUrl}/tasks/${created.id}`);
  expect(verifyRes.status).toBe(404);
});

test("invalid body returns validation error", async () => {
  const res = await fetch(`${baseUrl}/tasks`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ wrongField: "oops" })
  });

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

E2E tests are slower than unit or integration tests because they spin up a real server and go through the network stack. Keep them focused on critical user flows rather than every edge case — those belong in unit tests.

Testing Middleware and Plugins

Elysia plugins and middleware can be tested in isolation too. Suppose you have an authentication plugin in src/plugins/auth.ts:

import { Elysia } from "elysia";

export const authPlugin = new Elysia()
  .derive(({ headers }) => {
    const token = headers.authorization?.replace("Bearer ", "");
    const authenticated = token === "secret-token";
    return { user: authenticated ? { id: "1", name: "Admin" } : null };
  })
  .guard(({ user, set }) => {
    if (!user) {
      set.status = 401;
      return { error: "Unauthorized" };
    }
  });

Test it directly:

import { test, expect } from "bun:test";
import { authPlugin } from "../src/plugins/auth";

const protectedApp = new Elysia()
  .use(authPlugin)
  .get("/me", ({ user }) => user);

test("allows access with valid token", async () => {
  const res = await protectedApp.handle(
    new Request("http://localhost/me", {
      headers: { authorization: "Bearer secret-token" }
    })
  );
  const body = await res.json();

  expect(res.status).toBe(200);
  expect(body.name).toBe("Admin");
});

test("blocks access without token", async () => {
  const res = await protectedApp.handle(
    new Request("http://localhost/me")
  );

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

test("blocks access with invalid token", async () => {
  const res = await protectedApp.handle(
    new Request("http://localhost/me", {
      headers: { authorization: "Bearer wrong" }
    })
  );

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

Best Practices

Conclusion

Testing Elysia applications is straightforward thanks to Bun's native test runner, Elysia's handle() method, and the type-safe Eden Treaty client. By layering your tests — unit tests for business logic, integration tests for route handlers, and E2E tests for full user flows — you build a safety net that catches bugs early and gives you confidence to ship. Start with unit tests for your service layer, add Eden-based integration tests for your routes, and reserve E2E tests for the most critical paths. With this strategy in place, your Elysia application will be as reliable in production as it is fast in development.

— Ad —

Google AdSense will appear here after approval

← Back to all articles