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:
- Route handlers — the server functions that fetch data and handle requests
- Island components — interactive Preact components that run in the browser
- 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.tsTesting 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 1When 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.