Skip to main content
This guide covers testing strategies for your Hono OpenAPI application using Vitest and Hono’s built-in testing utilities.

Test Setup

The starter includes Vitest for testing with Hono’s testClient for making type-safe API requests.

Test Configuration

Run tests with:
pnpm test
Tests run with NODE_ENV=test which is configured in package.json:
package.json
{
  "scripts": {
    "test": "cross-env NODE_ENV=test vitest"
  }
}
The test environment uses a separate test.db database that’s automatically created and cleaned up.

Test Structure

Tests are colocated with route definitions in {resource}.test.ts files.

Basic Test File

src/routes/tasks/tasks.test.ts
import { testClient } from "hono/testing";
import { execSync } from "node:child_process";
import fs from "node:fs";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import env from "@/env";
import { createTestApp } from "@/lib/create-app";
import router from "./tasks.index";

if (env.NODE_ENV !== "test") {
  throw new Error("NODE_ENV must be 'test'");
}

const client = testClient(createTestApp(router));

describe("tasks routes", () => {
  beforeAll(async () => {
    // Set up test database
    execSync("pnpm drizzle-kit push");
  });

  afterAll(async () => {
    // Clean up test database
    fs.rmSync("test.db", { force: true });
  });

  // Tests go here
});

Test Client

Create a type-safe test client:
import { testClient } from "hono/testing";
import { createTestApp } from "@/lib/create-app";
import router from "./tasks.index";

const client = testClient(createTestApp(router));
The createTestApp function wraps your router with the full app middleware stack:
src/lib/create-app.ts
export function createTestApp<S extends Schema>(router: AppOpenAPI<S>) {
  return createApp().route("/", router);
}

Testing Routes

Testing GET Requests

it("get /tasks lists all tasks", async () => {
  const response = await client.tasks.$get();
  
  expect(response.status).toBe(200);
  
  if (response.status === 200) {
    const json = await response.json();
    expectTypeOf(json).toBeArray();
    expect(json.length).toBe(1);
  }
});

it("get /tasks/{id} gets a single task", async () => {
  const response = await client.tasks[":id"].$get({
    param: { id: 1 },
  });
  
  expect(response.status).toBe(200);
  
  if (response.status === 200) {
    const json = await response.json();
    expect(json.name).toBe("Learn vitest");
    expect(json.done).toBe(false);
  }
});

Testing POST Requests

it("post /tasks creates a task", async () => {
  const response = await client.tasks.$post({
    json: {
      name: "Learn vitest",
      done: false,
    },
  });
  
  expect(response.status).toBe(200);
  
  if (response.status === 200) {
    const json = await response.json();
    expect(json.name).toBe("Learn vitest");
    expect(json.done).toBe(false);
  }
});

Testing PATCH Requests

it("patch /tasks/{id} updates a single property", async () => {
  const response = await client.tasks[":id"].$patch({
    param: { id: 1 },
    json: { done: true },
  });
  
  expect(response.status).toBe(200);
  
  if (response.status === 200) {
    const json = await response.json();
    expect(json.done).toBe(true);
  }
});

Testing DELETE Requests

it("delete /tasks/{id} removes a task", async () => {
  const response = await client.tasks[":id"].$delete({
    param: { id: 1 },
  });
  
  expect(response.status).toBe(204);
});

Testing Validation

Body Validation

it("post /tasks validates the body when creating", async () => {
  const response = await client.tasks.$post({
    json: {
      done: false,
      // missing required 'name' field
    },
  });
  
  expect(response.status).toBe(422);
  
  if (response.status === 422) {
    const json = await response.json();
    expect(json.error.issues[0].path[0]).toBe("name");
    expect(json.error.issues[0].message).toBe(
      ZOD_ERROR_MESSAGES.EXPECTED_STRING
    );
  }
});

it("patch /tasks/{id} validates the body when updating", async () => {
  const response = await client.tasks[":id"].$patch({
    param: { id: 1 },
    json: { name: "" }, // too short
  });
  
  expect(response.status).toBe(422);
  
  if (response.status === 422) {
    const json = await response.json();
    expect(json.error.issues[0].path[0]).toBe("name");
    expect(json.error.issues[0].code).toBe(ZodIssueCode.too_small);
  }
});

Parameter Validation

it("get /tasks/{id} validates the id param", async () => {
  const response = await client.tasks[":id"].$get({
    param: { id: "wat" }, // invalid number
  });
  
  expect(response.status).toBe(422);
  
  if (response.status === 422) {
    const json = await response.json();
    expect(json.error.issues[0].path[0]).toBe("id");
    expect(json.error.issues[0].message).toBe(
      ZOD_ERROR_MESSAGES.EXPECTED_NUMBER
    );
  }
});

it("delete /tasks/{id} validates the id when deleting", async () => {
  const response = await client.tasks[":id"].$delete({
    param: { id: "wat" },
  });
  
  expect(response.status).toBe(422);
  
  if (response.status === 422) {
    const json = await response.json();
    expect(json.error.issues[0].path[0]).toBe("id");
    expect(json.error.issues[0].message).toBe(
      ZOD_ERROR_MESSAGES.EXPECTED_NUMBER
    );
  }
});

