hono-testing

安装量: 113
排名: #7574

安装

npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill hono-testing

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() 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 // 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:

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 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 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:

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 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 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 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 // 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 // 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 { 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()

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 import { describe, it, expect, beforeEach } from 'vitest' import { createTestApp, createMockEnv } from './utils' import { setupRoutes } from '../src/routes'

describe('API Tests', () => { let app: ReturnType let env: ReturnType

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 app.request( path: string, options?: RequestInit, env?: Bindings ): Promise

Common Assertions // 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 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: MIT

返回排行榜