Hono 工程化实战 01:用 Vitest 把接口测试写稳
wxk1991 Lv5

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

生产接口经常依赖 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 已经把接口测试的门槛降得很低了。

剩下的,就是把它变成团队习惯。