Testing Route Handlers
Integration tests for APIs
Unit tests test individual functions. Integration tests test the full request-response cycle: the client sends a request, the route handler processes it (validates input, queries the database, formats the response), and returns a response with the correct status code, headers, and body.
app.fetch(): no server needed
Hectoday HTTP’s setup() returns an app with a fetch method. This method accepts a Request and returns a Response — the same interface as a running server, but without starting one.
import { app } from "../../src/app.js";
test("GET /v2/books returns 200", async () => {
const request = new Request("http://localhost/v2/books");
const response = await app.fetch(request);
expect(response.status).toBe(200);
}); No server port. No network. No listen(). The test calls app.fetch() directly — fast and deterministic.
Parsing the response
test("GET /v2/books returns JSON array", async () => {
const response = await app.fetch(new Request("http://localhost/v2/books"));
const data = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(Array.isArray(data)).toBe(true);
}); Check the status code, headers, and body. Parse JSON with response.json(). Assert against the parsed data.
Building requests
For POST, PUT, PATCH — you need to build requests with bodies and headers:
function jsonRequest(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>,
): Request {
return new Request(`http://localhost${path}`, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
}
// Usage
const request = jsonRequest("POST", "/v2/books", {
title: "Kindred",
authorId: "550e8400-e29b-41d4-a716-446655440000",
genre: "science-fiction",
});
const response = await app.fetch(request); The http://localhost base URL is required by the Request constructor but is not actually contacted — app.fetch processes the request directly.
A complete integration test
import { describe, test, expect, beforeEach } from "vitest";
import { app } from "../../src/app.js";
import { testDb } from "../setup.js";
beforeEach(() => {
testDb.exec("DELETE FROM reviews; DELETE FROM books; DELETE FROM authors;");
testDb.prepare("INSERT INTO authors (id, name) VALUES (?, ?)").run("author-1", "Octavia Butler");
testDb
.prepare("INSERT INTO books (id, title, author_id, genre) VALUES (?, ?, ?, ?)")
.run("book-1", "Kindred", "author-1", "science-fiction");
});
describe("GET /v2/books", () => {
test("returns 200 with list of books", async () => {
const response = await app.fetch(new Request("http://localhost/v2/books"));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].title).toBe("Kindred");
expect(data[0].author.name).toBe("Octavia Butler");
});
test("returns empty array when no books", async () => {
testDb.exec("DELETE FROM books");
const response = await app.fetch(new Request("http://localhost/v2/books"));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(0);
});
}); Seed data → make request → assert response. The test verifies the entire pipeline: routing, query, transformation, response formatting.
Exercises
Exercise 1: Write an integration test for GET /v2/books. Seed 3 books. Verify all 3 appear in the response.
Exercise 2: Write an integration test for GET /v2/books/:id. Verify it returns the correct book with nested author.
Exercise 3: Create the jsonRequest helper. Use it to test a POST endpoint.
Why use app.fetch() instead of starting a real server for integration tests?