Testing APIs from First Principles
- What you're actually testing
- The testing interface
- Setting up
- Your first test
- What to assert on
- Status
- Body
- Headers
- The three tests every endpoint needs
- Testing validation
- Testing auth
- Testing not found
- Testing hooks
- Testing CORS
- Unit testing auth functions
- Integration vs unit tests
- What not to test
- Test data
- Helpers for repeated setup
- Test organization
- Summary
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 vitestIf 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 foundAlways 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, CORSEach 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.