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:
Tests run with NODE_ENV=test which is configured in 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:
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
});
Use Descriptive Test Names
Test names should clearly describe what they’re testing. // Good
it ( "returns 404 when task not found" , async () => { ... });
// Avoid
it ( "test 404" , async () => { ... });
Test Both Success and Error Cases
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