Testing Hono Components: From Unit to E2E Tests
Hono is a fast, lightweight web framework that runs on any JavaScript runtime — Cloudflare Workers, Deno, Bun, and Node.js. Its minimal design and composable middleware architecture make it a joy to build APIs with, but like any application, Hono apps need rigorous testing to remain reliable as they grow. This tutorial walks you through a complete testing strategy for Hono applications, from isolated unit tests all the way to end-to-end (E2E) tests that exercise your entire request-response lifecycle.
Why Testing Hono Matters
Because Hono apps are often deployed to edge environments with strict limits on CPU time and memory, bugs that slip into production can be costly and hard to debug. A layered testing strategy gives you confidence at every level:
- Unit tests verify individual handlers, middleware, and helper functions in isolation.
- Integration tests check how multiple components work together, including middleware chains and routing.
- E2E tests validate the full application against real HTTP requests, simulating what users actually experience.
By testing at each layer, you catch regressions early, document expected behavior, and refactor with confidence.
Setting Up Your Project
Let's start with a typical Hono project. We'll use Node.js with Vitest as our test runner, but the concepts apply to any runtime. First, install the dependencies:
npm install hono
npm install -D vitest @vitest/coverage-v8 supertest
Here is a basic Hono application we'll use throughout this tutorial. Save it as src/app.ts:
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { authMiddleware } from './middleware/auth'
import { validateUserInput } from './utils/validation'
import { findUser, createUser } from './services/userService'
const app = new Hono()
app.use('*', logger())
app.get('/health', (c) => {
return c.json({ status: 'ok' })
})
app.get('/users/:id', async (c) => {
const id = c.req.param('id')
const user = await findUser(id)
if (!user) {
return c.json({ error: 'User not found' }, 404)
}
return c.json({ user })
})
app.post('/users', authMiddleware, async (c) => {
const body = await c.req.json()
const errors = validateUserInput(body)
if (errors.length > 0) {
return c.json({ errors }, 400)
}
const user = await createUser(body)
return c.json({ user }, 201)
})
export default app
Now let's break this down and test each piece.
Unit Testing Handlers and Utilities
Unit tests focus on the smallest pieces of your application. In a Hono app, that means testing utility functions, validation logic, and individual route handlers in isolation. The key is to mock external dependencies so you're only testing one thing at a time.
Testing Validation Utilities
Let's say our validation utility lives in src/utils/validation.ts:
export interface UserInput {
name?: string
email?: string
age?: number
}
export function validateUserInput(input: UserInput): string[] {
const errors: string[] = []
if (!input.name || input.name.trim().length === 0) {
errors.push('Name is required')
}
if (!input.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
errors.push('Valid email is required')
}
if (input.age !== undefined && (input.age < 0 || input.age > 120)) {
errors.push('Age must be between 0 and 120')
}
return errors
}
Testing this function is straightforward since it's a pure function with no side effects:
import { describe, it, expect } from 'vitest'
import { validateUserInput } from '../src/utils/validation'
describe('validateUserInput', () => {
it('returns no errors for valid input', () => {
const errors = validateUserInput({
name: 'Alice',
email: 'alice@example.com',
age: 30,
})
expect(errors).toEqual([])
})
it('returns error when name is missing', () => {
const errors = validateUserInput({ email: 'alice@example.com' })
expect(errors).toContain('Name is required')
})
it('returns error for invalid email format', () => {
const errors = validateUserInput({ name: 'Alice', email: 'not-an-email' })
expect(errors).toContain('Valid email is required')
})
it('returns error when age is out of range', () => {
const errors = validateUserInput({
name: 'Alice',
email: 'alice@example.com',
age: 150,
})
expect(errors).toContain('Age must be between 0 and 120')
})
it('allows undefined age', () => {
const errors = validateUserInput({
name: 'Alice',
email: 'alice@example.com',
})
expect(errors).toEqual([])
})
})
Testing the User Service with Mocks
Our route handlers depend on a user service that likely talks to a database. For unit tests, we mock that service so we can test the handler logic independently. Here's the service in src/services/userService.ts:
export interface User {
id: string
name: string
email: string
age?: number
}
const db = new Map<string, User>()
export async function findUser(id: string): Promise<User | null> {
return db.get(id) ?? null
}
export async function createUser(input: Omit<User, 'id'>): Promise<User> {
const id = crypto.randomUUID()
const user = { id, ...input }
db.set(id, user)
return user
}
Now let's test the /users/:id handler by mocking the service. Hono's app.request() method lets you simulate HTTP requests against your app without starting a real server:
import { describe, it, expect, vi } from 'vitest'
import app from '../src/app'
vi.mock('../src/services/userService', () => ({
findUser: vi.fn(),
createUser: vi.fn(),
}))
import { findUser } from '../src/services/userService'
describe('GET /users/:id', () => {
it('returns the user when found', async () => {
const mockUser = { id: '123', name: 'Alice', email: 'alice@example.com' }
vi.mocked(findUser).mockResolvedValue(mockUser)
const res = await app.request('/users/123')
const body = await res.json()
expect(res.status).toBe(200)
expect(body.user).toEqual(mockUser)
expect(findUser).toHaveBeenCalledWith('123')
})
it('returns 404 when user is not found', async () => {
vi.mocked(findUser).mockResolvedValue(null)
const res = await app.request('/users/nonexistent')
const body = await res.json()
expect(res.status).toBe(404)
expect(body.error).toBe('User not found')
})
})
The app.request() method is one of Hono's most powerful testing features. It creates a real Request object, runs it through your middleware stack, and returns the Response — all in-process, with no network overhead.
Testing Middleware
Middleware is central to Hono applications. Testing it properly ensures your authentication, logging, and request transformation logic works as expected. Let's look at our auth middleware in src/middleware/auth.ts:
import type { Context, Next } from 'hono'
export async function authMiddleware(c: Context, next: Next) {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
}
const token = authHeader.slice(7)
if (token !== 'valid-secret-token') {
return c.json({ error: 'Invalid token' }, 401)
}
c.set('user', { id: '1', name: 'Admin' })
await next()
}
To test middleware in isolation, create a minimal Hono app that uses only the middleware and a simple handler:
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { authMiddleware } from '../src/middleware/auth'
function createTestApp() {
const app = new Hono()
app.use('*', authMiddleware)
app.get('/protected', (c) => {
const user = c.get('user')
return c.json({ message: 'Access granted', user })
})
return app
}
describe('authMiddleware', () => {
it('allows requests with a valid Bearer token', async () => {
const app = createTestApp()
const res = await app.request('/protected', {
headers: { Authorization: 'Bearer valid-secret-token' },
})
const body = await res.json()
expect(res.status).toBe(200)
expect(body.message).toBe('Access granted')
expect(body.user).toEqual({ id: '1', name: 'Admin' })
})
it('rejects requests without Authorization header', async () => {
const app = createTestApp()
const res = await app.request('/protected')
const body = await res.json()
expect(res.status).toBe(401)
expect(body.error).toBe('Missing or invalid Authorization header')
})
it('rejects requests with an invalid token', async () => {
const app = createTestApp()
const res = await app.request('/protected', {
headers: { Authorization: 'Bearer wrong-token' },
})
const body = await res.json()
expect(res.status).toBe(401)
expect(body.error).toBe('Invalid token')
})
it('rejects requests with malformed Authorization header', async () => {
const app = createTestApp()
const res = await app.request('/protected', {
headers: { Authorization: 'Basic abc123' },
})
expect(res.status).toBe(401)
})
})
This approach of building a minimal test app around your middleware is a pattern you'll reuse often. It keeps tests focused and avoids coupling to the rest of your application.
Integration Testing with the Full App
Integration tests exercise multiple components together. Instead of mocking the service layer, you might use an in-memory database or a test database to verify that routing, middleware, validation, and data access all cooperate correctly.
import { describe, it, expect, beforeEach } from 'vitest'
import app from '../src/app'
// In a real project, you would reset the database between tests
describe('User API integration', () => {
it('creates a user and retrieves it', async () => {
// Create a user
const createRes = await app.request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer valid-secret-token',
},
body: JSON.stringify({
name: 'Bob',
email: 'bob@example.com',
age: 25,
}),
})
expect(createRes.status).toBe(201)
const created = await createRes.json()
expect(created.user.id).toBeDefined()
expect(created.user.name).toBe('Bob')
// Retrieve the same user
const getRes = await app.request(`/users/${created.user.id}`)
expect(getRes.status).toBe(200)
const fetched = await getRes.json()
expect(fetched.user.email).toBe('bob@example.com')
})
it('returns 400 for invalid user input', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer valid-secret-token',
},
body: JSON.stringify({ name: '' }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.errors).toContain('Name is required')
expect(body.errors).toContain('Valid email is required')
})
it('returns 401 when creating a user without auth', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Bob', email: 'bob@example.com' }),
})
expect(res.status).toBe(401)
})
})
Notice how these tests don't mock anything — they exercise the real middleware, real validation, and real service layer. This gives you high confidence that the pieces fit together correctly.
End-to-End Testing with a Real Server
For true E2E tests, you want to start your actual HTTP server and make real network requests. This catches issues that in-process testing might miss, such as port binding, serialization quirks, or runtime-specific behavior. We'll use supertest to make HTTP requests against a running server.
First, create a server entry point in src/server.ts:
import { serve } from '@hono/node-server'
import app from './app'
const port = Number(process.env.PORT) || 3000
const server = serve({
fetch: app.fetch,
port,
})
export default server
Now write E2E tests that start the server and make real HTTP requests:
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import request from 'supertest'
import server from '../src/server'
describe('E2E: Health and User endpoints', () => {
afterAll(() => {
server.close()
})
it('GET /health returns ok status', async () => {
const res = await request(server).get('/health')
expect(res.status).toBe(200)
expect(res.body.status).toBe('ok')
})
it('full user creation and retrieval flow', async () => {
// Create
const createRes = await request(server)
.post('/users')
.set('Authorization', 'Bearer valid-secret-token')
.set('Content-Type', 'application/json')
.send({ name: 'Charlie', email: 'charlie@example.com', age: 35 })
expect(createRes.status).toBe(201)
const userId = createRes.body.user.id
// Retrieve
const getRes = await request(server).get(`/users/${userId}`)
expect(getRes.status).toBe(200)
expect(getRes.body.user.name).toBe('Charlie')
})
it('returns 404 for unknown routes', async () => {
const res = await request(server).get('/unknown-path')
expect(res.status).toBe(404)
})
})
E2E tests are slower than unit tests, so reserve them for critical user flows. You don't need to test every edge case at the E2E level — that's what unit and integration tests are for.
Testing Hono with Zod Validation
Many Hono projects use Zod for schema validation via hono/zod-openapi or the built-in validator middleware. Testing these validators ensures your API rejects malformed input gracefully. Here's an example using Hono's validator:
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const schema = z.object({
title: z.string().min(1).max(100),
completed: z.boolean().optional(),
})
const todoApp = new Hono()
todoApp.post('/todos', zValidator('json', schema), (c) => {
const data = c.req.valid('json')
return c.json({ todo: data }, 201)
})
export default todoApp
Test both valid and invalid inputs:
import { describe, it, expect } from 'vitest'
import todoApp from '../src/todoApp'
describe('POST /todos with Zod validation', () => {
it('accepts valid todo input', async () => {
const res = await todoApp.request('/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Buy groceries', completed: false }),
})
expect(res.status).toBe(201)
const body = await res.json()
expect(body.todo.title).toBe('Buy groceries')
})
it('rejects empty title', async () => {
const res = await todoApp.request('/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '' }),
})
expect(res.status).toBe(400)
})
it('rejects missing title field', async () => {
const res = await todoApp.request('/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: true }),
})
expect(res.status).toBe(400)
})
})
Best Practices for Testing Hono Applications
Structure Your Tests in Layers
Follow the testing pyramid: write many fast unit tests, fewer integration tests, and a small set of E2E tests for critical flows. This keeps your test suite fast and maintainable.
Use app.request() for Handler Tests
Hono's built-in app.request() method is the ideal tool for testing route handlers. It's fast, runs in-process, and exercises your real routing and middleware stack. Use it instead of mocking the framework itself.
Mock at the Boundaries
Mock external dependencies like databases, third-party APIs, and file systems — not Hono itself. This keeps your tests focused on your application logic while remaining resilient to framework changes.
Reset State Between Tests
If your tests share state (like an in-memory database), make sure to reset it in a beforeEach or afterEach hook. Leaking state between tests leads to flaky, hard-to-debug failures.
import { beforeEach } from 'vitest'
import { db } from '../src/services/userService'
beforeEach(() => {
db.clear()
})
Test Error Paths Explicitly
Don't just test the happy path. Explicitly test validation failures, missing resources, unauthorized access, and malformed input. Error handling is where most production bugs hide.
Organize Tests by Feature
Group your test files to mirror your source structure. If your app has src/routes/users.ts, put the tests in tests/routes/users.test.ts. This makes it easy to find the tests for any given feature.
Use Snapshot Testing Sparingly
Vitest's snapshot testing can be useful for verifying complex response shapes, but overusing it leads to tests that pass without anyone actually reviewing the output. Use snapshots for stable, well-understood outputs only.
Running Your Tests
Add these scripts to your package.json to run tests at different levels:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "vitest run tests/e2e",
"test:coverage": "vitest run --coverage"
}
}
A sample Vitest configuration in vitest.config.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/server.ts'],
},
},
})
Conclusion
Testing Hono applications doesn't have to be complicated. By leveraging Hono's built-in app.request() method, you can test route handlers and middleware quickly without spinning up a server. Unit tests keep your utilities and business logic correct, integration tests verify that your components work together, and E2E tests give you confidence that the entire system behaves as expected under real HTTP conditions. The key is to test at the right level for each concern — mock at the boundaries, reset state between tests, and always cover error paths alongside the happy path. With this layered approach, your Hono applications will be robust, maintainable, and ready for production on any runtime.