路由测试

要点

  • 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。集成测试单独放一个文件验证模块组装后的行为。新增一个路由模块就加一个测试文件,目录结构本身就是路由的镜像。

延伸阅读

总结

Hono 的路由测试围绕一个核心特性展开:app.request() 可以在不启动服务器的情况下完成完整的请求-响应循环。没有全局状态、没有端口占用、没有测试间的隐式依赖。

回顾这一篇涉及到的测试层次:

  1. 单路由测试:直接对子模块 app 发请求,验证路径参数、查询参数、请求体的解析和响应格式
  2. 中间件测试:创建 mini-app,只挂载中间件和一个捕获路由,验证中间件的行为
  3. 依赖 Mock:通过 c.set() 注入 mock service,路由代码不需要感知测试和生产环境的区别
  4. 集成测试:用 createApp() 组装所有模块和中间件,验证完整链路
  5. 快照测试:适合字段多、格式稳定的响应,动态字段需要先替换再快照

路由系列到这里就结束了。从最基础的路由注册、参数解析,到分组挂载、错误处理,再到最后的测试验证,这十二篇覆盖了 Hono 路由系统的主要实践。

接下来进入 05-Hono 中间件,会系统展开 Hono 内置中间件的用法和自定义中间件的编写模式。中间件在路由系列里已经多次出现,下一篇会把它们集中拆开来讲。