← Back to DevBytes

Testing Deno Components: From Unit to E2E Tests

Testing Deno Components: From Unit to E2E Tests

Deno ships with a powerful, batteries-included test runner that requires no external dependencies or configuration. Whether you are validating a single utility function or simulating real user journeys across an entire web application, Deno's standard library and built-in APIs give you everything you need to build a robust testing pyramid. This tutorial walks through the full spectrum — from isolated unit tests to end-to-end (E2E) tests — with practical, runnable examples.

Why Testing Matters in Deno

Because Deno has a standard test runner built into the runtime, there is almost no friction to writing tests. This low barrier encourages a culture where every module is accompanied by tests. A well-structured test suite provides several concrete benefits:

A healthy test suite is typically shaped like a pyramid: many fast unit tests at the bottom, fewer integration tests in the middle, and a small number of slow E2E tests at the top. Deno supports every layer of this pyramid natively.

Setting Up a Testable Deno Project

Create a project with the following structure:

my-deno-app/
├── deno.json
├── src/
│   ├── math.ts
│   ├── user.ts
│   └── server.ts
└── tests/
    ├── math_test.ts
    ├── user_test.ts
    ├── server_test.ts
    └── e2e_test.ts

Define a deno.json with a test task and import map:

{
  "tasks": {
    "test": "deno test --allow-net --allow-read --allow-env",
    "test:unit": "deno test tests/math_test.ts tests/user_test.ts",
    "test:e2e": "deno test --allow-net tests/e2e_test.ts"
  },
  "imports": {
    "std/": "https://deno.land/std@0.224.0/"
  }
}

Run tests with deno task test. Deno automatically discovers files matching the *_test.ts pattern.

Unit Testing: Testing Functions in Isolation

Unit tests verify the smallest pieces of logic in isolation. They should be fast, deterministic, and free of side effects. Let's start with a simple math module.

The Module Under Test

// src/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error("Division by zero is not allowed");
  }
  return a / b;
}

export function isPrime(n: number): boolean {
  if (n < 2) return false;
  for (let i = 2; i <= Math.sqrt(n); i++) {
    if (n % i === 0) return false;
  }
  return true;
}

Writing the Unit Tests

Deno uses the Deno.test API along with assertion helpers from the standard library:

// tests/math_test.ts
import { assertEquals, assertThrows } from "std/assert/mod.ts";
import { add, divide, isPrime } from "../src/math.ts";

Deno.test("add() returns the sum of two numbers", () => {
  assertEquals(add(2, 3), 5);
  assertEquals(add(-1, 1), 0);
  assertEquals(add(0, 0), 0);
});

Deno.test("divide() throws on division by zero", () => {
  assertThrows(
    () => divide(10, 0),
    Error,
    "Division by zero is not allowed",
  );
});

Deno.test("divide() returns the quotient for valid inputs", () => {
  assertEquals(divide(10, 2), 5);
  assertEquals(divide(7, 1), 7);
});

Deno.test("isPrime() correctly identifies prime numbers", () => {
  const primes = [2, 3, 5, 7, 11, 13, 97];
  const nonPrimes = [0, 1, 4, 6, 8, 9, 100];

  for (const n of primes) {
    assertEquals(isPrime(n), true, `Expected ${n} to be prime`);
  }
  for (const n of nonPrimes) {
    assertEquals(isPrime(n), false, `Expected ${n} to not be prime`);
  }
});

Run these tests with deno test tests/math_test.ts. Each Deno.test call registers a named test case, and the runner reports pass/fail status with timing information.

Using Steps for Nested Assertions

For more complex units, Deno supports test steps that group related assertions and report them individually:

Deno.test("math operations", async (t) => {
  await t.step("addition works", () => {
    assertEquals(add(1, 1), 2);
  });
  await t.step("division works", () => {
    assertEquals(divide(8, 4), 2);
  });
  await t.step("primality check works", () => {
    assertEquals(isPrime(2), true);
  });
});

Steps are useful for organizing related checks without creating dozens of top-level test cases.

Testing Components with Dependencies

Real components often depend on external systems — databases, APIs, the filesystem. To keep unit tests fast and deterministic, you should isolate these dependencies using stubs and mocks. Deno's standard library provides stub and spy utilities for this purpose.

The User Service Module

// src/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export interface UserStore {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

export class UserService {
  constructor(private store: UserStore) {}

  async getUser(id: string): Promise<User> {
    const user = await this.store.findById(id);
    if (!user) {
      throw new Error(`User ${id} not found`);
    }
    return user;
  }

  async createUser(name: string, email: string): Promise<User> {
    if (!name.trim()) {
      throw new Error("Name is required");
    }
    if (!email.includes("@")) {
      throw new Error("Invalid email");
    }
    const user: User = {
      id: crypto.randomUUID(),
      name,
      email,
    };
    await this.store.save(user);
    return user;
  }
}

