Testing Deno Fresh Routes, Islands and Middleware (2026)

Testing Deno Fresh Routes, Islands and Middleware (2026)

Deno Fresh is a full-stack framework built on Deno, Preact, and server-side rendering. It follows a "no build step" philosophy — components render on the server by default, and interactive parts (called Islands) hydrate on the client.

Testing Fresh apps covers three areas:

  1. Route handlers — the server functions that fetch data and handle requests
  2. Island components — interactive Preact components that run in the browser
  3. Middleware — functions that run before every request

Project Structure

A typical Fresh project:

.
├── routes/
│   ├── index.tsx          # page route
│   ├── posts/
│   │   ├── index.tsx      # /posts
│   │   └── [slug].tsx     # /posts/:slug
│   └── api/
│       └── posts.ts       # /api/posts (JSON handler)
├── islands/
│   ├── Counter.tsx        # interactive component
│   └── SearchBox.tsx      # interactive search
├── components/
│   └── PostCard.tsx       # static component
└── fresh.config.ts

Testing Route Handlers

Fresh routes export handler functions for API routes and default components for pages. Test handlers by calling them directly with a Request object.

API Route Handlers

// routes/api/posts.ts
import type { Handlers } from "$fresh/server.ts";
import { db } from "../../db.ts";

export const handler: Handlers = {
  async GET(req, ctx) {
    const url = new URL(req.url);
    const limit = Number(url.searchParams.get("limit") ?? "10");
    const offset = Number(url.searchParams.get("offset") ?? "0");

    const posts = await db.posts.findMany({
      take: Math.min(limit, 50),
      skip: offset,
      orderBy: { createdAt: "desc" },
    });

    return Response.json({ posts });
  },

  async POST(req, ctx) {
    const body = await req.json();

    if (!body.title?.trim()) {
      return Response.json({ error: "Title is required" }, { status: 422 });
    }

    const post = await db.posts.create({
      data: { title: body.title.trim(), content: body.content?.trim() ?? "" },
    });

    return Response.json({ post }, { status: 201 });
  },
};
// routes/api/posts_test.ts
import { assertEquals, assertObjectMatch } from "jsr:@std/assert";
import { stub } from "jsr:@std/mock";
import { handler } from "./posts.ts";
import * as dbModule from "../../db.ts";

Deno.test("GET /api/posts returns post list", async () => {
  const mockPosts = [{ id: 1, title: "Post 1" }, { id: 2, title: "Post 2" }];
  const dbStub = stub(dbModule.db.posts, "findMany", async () => mockPosts as any);

  try {
    const req = new Request("http://localhost/api/posts");
    const res = await handler.GET!(req, {} as any);

    assertEquals(res.status, 200);
    const data = await res.json();
    assertEquals(data.posts.length, 2);
  } finally {
    dbStub.restore();
  }
});

Deno.test("GET /api/posts respects limit parameter", async () => {
  const dbStub = stub(dbModule.db.posts, "findMany", async () => [] as any);

  try {
    const req = new Request("http://localhost/api/posts?limit=5");
    await handler.GET!(req, {} as any);

    // Verify the limit was passed to the query
    const callArgs = dbStub.calls[0].args[0];
    assertEquals(callArgs.take, 5);
  } finally {
    dbStub.restore();
  }
});

Deno.test("GET /api/posts caps limit at 50", async () => {
  const dbStub = stub(dbModule.db.posts, "findMany", async () => [] as any);

  try {
    const req = new Request("http://localhost/api/posts?limit=100");
    await handler.GET!(req, {} as any);

    assertEquals(dbStub.calls[0].args[0].take, 50);
  } finally {
    dbStub.restore();
  }
});

Deno.test("POST /api/posts returns 422 for missing title", async () => {
  const req = new Request("http://localhost/api/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content: "Content without title" }),
  });

  const res = await handler.POST!(req, {} as any);

  assertEquals(res.status, 422);
  const data = await res.json();
  assertEquals(data.error, "Title is required");
});

Deno.test("POST /api/posts creates post and returns 201", async () => {
  const createStub = stub(
    dbModule.db.posts,
    "create",
    async () => ({ id: 5, title: "New Post", content: "Content" }) as any
  );

  try {
    const req = new Request("http://localhost/api/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "New Post", content: "Content" }),
    });

    const res = await handler.POST!(req, {} as any);

    assertEquals(res.status, 201);
    const data = await res.json();
    assertEquals(data.post.title, "New Post");
  } finally {
    createStub.restore();
  }
});

Page Route Handlers

Fresh page routes can also export handler functions for SSR data fetching:

// routes/posts/[slug].tsx
import type { Handlers, PageProps } from "$fresh/server.ts";
import { db } from "../../db.ts";

interface Post {
  id: number;
  title: string;
  content: string;
  slug: string;
}

export const handler: Handlers<Post> = {
  async GET(req, ctx) {
    const post = await db.posts.findFirst({
      where: { slug: ctx.params.slug },
    });

    if (!post) {
      return ctx.renderNotFound();
    }

    return ctx.render(post);
  },
};
// routes/posts/[slug]_test.ts
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/mock";
import { handler } from "./[slug].tsx";
import * as dbModule from "../../db.ts";

Deno.test("returns 404 for unknown slug", async () => {
  const dbStub = stub(dbModule.db.posts, "findFirst", async () => null);

  try {
    const ctx = {
      params: { slug: "nonexistent-post" },
      renderNotFound: () => new Response("Not Found", { status: 404 }),
      render: (_: unknown) => new Response("OK"),
    };

    const req = new Request("http://localhost/posts/nonexistent-post");
    const res = await handler.GET!(req, ctx as any);

    assertEquals(res.status, 404);
  } finally {
    dbStub.restore();
  }
});

