Hono Testing Patterns
Overview
Hono provides a simple testing approach: create a Request, pass it to your app, and validate the Response. The framework includes a typed test client for even better DX.
Key Features:
- Simple
app.request()API - Typed test client with full inference
- Environment mocking for Workers
- Works with Vitest, Jest, or any test runner
When to Use This Skill
Use Hono testing when:
- Writing unit tests for route handlers
- Integration testing API endpoints
- Testing middleware behavior
- Mocking Cloudflare Workers bindings
- Validating request/response cycles
Basic Testing
Using app.request()
typescriptimport { Hono } from 'hono' import { describe, it, expect } from 'vitest' const app = new Hono() app.get('/hello', (c) => c.text('Hello!')) app.get('/json', (c) => c.json({ message: 'Hello' })) describe('Basic routes', () => { it('should return text', async () => { const res = await app.request('/hello') expect(res.status).toBe(200) expect(await res.text()).toBe('Hello!') }) it('should return JSON', async () => { const res = await app.request('/json') expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('application/json') expect(await res.json()).toEqual({ message: 'Hello' }) }) })
Request Options
typescript// GET with query params const res = await app.request('/search?q=hono&page=1') // POST with JSON body const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }) }) // POST with form data const formData = new FormData() formData.append('name', 'Alice') formData.append('email', 'alice@example.com') const res = await app.request('/users', { method: 'POST', body: formData }) // With custom headers const res = await app.request('/protected', { headers: { 'Authorization': 'Bearer token123', 'X-Custom-Header': 'value' } }) // DELETE request const res = await app.request('/users/123', { method: 'DELETE' })
Typed Test Client
The test client provides full type inference:
typescriptimport { Hono } from 'hono' import { testClient } from 'hono/testing' import { describe, it, expect } from 'vitest' const app = new Hono() .get('/users', (c) => c.json({ users: [] })) .post('/users', async (c) => { const body = await c.req.json() return c.json({ id: '1', ...body }, 201) }) .get('/users/:id', (c) => { return c.json({ id: c.req.param('id'), name: 'Alice' }) }) describe('Users API', () => { const client = testClient(app) it('should list users', async () => { const res = await client.users.$get() expect(res.status).toBe(200) const data = await res.json() expect(data.users).toEqual([]) }) it('should create user', async () => { const res = await client.users.$post({ json: { name: 'Alice', email: 'alice@example.com' } }) expect(res.status).toBe(201) const data = await res.json() expect(data.name).toBe('Alice') }) it('should get user by id', async () => { const res = await client.users[':id'].$get({ param: { id: '123' } }) expect(res.status).toBe(200) const data = await res.json() expect(data.id).toBe('123') }) })
Testing with Validation
typescriptimport { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() const createUserSchema = z.object({ name: z.string().min(1), email: z.string().email() }) app.post('/users', zValidator('json', createUserSchema), async (c) => { const data = c.req.valid('json') return c.json({ id: '1', ...data }, 201) }) describe('Validation', () => { it('should accept valid data', async () => { const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }) }) expect(res.status).toBe(201) }) it('should reject invalid email', async () => { const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Alice', email: 'invalid-email' }) }) expect(res.status).toBe(400) }) it('should reject missing name', async () => { const res = await app.request('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'alice@example.com' }) }) expect(res.status).toBe(400) }) })
Mocking Environment (Cloudflare Workers)
Mock Bindings
typescriptimport { Hono } from 'hono' type Bindings = { DB: D1Database KV: KVNamespace API_KEY: string } const app = new Hono<{ Bindings: Bindings }>() app.get('/data', async (c) => { const result = await c.env.DB.prepare('SELECT * FROM users').all() return c.json(result) }) app.get('/config', (c) => { return c.json({ apiKey: c.env.API_KEY.slice(0, 4) + '...' }) }) describe('With mocked bindings', () => { // Mock D1 database const mockDB = { prepare: () => ({ all: async () => ({ results: [{ id: 1, name: 'Alice' }] }), first: async () => ({ id: 1, name: 'Alice' }), run: async () => ({ success: true }) }) } // Mock KV namespace const mockKV = { get: async (key: string) => 'cached-value', put: async (key: string, value: string) => {}, delete: async (key: string) => {} } const mockEnv: Bindings = { DB: mockDB as unknown as D1Database, KV: mockKV as unknown as KVNamespace, API_KEY: 'test-api-key-12345' // pragma: allowlist secret } it('should use mocked database', async () => { const res = await app.request('/data', {}, mockEnv) expect(res.status).toBe(200) const data = await res.json() expect(data.results).toHaveLength(1) }) it('should use mocked API key', async () => { const res = await app.request('/config', {}, mockEnv) const data = await res.json() expect(data.apiKey).toBe('test...') }) })
Using Miniflare
For more realistic Cloudflare Workers testing:
typescriptimport { Miniflare } from 'miniflare' import { describe, it, expect, beforeAll, afterAll } from 'vitest' describe('With Miniflare', () => { let mf: Miniflare beforeAll(async () => { mf = new Miniflare({ script: ` import app from './src/index' export default app `, modules: true, d1Databases: ['DB'], kvNamespaces: ['KV'] }) }) afterAll(async () => { await mf.dispose() }) it('should work with real bindings', async () => { const res = await mf.dispatchFetch('http://localhost/data') expect(res.status).toBe(200) }) })
Testing Middleware
typescriptimport { Hono } from 'hono' import { createMiddleware } from 'hono/factory' // Middleware to test const authMiddleware = createMiddleware(async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) { return c.json({ error: 'Unauthorized' }, 401) } if (token !== 'valid-token') { return c.json({ error: 'Invalid token' }, 403) } c.set('userId', 'user-123') await next() }) const app = new Hono() app.use('/protected/*', authMiddleware) app.get('/protected/data', (c) => { const userId = c.get('userId') return c.json({ userId, data: 'secret' }) }) describe('Auth middleware', () => { it('should reject request without token', async () => { const res = await app.request('/protected/data') expect(res.status).toBe(401) expect(await res.json()).toEqual({ error: 'Unauthorized' }) }) it('should reject invalid token', async () => { const res = await app.request('/protected/data', { headers: { 'Authorization': 'Bearer invalid' } }) expect(res.status).toBe(403) expect(await res.json()).toEqual({ error: 'Invalid token' }) }) it('should allow valid token', async () => { const res = await app.request('/protected/data', { headers: { 'Authorization': 'Bearer valid-token' } }) expect(res.status).toBe(200) const data = await res.json() expect(data.userId).toBe('user-123') }) })
Testing Error Handling
typescriptimport { Hono } from 'hono' import { HTTPException } from 'hono/http-exception' const app = new Hono() app.get('/error', () => { throw new HTTPException(500, { message: 'Something went wrong' }) }) app.get('/not-found', (c) => { return c.notFound() }) app.onError((err, c) => { if (err instanceof HTTPException) { return c.json({ error: err.message }, err.status) } return c.json({ error: 'Internal error' }, 500) }) app.notFound((c) => { return c.json({ error: 'Not found' }, 404) }) describe('Error handling', () => { it('should handle HTTPException', async () => { const res = await app.request('/error') expect(res.status).toBe(500) expect(await res.json()).toEqual({ error: 'Something went wrong' }) }) it('should handle not found', async () => { const res = await app.request('/unknown-route') expect(res.status).toBe(404) expect(await res.json()).toEqual({ error: 'Not found' }) }) })
Testing File Uploads
typescriptimport { Hono } from 'hono' const app = new Hono() app.post('/upload', async (c) => { const formData = await c.req.formData() const file = formData.get('file') as File if (!file) { return c.json({ error: 'No file provided' }, 400) } return c.json({ filename: file.name, size: file.size, type: file.type }) }) describe('File upload', () => { it('should handle file upload', async () => { const file = new File(['hello world'], 'test.txt', { type: 'text/plain' }) const formData = new FormData() formData.append('file', file) const res = await app.request('/upload', { method: 'POST', body: formData }) expect(res.status).toBe(200) const data = await res.json() expect(data.filename).toBe('test.txt') expect(data.size).toBe(11) expect(data.type).toBe('text/plain') }) it('should reject missing file', async () => { const formData = new FormData() const res = await app.request('/upload', { method: 'POST', body: formData }) expect(res.status).toBe(400) }) })
Test Setup Patterns
Vitest Configuration
typescript// vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, environment: 'node', coverage: { reporter: ['text', 'json', 'html'], exclude: ['node_modules/', 'dist/'] } } })
Test Utilities
typescript// test/utils.ts import { Hono } from 'hono' import type { Bindings } from '../src/types' export function createTestApp() { // Return fresh app instance for each test return new Hono<{ Bindings: Bindings }>() } export function createMockEnv(overrides: Partial<Bindings> = {}): Bindings { return { DB: createMockDB(), KV: createMockKV(), API_KEY: 'test-key', // pragma: allowlist secret ...overrides } } export function createMockDB() { return { prepare: (sql: string) => ({ bind: (...args: any[]) => ({ all: async () => ({ results: [] }), first: async () => null, run: async () => ({ success: true }) }), all: async () => ({ results: [] }), first: async () => null, run: async () => ({ success: true }) }) } } export function createMockKV() { const store = new Map<string, string>() return { get: async (key: string) => store.get(key) ?? null, put: async (key: string, value: string) => { store.set(key, value) }, delete: async (key: string) => { store.delete(key) } } }
Using Test Utilities
typescriptimport { describe, it, expect, beforeEach } from 'vitest' import { createTestApp, createMockEnv } from './utils' import { setupRoutes } from '../src/routes' describe('API Tests', () => { let app: ReturnType<typeof createTestApp> let env: ReturnType<typeof createMockEnv> beforeEach(() => { app = createTestApp() env = createMockEnv() setupRoutes(app) }) it('should work with fresh instances', async () => { const res = await app.request('/api/health', {}, env) expect(res.status).toBe(200) }) })
Quick Reference
app.request() Signature
typescriptapp.request( path: string, options?: RequestInit, env?: Bindings ): Promise<Response>
Common Assertions
typescript// Status expect(res.status).toBe(200) expect(res.ok).toBe(true) // Headers expect(res.headers.get('Content-Type')).toContain('application/json') expect(res.headers.get('X-Custom')).toBe('value') // Body expect(await res.text()).toBe('Hello') expect(await res.json()).toEqual({ key: 'value' }) // Response properties expect(res.redirected).toBe(false) expect(res.url).toBe('http://localhost/path')
Test Client Methods
typescriptconst client = testClient(app) client.path.$get() client.path.$post({ json: {} }) client.path[':id'].$get({ param: { id: '1' } }) client.path.$get({ query: { page: 1 } }) client.path.$post({ header: { 'X-Custom': 'v' } })
Related Skills
- hono-core - Framework fundamentals
- hono-middleware - Middleware patterns
- hono-validation - Request validation
Version: Hono 4.x Last Updated: January 2025 License: MIT