Custom Validation

it("patch /tasks/{id} validates empty body", async () => {
  const response = await client.tasks[":id"].$patch({
    param: { id: 1 },
    json: {}, // empty update
  });
  
  expect(response.status).toBe(422);
  
  if (response.status === 422) {
    const json = await response.json();
    expect(json.error.issues[0].code).toBe(
      ZOD_ERROR_CODES.INVALID_UPDATES
    );
    expect(json.error.issues[0].message).toBe(
      ZOD_ERROR_MESSAGES.NO_UPDATES
    );
  }
});

Testing Error Cases

404 Not Found

it("get /tasks/{id} returns 404 when task not found", async () => {
  const response = await client.tasks[":id"].$get({
    param: { id: 999 },
  });
  
  expect(response.status).toBe(404);
  
  if (response.status === 404) {
    const json = await response.json();
    expect(json.message).toBe(HttpStatusPhrases.NOT_FOUND);
  }
});

Database Errors

it("handles database connection errors", async () => {
  // Mock database error
  vi.spyOn(db.query.tasks, "findMany").mockRejectedValue(
    new Error("Database connection failed")
  );
  
  const response = await client.tasks.$get();
  
  expect(response.status).toBe(500);
});

Test Organization

Ordered Test Flow

Organize tests in a logical flow that builds on previous operations:
describe("tasks routes", () => {
  const id = 1;
  const name = "Learn vitest";

  // 1. Test validation first
  it("post /tasks validates the body", async () => { ... });

  // 2. Create a resource
  it("post /tasks creates a task", async () => { ... });

  // 3. List resources
  it("get /tasks lists all tasks", async () => { ... });

  // 4. Get single resource
  it("get /tasks/{id} gets a single task", async () => { ... });

  // 5. Update resource
  it("patch /tasks/{id} updates a task", async () => { ... });

  // 6. Delete resource (last)
  it("delete /tasks/{id} removes a task", async () => { ... });
});

Database Setup/Teardown

beforeAll(async () => {
  // Create test database schema
  execSync("pnpm drizzle-kit push");
});

afterAll(async () => {
  // Clean up test database
  fs.rmSync("test.db", { force: true });
});

beforeEach(async () => {
  // Optional: Clear data between tests
  await db.delete(tasks);
});

Type Safety in Tests

The test client provides full type safety:
import { expectTypeOf } from "vitest";

it("get /tasks returns array of tasks", async () => {
  const response = await client.tasks.$get();
  
  if (response.status === 200) {
    const json = await response.json();
    
    // Type assertion
    expectTypeOf(json).toBeArray();
    expectTypeOf(json[0]).toHaveProperty("name");
    expectTypeOf(json[0]).toHaveProperty("done");
  }
});

Mocking

Mock Database Calls

import { vi } from "vitest";

it("handles database errors gracefully", async () => {
  const spy = vi.spyOn(db.query.tasks, "findMany")
    .mockRejectedValue(new Error("Connection failed"));
  
  const response = await client.tasks.$get();
  
  expect(response.status).toBe(500);
  expect(spy).toHaveBeenCalled();
  
  spy.mockRestore();
});

Mock Environment Variables

import { beforeEach, afterEach } from "vitest";

let originalEnv: NodeJS.ProcessEnv;

beforeEach(() => {
  originalEnv = process.env;
  process.env = { ...originalEnv, DATABASE_URL: "file:test.db" };
});

afterEach(() => {
  process.env = originalEnv;
});

Best Practices

Each test should be independent and not rely on state from other tests.
// Good - each test is self-contained
it("creates a task", async () => {
  const response = await client.tasks.$post({ ... });
  expect(response.status).toBe(200);
});

// Avoid - relies on previous test
let taskId;
it("creates a task", async () => {
  const response = await client.tasks.$post({ ... });
  taskId = response.json().id;
});
it("updates the task", async () => {
  // Uses taskId from previous test
});
Test names should clearly describe what they’re testing.
// Good
it("returns 404 when task not found", async () => { ... });

// Avoid
it("test 404", async () => { ... });
Cover happy paths and error scenarios.
// Success case
it("creates a task with valid data", async () => { ... });

// Error cases
it("returns 422 when name is missing", async () => { ... });
it("returns 422 when name is too long", async () => { ... });
Check response status before accessing response data.
const response = await client.tasks.$get();

if (response.status === 200) {
  const json = await response.json();
  // TypeScript knows json is the success type
}
Always clean up test databases and resources.
afterAll(async () => {
  fs.rmSync("test.db", { force: true });
});

Running Tests

# Run all tests
pnpm test

# Run tests in watch mode
pnpm test --watch

# Run tests with coverage
pnpm test --coverage

# Run specific test file
pnpm test tasks.test.ts

# Run tests matching a pattern
pnpm test --grep "validation"

Next Steps

Routes

Learn how to create API routes

Validation

Understand validation patterns

Vitest Docs

Official Vitest documentation

Hono Testing

Hono testing guide

Build docs developers (and LLMs) love