Hectoday
Docs
Web fundamentals with @hectoday/http

Testing APIs from First Principles

A guide for developers who know they should write tests but aren't sure what to test or how. Every example uses @hectoday/http with Vitest, but the ideas apply to any server and any test runner.

What you're actually testing

An API is a function. A request goes in, a response comes out. Testing means: send a request, check the response.

const response = await app.request("/health");
expect(response.status).toBe(200);

That's a test. You made a request. You checked the response. Everything between the request and the response — routing, validation, auth, database calls, business logic, hooks — ran exactly as it would in production.

You're not testing the framework. You're not testing Zod. You're not testing your database driver. You're testing your application: does it do the right thing when it receives this request?

The testing interface

Hectoday apps have a request method that builds a Request, runs it through the full lifecycle, and returns a Response. No server starts. No port is opened. No TCP involved.

// GET
const res = await app.request("/users");

// POST with body
const res = await app.request("/users", {
  method: "POST",
  body: { name: "Alice", email: "[email protected]" },
});

// With headers
const res = await app.request("/users", {
  method: "POST",
  body: { name: "Alice", email: "[email protected]" },
  headers: { authorization: "Bearer valid-token" },
});

// With query parameters
const res = await app.request("/users", {
  query: { page: "2", limit: "10" },
});

app.request serializes the body as JSON, sets the content-type header, appends query parameters to the URL, and calls app.fetch. The full request lifecycle runs: onRequest, routing, validation, your handler, onResponse.

The response is a standard Response object. Check it the same way you'd check any fetch response.

Setting up

Separate your app from your server:

// app.ts — exports the app
import { setup, route } from "@hectoday/http";

export const app = setup({
  routes: [...],
});
// server.ts — runs the app
import { app } from "./app";

Deno.serve(app.fetch);

Tests import the app, never the server:

// users.test.ts
import { describe, it, expect } from "vitest";
import { app } from "./app";

No server starts during tests. No ports to clean up. No race conditions between tests sharing a listener.

Your first test

Start with the simplest possible test:

import { describe, it, expect } from "vitest";
import { app } from "./app";

describe("GET /health", () => {
  it("returns 200", async () => {
    const res = await app.request("/health");
    expect(res.status).toBe(200);
  });
});

Run it:

npx vitest

If it passes, your app starts, routes the request, runs the handler, and returns a response. You've verified the entire lifecycle in one test.

What to assert on

A response has three things worth checking: status, headers, and body.

Status

The most important assertion. It tells you whether the request succeeded or failed, and why:

expect(res.status).toBe(200); // success
expect(res.status).toBe(201); // created
expect(res.status).toBe(204); // no content
expect(res.status).toBe(400); // bad input
expect(res.status).toBe(401); // no auth
expect(res.status).toBe(403); // not allowed
expect(res.status).toBe(404); // not found

Always check status first. If the status is wrong, everything else is irrelevant.

Body

Parse the response and check the data:

const body = await res.json();
expect(body.name).toBe("Alice");
expect(body.email).toBe("[email protected]");
expect(body.id).toBeDefined();

Don't check every field. Check the fields that matter for the behavior you're testing. If you're testing "create user returns the user," check name and id. You don't need to assert on createdAt unless that's what the test is about.

Headers

Rarely needed, but sometimes important:

expect(res.headers.get("x-request-id")).toBeDefined();
expect(res.headers.get("content-type")).toBe("application/json");
expect(res.headers.get("cache-control")).toBe("public, max-age=60");

The three tests every endpoint needs

For any endpoint that takes input and requires auth, write at least three tests:

