Hono 工程化实战 01:用 Vitest 把接口测试写稳
Hono 项目最容易被忽略的一件事,是接口测试。
很多人写 Hono 是因为它轻、快、启动简单。于是测试也容易变得很随意:
1 2 3 4
| 本地 curl 一下 前端页面点一下 Postman 保存几个请求 上线后看日志
|
这些都不是测试。
真正的接口测试,应该能在每次提交、每次发版、每次重构时自动告诉你:
1 2 3 4 5
| 路由还在不在 状态码对不对 响应结构有没有变 错误码是否稳定 鉴权逻辑有没有绕过
|
Hono 很适合写测试,因为它基于 Web 标准 Request/Response,不需要真的启动一个 HTTP 端口,也能直接测试 app。
这篇文章从最基础的 app.request() 开始,讲 Hono 项目如何用 Vitest 写一套可靠的接口测试。
技术栈快照时间:2026-06-23。Hono Testing、Testing Helper、Cloudflare Workers 测试方案会更新,具体 API 以官方文档为准。
一、不要只测 service
很多后端项目会只测 service:
1 2 3
| createUser() updatePost() calculatePrice()
|
这当然有价值。
但 API 项目真正容易出问题的地方往往在接口层:
1 2 3 4 5 6 7
| 参数没有校验 路径写错 状态码不统一 错误格式不一致 middleware 顺序不对 鉴权没生效 CORS 或 header 配置漏了
|
所以 Hono 项目至少要有两类测试:
1 2
| service/unit test:测业务函数 route/integration test:测完整 HTTP 行为
|
app.request() 就是 route test 的核心工具。
二、最小测试示例
假设有一个 app:
1 2 3 4 5 6 7
| import { Hono } from 'hono'
export const app = new Hono()
app.get('/health', (c) => { return c.json({ ok: true }) })
|
测试可以这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { describe, expect, it } from 'vitest' import { app } from '../src/app'
describe('GET /health', () => { it('returns ok', async () => { const res = await app.request('/health')
expect(res.status).toBe(200)
const body = await res.json() expect(body).toEqual({ ok: true }) }) })
|
这里没有启动服务器。
没有监听端口。
没有网络请求。
只是把请求交给 Hono app,然后检查 Response。
这就是 Hono 测试体验舒服的地方。
三、测试 POST JSON
真实接口不会只有 GET。
比如创建文章:
1 2 3 4 5 6 7 8 9 10
| app.post('/api/posts', async (c) => { const input = await c.req.json()
return c.json({ data: { id: 'post_123', title: input.title, }, }, 201) })
|
测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| it('creates a post', async () => { const res = await app.request('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Hono 测试实践', content: '这是一篇文章', }), })
expect(res.status).toBe(201)
const body = await res.json() expect(body.data.id).toBe('post_123') expect(body.data.title).toBe('Hono 测试实践') })
|
这个测试覆盖了:
1 2 3 4 5
| 请求方法 请求路径 请求 body 响应状态码 响应 JSON 结构
|
不要只断言 status === 200。
状态码只是第一层。
真正重要的是响应结构。
四、错误响应一定要测
很多团队只测成功路径。
这会让错误响应越来越乱。
生产项目里,错误响应应该是稳定契约。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| app.post('/api/posts', async (c) => { const input = await c.req.json()
if (!input.title) { return c.json({ error: { code: 'POST_TITLE_REQUIRED', message: '文章标题不能为空', }, }, 400) }
return c.json({ data: { id: 'post_123' } }, 201) })
|
测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| it('returns validation error when title is missing', async () => { const res = await app.request('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ content: '没有标题', }), })
expect(res.status).toBe(400)
const body = await res.json() expect(body.error.code).toBe('POST_TITLE_REQUIRED') expect(body.error.message).toBe('文章标题不能为空') })
|
错误测试至少要覆盖:
1 2 3 4 5 6 7
| 缺少参数 参数格式错误 未登录 无权限 资源不存在 业务状态冲突 限流
|
如果错误码不测,前端迟早会被后端随手改字段坑到。
五、把 app 创建成工厂函数
测试里最烦的是全局状态。
如果 app 创建时就绑定真实数据库、真实队列、真实第三方接口,测试会很难写。
更推荐把 app 创建成工厂函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import { Hono } from 'hono'
type Services = { posts: { create(input: { title: string; content: string }): Promise<{ id: string title: string }> } }
export function createApp(services: Services) { const app = new Hono()
app.post('/api/posts', async (c) => { const input = await c.req.json() const post = await services.posts.create(input)
return c.json({ data: post }, 201) })
return app }
|
测试时注入 fake service:
1 2 3 4 5 6 7 8 9 10
| const app = createApp({ posts: { async create(input) { return { id: 'post_test', title: input.title, } }, }, })
|
这样接口测试就不用依赖真实数据库。
测试目标也更清楚:
1 2 3
| 这里测 route 行为 不是测数据库驱动 不是测第三方 API
|
六、测试 middleware 顺序
Hono 项目里 middleware 很关键。
比如:
1 2 3 4 5 6 7 8
| requestId logger secureHeaders CORS bodyLimit auth permission route handler
|
顺序错了,行为就会变。
可以写测试确认鉴权真的生效:
1 2 3 4
| app.get('/api/me', authMiddleware, async (c) => { const user = c.get('user') return c.json({ data: user }) })
|
测试未登录:
1 2 3 4 5 6 7 8
| it('rejects anonymous request', async () => { const res = await app.request('/api/me')
expect(res.status).toBe(401)
const body = await res.json() expect(body.error.code).toBe('AUTH_REQUIRED') })
|
测试已登录:
1 2 3 4 5 6 7 8 9 10 11 12
| it('returns current user', async () => { const res = await app.request('/api/me', { headers: { Authorization: 'Bearer test-token', }, })
expect(res.status).toBe(200)
const body = await res.json() expect(body.data.id).toBe('user_test') })
|
不要只相信“我写了 auth middleware”。
测试要证明它真的挡住了匿名请求。
生产接口经常依赖 headers。
比如:
1 2 3 4 5 6
| Cache-Control ETag Set-Cookie Location X-Request-ID Content-Type
|
这些也要测。
示例:
1 2 3 4 5 6 7
| it('sets cache header', async () => { const res = await app.request('/api/public/posts')
expect(res.headers.get('Cache-Control')).toBe( 'public, max-age=60, stale-while-revalidate=300' ) })
|
登录接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| it('sets session cookie', async () => { const res = await app.request('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email: '[email protected]', password: 'password', }), })
expect(res.status).toBe(200) expect(res.headers.get('Set-Cookie')).toContain('session=') })
|
接口行为不只在 body 里。
Header 也是契约的一部分。
八、用 testClient 获得类型提示
Hono 还有 Testing Helper,可以用 testClient() 生成类型化测试客户端。
如果你的路由类型导出得比较完整,测试会更舒服:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { testClient } from 'hono/testing' import { describe, expect, it } from 'vitest' import { createApp } from '../src/app'
const app = createApp(fakeServices) const client = testClient(app)
it('creates a post', async () => { const res = await client.api.posts.$post({ json: { title: 'Hono testClient', content: '类型提示很好用', }, })
expect(res.status).toBe(201) })
|
testClient() 的价值不是让测试更“高级”。
它的价值是减少路径、参数、请求 body 写错的概率。
在 TypeScript 项目里,这很实用。
九、Cloudflare Workers 怎么测
如果 Hono 跑在 Cloudflare Workers 上,普通 app.request() 能测很多逻辑。
但一旦涉及 Workers 特有能力,就要更认真:
1 2 3 4 5 6 7
| D1 KV R2 Queues Durable Objects ExecutionContext scheduled handler
|
Hono 官方文档在 Cloudflare Workers 场景下推荐关注 @cloudflare/vitest-pool-workers。
原因是它更接近 Workers 运行时。
我的建议:
1 2 3 4
| 纯 route 行为:app.request() service 业务逻辑:普通 Vitest Workers binding 行为:Workers 测试环境 端到端部署验证:预览环境跑 smoke test
|
不要用一个测试工具解决所有问题。
测试越贴近目标,越有价值。
十、测试目录怎么放
一个简单结构:
1 2 3 4 5 6 7 8 9 10 11 12 13
| src/ app.ts routes/ services/ repositories/ tests/ routes/ posts.test.ts auth.test.ts services/ price.test.ts helpers/ create-test-app.ts
|
如果项目很小,也可以把测试放在旁边:
1 2
| src/routes/posts.ts src/routes/posts.test.ts
|
我更偏向大一点的 API 项目用 tests/。
原因是接口测试通常会跨 route、service、fixture,不完全属于某一个源文件。
十一、哪些测试必须写
不用追求 100% 覆盖率。
Hono API 更应该优先测这些:
1 2 3 4 5 6 7 8 9 10
| 登录/退出/刷新 token 权限边界 核心 CRUD 支付/Webhook 后台任务创建 文件上传 公开 API 的错误响应 多租户隔离 缓存 header 限流行为
|
尤其是权限和多租户。
这类 bug 一旦线上发生,代价比普通字段错误大得多。
十二、一个实用测试清单
给每个重要接口至少写四类测试:
1 2 3 4
| 成功请求 参数错误 未授权或无权限 资源不存在或状态冲突
|
例如文章删除接口:
1 2 3 4 5 6 7 8
| DELETE /api/posts/:id
应该测试: 1. 作者可以删除自己的文章 2. 未登录用户返回 401 3. 不是作者返回 403 4. 文章不存在返回 404 5. 已发布文章不能直接删除返回 409
|
这比盲目追求覆盖率更有意义。
测试的目的不是让报表好看。
测试的目的是让你敢改代码。
Hono 已经把接口测试的门槛降得很低了。
剩下的,就是把它变成团队习惯。