Testing Island Components

Islands are interactive Preact components. Test them with Preact Testing Library:

# Islands use Preact, not React
npm install -D @testing-library/preact @testing-library/jest-dom vitest jsdom
// islands/Counter.tsx
import { useState } from "preact/hooks";

interface CounterProps {
  initialCount?: number;
  step?: number;
}

export default function Counter({ initialCount = 0, step = 1 }: CounterProps) {
  const [count, setCount] = useState(initialCount);

  return (
    <div class="counter">
      <button onClick={() => setCount((c) => c - step)} aria-label="Decrement">
        −
      </button>
      <span data-testid="count">{count}</span>
      <button onClick={() => setCount((c) => c + step)} aria-label="Increment">
        +
      </button>
    </div>
  );
}
// islands/Counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/preact";
import { describe, it, expect } from "vitest";
import Counter from "./Counter.tsx";

describe("Counter island", () => {
  it("renders with initial count of 0", () => {
    render(<Counter />);
    expect(screen.getByTestId("count").textContent).toBe("0");
  });

  it("renders with custom initial count", () => {
    render(<Counter initialCount={10} />);
    expect(screen.getByTestId("count").textContent).toBe("10");
  });

  it("increments by 1 on click", async () => {
    render(<Counter />);
    await fireEvent.click(screen.getByLabelText("Increment"));
    expect(screen.getByTestId("count").textContent).toBe("1");
  });

  it("decrements by 1 on click", async () => {
    render(<Counter initialCount={5} />);
    await fireEvent.click(screen.getByLabelText("Decrement"));
    expect(screen.getByTestId("count").textContent).toBe("4");
  });

  it("uses custom step", async () => {
    render(<Counter step={5} />);
    await fireEvent.click(screen.getByLabelText("Increment"));
    expect(screen.getByTestId("count").textContent).toBe("5");
  });

  it("can go negative", async () => {
    render(<Counter />);
    await fireEvent.click(screen.getByLabelText("Decrement"));
    expect(screen.getByTestId("count").textContent).toBe("-1");
  });
});

Configure Vitest for Preact:

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./test/setup.ts"],
  },
  esbuild: {
    jsx: "transform",
    jsxFactory: "h",
    jsxFragment: "Fragment",
    jsxImportSource: "preact",
  },
});
// test/setup.ts
import "@testing-library/jest-dom";
import { h, Fragment } from "preact";

Testing Middleware

Fresh middleware processes every request. Test it by passing a mock Request and MiddlewareHandlerContext:

// routes/_middleware.ts
import type { MiddlewareHandlerContext } from "$fresh/server.ts";

export async function handler(
  req: Request,
  ctx: MiddlewareHandlerContext
): Promise<Response> {
  const start = Date.now();
  const res = await ctx.next();
  const elapsed = Date.now() - start;
  
  res.headers.set("X-Response-Time", `${elapsed}ms`);
  return res;
}
// routes/_middleware_test.ts
import { assertEquals } from "jsr:@std/assert";
import { handler } from "./_middleware.ts";

Deno.test("middleware adds X-Response-Time header", async () => {
  const req = new Request("http://localhost/");
  const ctx = {
    next: async () => new Response("OK", { headers: new Headers() }),
    params: {},
    state: {},
    destination: "route" as const,
    remoteAddr: { transport: "tcp" as const, hostname: "127.0.0.1", port: 0 },
    isPartial: false,
    render: async () => new Response(),
    renderNotFound: async () => new Response("Not Found", { status: 404 }),
    url: new URL(req.url),
    route: "/",
    component: undefined,
    pattern: "/",
  };

  const res = await handler(req, ctx as any);

  const responseTime = res.headers.get("X-Response-Time");
  assertEquals(responseTime !== null, true);
  assertEquals(responseTime?.endsWith("ms"), true);
});

E2E Testing with Playwright

For full browser testing of Fresh apps:

npx playwright install chromium
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  use: { baseURL: "http://localhost:8000" },
  webServer: {
    command: "deno task start",
    url: "http://localhost:8000",
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },
});
// e2e/counter.test.ts
import { test, expect } from "@playwright/test";

test("Counter island increments on click", async ({ page }) => {
  await page.goto("/");

  const count = page.getByTestId("count");
  await expect(count).toHaveText("0");

  await page.getByLabel("Increment").click();
  await expect(count).toHaveText("1");

  await page.getByLabel("Increment").click();
  await page.getByLabel("Increment").click();
  await expect(count).toHaveText("3");
});

test("Counter island decrements on click", async ({ page }) => {
  await page.goto("/");

  await page.getByLabel("Decrement").click();
  await expect(page.getByTestId("count")).toHaveText("-1");
});

What Tests Don't Cover in Fresh Apps

Fresh's server-first rendering means:

  • Some bugs only appear in the client-side hydration step
  • Island boundaries can cause hydration mismatches
  • Deno Deploy (Fresh's recommended hosting) has different network and compute limits

Test both the server-rendered output and the hydrated island behavior. Playwright handles both.

Production Monitoring with HelpMeTest

HelpMeTest monitors your Fresh app continuously after deployment:

Go to https://myapp.deno.dev
Verify the main heading is visible
Click the Increment button
Verify the count shows 1

When an island fails to hydrate, a route handler breaks, or middleware misconfiguration causes errors, HelpMeTest alerts you.

Free tier: 10 tests, 5-minute check intervals.
Pro: $100/month
— unlimited tests, 24/7 monitoring.


Start free at helpmetest.com — no credit card required.

Read more