Hono — Testing¶
OWNER: urszula, maxim ALSO_USED_BY: sandro, boris, yoanna LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: 4.12.x
Overview¶
Testing Hono services with Vitest. Covers request testing via app.request(),
the typed testClient, middleware isolation, and dependency mocking. Agents
writing or reviewing tests for API services need this page.
Test Setup¶
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts', 'src/**/*.test.ts'],
},
},
})
CHECK: Test environment is node, NOT jsdom.
IF: Environment is jsdom.
THEN: Hono uses Web Standard APIs. Node env provides them. jsdom adds overhead.
Testing with app.request()¶
The primary pattern. No HTTP server needed — Hono processes requests in-memory.
import { describe, it, expect } from 'vitest'
import { createApp } from '../app'
describe('GET /api/projects', () => {
it('returns project list', async () => {
const app = createApp(mockDeps)
const res = await app.request('/api/projects', {
headers: { Authorization: 'Bearer test-token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBeInstanceOf(Array)
})
it('requires authentication', async () => {
const app = createApp(mockDeps)
const res = await app.request('/api/projects')
expect(res.status).toBe(401)
})
})
POST/PUT/PATCH Requests¶
it('creates a project', async () => {
const app = createApp(mockDeps)
const res = await app.request('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({
name: 'Test Project',
clientId: '550e8400-e29b-41d4-a716-446655440000',
}),
})
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.name).toBe('Test Project')
})
Injecting Environment/Bindings¶
Pass env as the third argument to app.request():
const res = await app.request('/api/config', {}, {
API_KEY: 'test-key',
DATABASE_URL: 'postgres://test:test@localhost/test',
})
CHECK: Use the third arg for env injection, NOT process.env mutation.
IF: You set process.env.X = 'test' in tests.
THEN: Leaks between tests. Use Hono's env injection instead.
Typed Test Client¶
Hono provides testClient for type-safe test calls that mirror the RPC client.
import { testClient } from 'hono/testing'
import { createApp } from '../app'
describe('Projects API', () => {
const app = createApp(mockDeps)
const client = testClient(app)
it('lists projects', async () => {
const res = await client.api.projects.$get(undefined, {
headers: { Authorization: 'Bearer test-token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(2)
})
it('creates a project', async () => {
const res = await client.api.projects.$post({
json: { name: 'New', clientId: '...' },
}, {
headers: { Authorization: 'Bearer test-token' },
})
expect(res.status).toBe(201)
})
})
CHECK: Routes are chained in the route file for testClient type inference.
IF: Type inference breaks in testClient.
THEN: Route file likely uses separate app.get() calls instead of chaining.
READ_ALSO: wiki/docs/stack/hono/patterns.md (RPC Mode section)
Dependency Mocking¶
GE's factory pattern makes mocking straightforward.
// test/helpers/mock-deps.ts
import { vi } from 'vitest'
import type { AppDeps } from '../../src/app'
export function createMockDeps(overrides?: Partial<AppDeps>): AppDeps {
return {
db: {
select: vi.fn().mockReturnValue({ from: vi.fn().mockResolvedValue([]) }),
insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: 'test-id' }]),
})}),
...overrides?.db,
} as any,
redis: {
xadd: vi.fn().mockResolvedValue('1234-0'),
get: vi.fn().mockResolvedValue(null),
...overrides?.redis,
} as any,
config: {
serviceName: 'test-service',
...overrides?.config,
} as any,
}
}
// In test file
import { createMockDeps } from './helpers/mock-deps'
const mockDeps = createMockDeps({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockResolvedValue([
{ id: '1', name: 'Project A' },
{ id: '2', name: 'Project B' },
]),
}),
} as any,
})
const app = createApp(mockDeps)
CHECK: Each test creates a fresh createApp(mockDeps) instance.
IF: Tests share a single app instance.
THEN: State leaks between tests. Middleware context pollution.
Middleware Testing in Isolation¶
Test middleware without route handlers.
import { Hono } from 'hono'
import { rateLimiter } from '../middleware/rate-limiter'
describe('rateLimiter', () => {
it('allows requests under the limit', async () => {
const app = new Hono()
app.use('*', rateLimiter)
app.get('/test', (c) => c.text('ok'))
const res = await app.request('/test')
expect(res.status).toBe(200)
})
it('blocks requests over the limit', async () => {
const app = new Hono()
app.use('*', createRateLimiter({ windowMs: 60000, max: 2 }))
app.get('/test', (c) => c.text('ok'))
await app.request('/test')
await app.request('/test')
const res = await app.request('/test')
expect(res.status).toBe(429)
expect(res.headers.get('Retry-After')).toBeTruthy()
})
})
Testing Auth Middleware¶
describe('authMiddleware', () => {
const app = new Hono<AppEnv>()
app.use('*', authMiddleware)
app.get('/test', (c) => c.json({ userId: c.get('userId') }))
it('rejects missing token', async () => {
const res = await app.request('/test')
expect(res.status).toBe(401)
})
it('accepts valid internal token', async () => {
const res = await app.request('/test', {
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.userId).toBe('system')
})
})
Validation Testing¶
Test that Zod validators reject bad input correctly.
describe('POST /api/projects validation', () => {
it('rejects missing name', async () => {
const app = createApp(mockDeps)
const res = await app.request('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({ clientId: 'valid-uuid' }),
})
expect(res.status).toBe(422)
const body = await res.json()
expect(body.error).toBe('VALIDATION_ERROR')
expect(body.issues).toHaveLength(1)
expect(body.issues[0].path).toContain('name')
})
})
CHECK: Validation tests assert on both status code AND issue details. IF: You only check status code. THEN: A 422 from a different validation error passes silently.
Cross-References¶
READ_ALSO: wiki/docs/stack/hono/index.md READ_ALSO: wiki/docs/stack/hono/patterns.md READ_ALSO: wiki/docs/stack/hono/middleware.md READ_ALSO: wiki/docs/stack/hono/pitfalls.md