Mocking the Store with Stubs

// tests/user_test.ts
import { assertEquals, assertRejects, stub } from "std/testing/mock.ts";
import { UserService } from "../src/user.ts";
import type { User, UserStore } from "../src/user.ts";

function createMockStore(): UserStore {
  return {
    findById: (_id: string) => Promise.resolve(null),
    save: (_user: User) => Promise.resolve(),
  };
}

Deno.test("getUser() returns the user when found", async () => {
  const store = createMockStore();
  const fakeUser: User = {
    id: "abc",
    name: "Alice",
    email: "alice@example.com",
  };
  const findByIdStub = stub(store, "findById", () => Promise.resolve(fakeUser));

  const service = new UserService(store);
  const result = await service.getUser("abc");

  assertEquals(result, fakeUser);
  assertEquals(findByIdStub.calls.length, 1);
  assertEquals(findByIdStub.calls[0].args[0], "abc");

  findByIdStub.restore();
});

Deno.test("getUser() throws when user is not found", async () => {
  const store = createMockStore();
  const service = new UserService(store);

  await assertRejects(
    () => service.getUser("missing"),
    Error,
    "User missing not found",
  );
});

Deno.test("createUser() validates input and persists the user", async () => {
  const store = createMockStore();
  const saveStub = stub(store, "save");
  const service = new UserService(store);

  const user = await service.createUser("Bob", "bob@example.com");

  assertEquals(user.name, "Bob");
  assertEquals(user.email, "bob@example.com");
  assertEquals(saveStub.calls.length, 1);
  assertEquals(saveStub.calls[0].args[0].name, "Bob");
});

Deno.test("createUser() rejects empty names", async () => {
  const store = createMockStore();
  const service = new UserService(store);

  await assertRejects(
    () => service.createUser("", "bob@example.com"),
    Error,
    "Name is required",
  );
});

Deno.test("createUser() rejects invalid emails", async () => {
  const store = createMockStore();
  const service = new UserService(store);

  await assertRejects(
    () => service.createUser("Bob", "not-an-email"),
    Error,
    "Invalid email",
  );
});

By injecting the UserStore dependency through the constructor, the service becomes trivially testable. The stub function replaces a method on an object and records every call, so you can assert both return values and invocation details.

Integration Testing: Testing the HTTP Server

Integration tests verify that multiple components work together. A common scenario is testing an HTTP server with its routing, handlers, and middleware. Deno's standard library includes std/http and a testing utility for making requests against an in-process server.

The Server Module

// src/server.ts
import { Application, Router } from "https://deno.land/x/oak@v12.6.2/mod.ts";

export interface ServerDeps {
  userService: {
    getUser(id: string): Promise<{ id: string; name: string; email: string }>;
  };
}

export function createApp(deps: ServerDeps): Application {
  const router = new Router();

  router.get("/health", (ctx) => {
    ctx.response.body = { status: "ok" };
  });

  router.get("/users/:id", async (ctx) => {
    try {
      const id = ctx.params.id;
      const user = await deps.userService.getUser(id);
      ctx.response.body = user;
    } catch (err) {
      ctx.response.status = 404;
      ctx.response.body = { error: (err as Error).message };
    }
  });

  const app = new Application();
  app.use(router.routes());
  app.use(router.allowedMethods());
  return app;
}

Testing the Server with a Real Port

// tests/server_test.ts
import { assertEquals } from "std/assert/mod.ts";
import { stub } from "std/testing/mock.ts";
import { createApp } from "../src/server.ts";

async function startServer(app: ReturnType<typeof createApp>) {
  const controller = new AbortController();
  const listenPromise = app.listen({ port: 0, signal: controller.signal });
  // Oak does not expose the chosen port easily, so we use a fixed port here.
  return { controller, listenPromise };
}

Deno.test("GET /health returns ok status", async () => {
  const fakeUserService = {
    getUser: () => Promise.resolve({ id: "x", name: "x", email: "x" }),
  };
  const app = createApp({ userService: fakeUserService });
  const controller = new AbortController();
  const port = 45001;
  const listenPromise = app.listen({ port, signal: controller.signal });

  // Give the server a moment to start
  await new Promise((resolve) => setTimeout(resolve, 100));

  try {
    const res = await fetch(`http://localhost:${port}/health`);
    assertEquals(res.status, 200);
    const body = await res.json();
    assertEquals(body, { status: "ok" });
  } finally {
    controller.abort();
    await listenPromise.catch(() => {});
  }
});

