hono-testing
Hono testing patterns - app.request(), test client, mocking environment, and integration testing strategies
Tech stack
Works with
---
name: hono-testing
description: Hono testing patterns - app.request(), test client, mocking environment, and integration testing strategies
license: MIT
---
# 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()
```typescript
import { 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:
```typescript
import { 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
```typescript
import { 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
```typescript
import { 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:
```typescript
import { 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
```typescript
import { 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
```typescript
import { 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
```typescript
import { 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
```typescript
import { 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
```typescript
app.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
```typescript
const 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**: MITMore Testing skills
tdd
mattpocock/skills
Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.
agent-browser
vercel-labs/agent-browser
Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools.
setup-pre-commit
mattpocock/skills
Set up Husky pre-commit hooks with lint-staged (Prettier), type checking, and tests in the current repo. Use when user wants to add pre-commit hooks, set up Husky, configure lint-staged, or add commit-time formatting/typechecking/testing.