describe("POST /users", () => {
  it("creates a user with valid input", async () => {
    const res = await app.request("/users", {
      method: "POST",
      body: { name: "Alice", email: "[email protected]" },
      headers: { authorization: "Bearer valid-token" },
    });

    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.name).toBe("Alice");
  });

  it("rejects invalid input", async () => {
    const res = await app.request("/users", {
      method: "POST",
      body: { name: "", email: "not-an-email" },
      headers: { authorization: "Bearer valid-token" },
    });

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

  it("rejects missing auth", async () => {
    const res = await app.request("/users", {
      method: "POST",
      body: { name: "Alice", email: "[email protected]" },
    });

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

Happy path — valid input, valid auth, expect success. This proves the endpoint works.

Invalid input — bad data, valid auth, expect 400. This proves validation works.

Missing auth — valid data, no auth, expect 401. This proves auth works.

These three tests cover the most common failure modes. Add more tests for edge cases as you discover them.

Testing validation

Test that your schemas reject what they should:

describe("POST /users validation", () => {
  const validHeaders = { authorization: "Bearer valid-token" };

  it("rejects empty name", async () => {
    const res = await app.request("/users", {
      method: "POST",
      body: { name: "", email: "[email protected]" },
      headers: validHeaders,
    });
    expect(res.status).toBe(400);
  });

  it("rejects invalid email", async () => {
    const res = await app.request("/users", {
      method: "POST",
      body: { name: "Alice", email: "not-an-email" },
      headers: validHeaders,
    });
    expect(res.status).toBe(400);
  });

  it("rejects missing body", async () => {
    const res = await app.request("/users", {
      method: "POST",
      headers: validHeaders,
    });
    expect(res.status).toBe(400);
  });

  it("applies default pagination", async () => {
    const res = await app.request("/users", {
      headers: validHeaders,
    });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.page).toBe(1);
    expect(body.limit).toBe(20);
  });
});

You're not testing Zod. You're testing that your schema definitions are correct and that your handler returns the right status code when validation fails.

Testing auth

Test each level of access:

describe("DELETE /users/:id auth", () => {
  const userId = "550e8400-e29b-41d4-a716-446655440000";

  it("rejects unauthenticated requests", async () => {
    const res = await app.request(`/users/${userId}`, {
      method: "DELETE",
    });
    expect(res.status).toBe(401);
  });

  it("rejects non-admin users", async () => {
    const res = await app.request(`/users/${userId}`, {
      method: "DELETE",
      headers: { authorization: "Bearer user-token" },
    });
    expect(res.status).toBe(403);
  });

  it("allows admin users", async () => {
    const res = await app.request(`/users/${userId}`, {
      method: "DELETE",
      headers: { authorization: "Bearer admin-token" },
    });
    expect(res.status).toBe(204);
  });
});

Each test sends a different token (or no token) and checks that the response matches the expected access level.

Testing not found

describe("GET /users/:id", () => {
  it("returns 404 for nonexistent user", async () => {
    const res = await app.request("/users/00000000-0000-0000-0000-000000000000", {
      headers: { authorization: "Bearer valid-token" },
    });
    expect(res.status).toBe(404);
  });

  it("returns 400 for invalid UUID", async () => {
    const res = await app.request("/users/not-a-uuid", {
      headers: { authorization: "Bearer valid-token" },
    });
    expect(res.status).toBe(400);
  });
});

These are two different cases. Invalid UUID is a validation error (400). Valid UUID that doesn't exist is a not-found error (404). Test both.

Testing hooks

Hooks run on every request. Test them through normal endpoint tests:

describe("hooks", () => {
  it("adds request-id header to responses", async () => {
    const res = await app.request("/health");
    expect(res.headers.get("x-request-id")).toBeDefined();
  });

  it("returns 404 with correct format for unknown routes", async () => {
    const res = await app.request("/nonexistent");
    expect(res.status).toBe(404);
    const body = await res.json();
    expect(body.error).toBeDefined();
  });
});

You don't test hooks in isolation. You test their effects on real responses.

Testing CORS

describe("CORS", () => {
  it("handles preflight requests", async () => {
    const res = await app.fetch(
      new Request("http://localhost/users", {
        method: "OPTIONS",
        headers: {
          origin: "https://myapp.com",
          "access-control-request-method": "POST",
          "access-control-request-headers": "content-type, authorization",
        },
      }),
    );

    expect(res.status).toBe(204);
    expect(res.headers.get("access-control-allow-origin")).toBe("https://myapp.com");
    expect(res.headers.get("access-control-allow-methods")).toContain("POST");
  });

  it("adds CORS headers to normal responses", async () => {
    const res = await app.fetch(
      new Request("http://localhost/health", {
        headers: { origin: "https://myapp.com" },
      }),
    );

    expect(res.headers.get("access-control-allow-origin")).toBe("https://myapp.com");
  });
});

CORS tests use app.fetch with a raw Request because app.request doesn't set the Origin header. Preflight tests must send OPTIONS with the right headers.

Unit testing auth functions

Auth functions are just functions. Test them directly:

import { authenticate } from "./auth";

describe("authenticate", () => {
  it("returns 401 for missing header", () => {
    const result = authenticate(new Request("http://localhost"));
    expect(result).toBeInstanceOf(Response);
    expect((result as Response).status).toBe(401);
  });

  it("returns 401 for invalid token", () => {
    const result = authenticate(
      new Request("http://localhost", {
        headers: { authorization: "Bearer invalid" },
      }),
    );
    expect(result).toBeInstanceOf(Response);
  });

  it("returns user for valid token", () => {
    const result = authenticate(
      new Request("http://localhost", {
        headers: { authorization: "Bearer valid-token" },
      }),
    );
    expect(result).not.toBeInstanceOf(Response);
    expect((result as User).id).toBeDefined();
  });
});

No app, no framework. Function in, value out. The instanceof Response check is the same pattern you use in handlers.

Integration vs unit tests

The tests above fall into two categories:

Integration tests use app.request. They test the full lifecycle: routing, validation, auth, handler logic, hooks. They're slow(er) but prove the whole thing works together.

Unit tests test individual functions directly. The auth function. A business logic function. A utility. They're fast and focused but don't prove the pieces work together.

Start with integration tests. They give you the most confidence per test. Add unit tests when you have complex logic that's hard to cover through the API alone.

What not to test

Don't test the framework. You don't need to verify that route.get matches GET requests. The framework's own tests cover that.

Don't test Zod. You don't need to verify that z.string().email() rejects "bad". Zod's own tests cover that. Test that your schema definitions are correct by sending bad data to your endpoint.

Don't test other people's libraries. If you use a database client, don't test that it connects. Test that your handler does the right thing with the data it returns.

Don't mock the framework. Don't mock c.input or c.request. Use app.request and let the real framework run. If you mock the framework, you're testing your mocks, not your code.

Test data

Hardcode test data in your test files. Don't generate random data. Don't share test data across files through imports. Each test should be readable on its own:

it("creates a user", async () => {
  const res = await app.request("/users", {
    method: "POST",
    body: { name: "Alice", email: "[email protected]" },
    headers: { authorization: "Bearer valid-token" },
  });

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

Anyone reading this test can see the input and the expected output. No hunting through fixtures or factories.

Helpers for repeated setup

If every test needs auth headers, extract a helper:

function authed(options: Record<string, unknown> = {}) {
  return {
    ...options,
    headers: {
      authorization: "Bearer valid-token",
      ...(options.headers as Record<string, string>),
    },
  };
}

// Usage
const res = await app.request(
  "/users",
  authed({
    method: "POST",
    body: { name: "Alice", email: "[email protected]" },
  }),
);

Keep helpers simple. If a helper needs a helper, you've gone too far.

Test organization

One test file per route group:

src/
  users.ts
  users.test.ts      ← tests for /users endpoints
  admin.ts
  admin.test.ts      ← tests for /admin endpoints
  auth.ts
  auth.test.ts       ← unit tests for auth functions
  app.ts
  app.test.ts        ← tests for hooks, 404, CORS

Each test file imports the app and tests its own endpoints. The app.test.ts file covers cross-cutting behavior like hooks and not-found handling.

Summary

What to test How to test it
Happy path Valid input + valid auth → expected status and body
Invalid input Bad data + valid auth → 400
Missing auth Valid data + no auth → 401
Wrong role Valid data + wrong token → 403
Not found Valid params for nonexistent resource → 404
Invalid params Malformed ID → 400
Hooks Check headers or format on any response
CORS Send OPTIONS with Origin header, check CORS headers
Auth functions Call directly, check instanceof Response

Use app.request for integration tests. Call functions directly for unit tests. Don't mock the framework. Don't test other people's libraries. Check status first, then body, then headers. Start with three tests per endpoint: happy path, invalid input, missing auth. Add more when you find bugs.