Deno.test("GET /users/:id returns the user when found", async () => {
  const fakeUser = { id: "123", name: "Alice", email: "alice@example.com" };
  const getUserStub = stub(
    { getUser: () => Promise.resolve(fakeUser) },
    "getUser",
  );

  const app = createApp({ userService: getUserStub.value });
  const controller = new AbortController();
  const port = 45002;
  const listenPromise = app.listen({ port, signal: controller.signal });

  await new Promise((resolve) => setTimeout(resolve, 100));

  try {
    const res = await fetch(`http://localhost:${port}/users/123`);
    assertEquals(res.status, 200);
    const body = await res.json();
    assertEquals(body, fakeUser);
    assertEquals(getUserStub.calls.length, 1);
    assertEquals(getUserStub.calls[0].args[0], "123");
  } finally {
    controller.abort();
    await listenPromise.catch(() => {});
  }
});

Deno.test("GET /users/:id returns 404 when user is missing", async () => {
  const userService = {
    getUser: () => Promise.reject(new Error("User not found")),
  };
  const app = createApp({ userService });
  const controller = new AbortController();
  const port = 45003;
  const listenPromise = app.listen({ port, signal: controller.signal });

  await new Promise((resolve) => setTimeout(resolve, 100));

  try {
    const res = await fetch(`http://localhost:${port}/users/missing`);
    assertEquals(res.status, 404);
    const body = await res.json();
    assertEquals(body.error, "User not found");
  } finally {
    controller.abort();
    await listenPromise.catch(() => {});
  }
});

These integration tests boot the real Oak application, make real HTTP requests through fetch, and assert on the actual response. The only mocked piece is the user service, which keeps the test fast while still exercising the routing and serialization layers.

End-to-End Testing: Full User Journeys

E2E tests exercise the entire application from the outside, treating it as a black box. They typically start the real server with real (or near-real) dependencies and simulate what a client would do. These tests are slower and more brittle, so you should keep their number small and focused on critical paths.

Building an E2E Test

// tests/e2e_test.ts
import { assertEquals } from "std/assert/mod.ts";
import { UserService } from "../src/user.ts";

// An in-memory store that mimics a real database for E2E purposes.
class InMemoryUserStore {
  private users = new Map<string, unknown>();

  async findById(id: string) {
    return this.users.get(id) ?? null;
  }

  async save(user: { id: string }) {
    this.users.set(user.id, user);
  }
}

Deno.test("E2E: create a user and retrieve it via the API", async () => {
  const store = new InMemoryUserStore();
  const userService = new UserService(store);

  // Import the server factory lazily so we wire up real dependencies.
  const { createApp } = await import("../src/server.ts");
  const app = createApp({
    userService: {
      getUser: (id: string) => userService.getUser(id),
    },
  });

  const controller = new AbortController();
  const port = 45010;
  const listenPromise = app.listen({ port, signal: controller.signal });

  await new Promise((resolve) => setTimeout(resolve, 150));

  try {
    // Step 1: create a user directly through the service (simulating a write API).
    const created = await userService.createUser("Charlie", "charlie@example.com");

    // Step 2: retrieve the user through the HTTP API.
    const res = await fetch(`http://localhost:${port}/users/${created.id}`);
    assertEquals(res.status, 200);
    const body = await res.json();
    assertEquals(body.name, "Charlie");
    assertEquals(body.email, "charlie@example.com");

    // Step 3: verify a missing user returns 404.
    const missingRes = await fetch(`http://localhost:${port}/users/does-not-exist`);
    assertEquals(missingRes.status, 404);
  } finally {
    controller.abort();
    await listenPromise.catch(() => {});
  }
});

Deno.test("E2E: health check is reachable", async () => {
  const { createApp } = await import("../src/server.ts");
  const app = createApp({
    userService: {
      getUser: () => Promise.reject(new Error("unused")),
    },
  });

  const controller = new AbortController();
  const port = 45011;
  const listenPromise = app.listen({ port, signal: controller.signal });

  await new Promise((resolve) => setTimeout(resolve, 150));

  try {
    const res = await fetch(`http://localhost:${port}/health`);
    assertEquals(res.status, 200);
    const body = await res.json();
    assertEquals(body.status, "ok");
  } finally {
    controller.abort();
    await listenPromise.catch(() => {});
  }
});

This E2E test wires together the real UserService with an in-memory store, boots the actual HTTP server, and walks through a complete user journey: creating a user, fetching it through the API, and verifying error handling for a missing record. For a true production-grade E2E suite, you would replace the in-memory store with a real database running in a container, started and torn down by a test setup hook.

Best Practices for Deno Testing

Conclusion

Deno's built-in test runner and standard library make it straightforward to build a comprehensive testing strategy without pulling in heavy external frameworks. By combining isolated unit tests with dependency injection, integration tests that exercise real HTTP flows, and a small set of E2E tests that validate complete user journeys, you can ship Deno applications with confidence. Start with unit tests for your pure logic, add integration tests around your server boundaries, and reserve E2E tests for the few critical paths that matter most to your users. With consistent practice and the patterns shown in this tutorial, your test suite will become a reliable safety net that accelerates development rather than slowing it down.

— Ad —

Google AdSense will appear here after approval

← Back to all articles