路由测试
要点
- Hono 的
app.request()可以在不启动服务器的情况下发送请求,测试不再依赖端口和网络 - 没有全局状态、没有单例——每次
new Hono()都是独立的,天然适合隔离测试 - 测试应该覆盖路由的行为(状态码、响应体、Header),而不是内部实现细节
- 中间件可以单独挂载到一个 mini-app 上做单元测试
- 用
vitest配合app.request()是最常见的组合 - 依赖注入让数据库和外部 API 的 mock 变得简单,不需要在测试里启动真实服务
1. 为什么 Hono 容易测试
先看一个典型的 Hono 应用入口:
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', (c) => c.json({ status: 'ok' }))
export default app没有 app.listen(),没有全局 server 实例,没有隐式的单例状态。app 是一个纯粹的请求处理函数——给它一个 Request,返回一个 Response。
这意味着测试不需要启动 HTTP 服务器:
// tests/health.test.ts
import { describe, it, expect } from 'vitest'
import app from '../src/index'
describe('health check', () => {
it('返回 200 和 ok 状态', async () => {
const res = await app.request('/health')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ status: 'ok' })
})
})app.request() 接收和 fetch() 一样的参数,内部构造 Request 对象走完路由匹配和中间件链路后返回 Response,整个过程不涉及网络。
对比 Express 的测试方式,通常需要 supertest 包装 server 实例或手动 listen() 到随机端口。Hono 跳过了这一步,每个测试用例只需要构造请求,不共享端口,不存在测试间互相干扰的问题。
2. 测试路由参数、查询参数与请求体
app.request() 的签名和 fetch() 一致,URL 里拼出参数值即可:
// src/routes/users.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/users/:id', (c) => {
return c.json({ id: c.req.param('id') })
})
app.get('/search', (c) => {
const q = c.req.query('q')
const page = c.req.query('page') ?? '1'
return c.json({ q, page })
})
export default app// tests/routes/users.test.ts
import { describe, it, expect } from 'vitest'
import app from '../../src/routes/users'
describe('GET /users/:id', () => {
it('返回路径参数', async () => {
const res = await app.request('/users/42')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: '42' })
})
})
describe('GET /search', () => {
it('读取查询参数', async () => {
const res = await app.request('/search?q=hono&page=2')
expect(await res.json()).toEqual({ q: 'hono', page: '2' })
})
})路径参数直接在 URL 里拼出(/users/42),查询参数写在 ?key=value 部分。测试 POST 请求的 body:
// src/routes/posts.ts
import { Hono } from 'hono'
const app = new Hono()
app.post('/posts', async (c) => {
const body = await c.req.json()
if (!body.title) {
return c.json({ error: 'title is required' }, 400)
}
return c.json({ id: '1', title: body.title }, 201)
})
export default app// tests/routes/posts.test.ts
it('合法请求返回 201', async () => {
const res = await app.request('/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Hono Testing Guide' }),
})
expect(res.status).toBe(201)
expect(await res.json()).toEqual({ id: '1', title: 'Hono Testing Guide' })
})
it('缺少 title 返回 400', async () => {
const res = await app.request('/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
expect(await res.json()).toHaveProperty('error')
})路径参数、查询参数、body 都通过 URL 和 Request 构造参数传入。app.request() 也接受完整的 Request 对象:
const req = new Request('http://localhost/users/42', {
headers: { Authorization: 'Bearer token123' },
})
const res = await app.request(req)URL 中的 host 部分可以随意填写,Hono 的路由匹配只看 path。
3. 测试中间件
中间件测试不需要挂完整的路由。创建一个 mini-app,只挂载要测试的中间件和一个捕获用的路由:
// src/middleware/timing.ts
import { createMiddleware } from 'hono/factory'
export const timingHeader = createMiddleware(async (c, next) => {
const start = Date.now()
await next()
c.header('X-Response-Time', `${Date.now() - start}ms`)
})// tests/middleware/timing.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { timingHeader } from '../../src/middleware/timing'
describe('timingHeader middleware', () => {
it('在响应中写入 X-Response-Time', async () => {
const app = new Hono()
app.use('*', timingHeader)
app.get('/', (c) => c.text('ok'))
const res = await app.request('/')
expect(res.headers.get('X-Response-Time')).toMatch(/\d+ms/)
})
it('执行顺序:before → handler → after', async () => {
const app = new Hono()
const order: string[] = []
app.use('*', async (c, next) => {
order.push('before')
await next()
order.push('after')
})
app.get('/', (c) => {
order.push('handler')
return c.text('ok')
})
await app.request('/')
expect(order).toEqual(['before', 'handler', 'after'])
})
})这种模式适合测试鉴权、日志等需要在 next() 前后做不同操作的中间件。
4. 依赖注入与 Mock
路由里通常会调用数据库或外部 API。测试时如果每次都走真实依赖,测试会变得慢且不稳定。Hono 的 c.set() / c.get() 天然支持依赖注入。
// src/types.ts
export type UserService = {
findById(id: string): Promise<{ id: string; name: string } | null>
}// src/routes/users.ts
app.get('/users/:id', async (c) => {
const userService = c.get('userService') as UserService
const user = await userService.findById(c.req.param('id'))
if (!user) return c.json({ error: 'not found' }, 404)
return c.json(user)
})测试时构造 mock,通过 app.use() 注入:
// tests/routes/users-mock.test.ts
function createTestApp(userService: UserService) {
const testApp = new Hono()
testApp.use('*', async (c, next) => {
c.set('userService' as any, userService)
await next()
})
testApp.route('/', app)
return testApp
}
describe('users with mock service', () => {
it('找到用户返回 200', async () => {
const userService: UserService = {
findById: vi.fn().mockResolvedValue({ id: '42', name: 'Alice' }),
}
const res = await createTestApp(userService).request('/users/42')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: '42', name: 'Alice' })
expect(userService.findById).toHaveBeenCalledWith('42')
})
it('用户不存在返回 404', async () => {
const userService: UserService = {
findById: vi.fn().mockResolvedValue(null),
}
const res = await createTestApp(userService).request('/users/999')
expect(res.status).toBe(404)
})
})路由代码不关心 UserService 是真实实现还是 mock。
5. 集成测试模式
单元测试覆盖单个路由模块后,集成测试验证主 app 上路由、中间件、依赖组装在一起的完整行为:
// src/app.ts
export function createApp(userService: UserService, postService: PostService) {
const app = new Hono().basePath('/api/v1')
app.use('*', cors())
app.use('*', async (c, next) => {
c.set('userService' as any, userService)
c.set('postService' as any, postService)
await next()
})
app.route('/users', users)
app.route('/posts', posts)
return app
}// tests/integration/app.test.ts
describe('集成测试', () => {
const app = createApp(
{ findById: vi.fn().mockResolvedValue({ id: '1', name: 'Alice' }) },
{ findAll: vi.fn().mockResolvedValue([{ id: '1', title: 'Hello' }]) },
)
it('GET /api/v1/users/1 走完整链路', async () => {
const res = await app.request('/api/v1/users/1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: '1', name: 'Alice' })
})
it('CORS 中间件生效', async () => {
const res = await app.request('/api/v1/users/1', {
headers: { Origin: 'http://example.com' },
})
expect(res.headers.get('Access-Control-Allow-Origin')).toBeTruthy()
})
})6. 快照测试与流式响应
快照测试适合响应格式稳定、字段较多的场景:
// tests/routes/snapshot.test.ts
it('响应格式与快照一致', async () => {
const res = await app.request('/users/42')
const body = await res.json()
expect(res.status).toBe(200)
expect(body).toMatchSnapshot()
})响应里包含时间戳、UUID 等动态值时,先替换为占位符再快照:
it('快照中动态字段已替换', async () => {
const body = await res.json() as Record<string, unknown>
body.createdAt = '[TIMESTAMP]'
expect(body).toMatchSnapshot()
})快照测试适合字段多、手动 toEqual 容易遗漏的场景;字段少的路由直接断言更清晰。
流式响应(c.stream())的测试需要逐块读取 body:
// tests/routes/stream.test.ts
it('返回流式内容', async () => {
const res = await app.request('/stream')
expect(res.status).toBe(200)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
}
expect(content).toContain('chunk1')
})流式测试验证响应包含预期内容即可,不需要验证 chunk 的精确边界——那取决于运行时的缓冲区行为。
7. 常见的测试误区
测试行为而不是实现
// ❌ 只验证了方法调用
const spy = vi.spyOn(userService, 'findById')
await app.request('/users/42')
expect(spy).toHaveBeenCalled()
// ✅ 验证输入输出
const res = await app.request('/users/42')
expect(await res.json()).toEqual({ id: '42', name: 'Alice' })断言 service 方法是否被调用属于实现细节。如果将来换了实现方式,测试就会失败,但行为其实没变。
不要在一个测试里塞太多断言
// ❌ 一个用例验证所有场景
it('用户路由正常工作', async () => {
expect((await app.request('/users/1')).status).toBe(200)
expect((await app.request('/users/0')).status).toBe(400)
expect((await app.request('/users/999')).status).toBe(404)
})
// ✅ 每个用例验证一个场景
it('找到用户返回 200', async () => { /* ... */ })
it('无效 id 返回 400', async () => { /* ... */ })
it('用户不存在返回 404', async () => { /* ... */ })一个用例验证多件事,其中一个断言失败后后面的都不会执行,排查问题时也看不出是哪个场景出了问题。
不要忽略状态码
// ❌ 只验证响应体
const body = await res.json()
expect(body).toEqual({ id: '42' })
// ✅ 先断言状态码
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: '42' })一个 500 错误碰巧返回了正确格式的 JSON body,不检查状态码就能通过测试。
8. 测试文件的组织
测试目录结构建议与路由模块对应:
tests/
├── routes/
│ ├── users.test.ts ← 对应 src/routes/users.ts
│ ├── posts.test.ts ← 对应 src/routes/posts.ts
│ └── auth.test.ts ← 对应 src/routes/auth.ts
├── middleware/
│ ├── timing.test.ts ← 对应 src/middleware/timing.ts
│ └── auth.test.ts ← 对应 src/middleware/auth.ts
└── integration/
└── app.test.ts ← 完整应用的集成测试
每个测试文件只导入对应的模块,不测试完整 app。集成测试单独放一个文件验证模块组装后的行为。新增一个路由模块就加一个测试文件,目录结构本身就是路由的镜像。
延伸阅读
- 09-错误处理 — 路由错误处理的完整机制
- 03.04-Context 上下文对象原理 —
c.set()/c.get()的依赖注入机制 - 20-测试体系 — Hono 项目的完整测试方案
- Hono Testing 文档 — Hono 官方测试指南
- Vitest 文档 — vitest 测试框架
总结
Hono 的路由测试围绕一个核心特性展开:app.request() 可以在不启动服务器的情况下完成完整的请求-响应循环。没有全局状态、没有端口占用、没有测试间的隐式依赖。
回顾这一篇涉及到的测试层次:
- 单路由测试:直接对子模块 app 发请求,验证路径参数、查询参数、请求体的解析和响应格式
- 中间件测试:创建 mini-app,只挂载中间件和一个捕获路由,验证中间件的行为
- 依赖 Mock:通过
c.set()注入 mock service,路由代码不需要感知测试和生产环境的区别 - 集成测试:用
createApp()组装所有模块和中间件,验证完整链路 - 快照测试:适合字段多、格式稳定的响应,动态字段需要先替换再快照
路由系列到这里就结束了。从最基础的路由注册、参数解析,到分组挂载、错误处理,再到最后的测试验证,这十二篇覆盖了 Hono 路由系统的主要实践。
接下来进入 05-Hono 中间件,会系统展开 Hono 内置中间件的用法和自定义中间件的编写模式。中间件在路由系列里已经多次出现,下一篇会把它们集中拆开来讲。