- 后端
【免费下载链接】oak
A middleware framework for handling HTTP with Deno, Node, Bun and Cloudflare Workers 🐿️ 🦕
在 oak 中编写中间件并不难,难的是在没有真实网络端口的情况下,为中间件写出一份稳定、快速、可断言的单元测试。oak 官方将内部使用的 mocking 库通过testing命名空间暴露为公共 API,让你可以在测试中直接创建模拟的Application、Context与next(),从而在不监听端口、不发起真实 HTTP 请求的前提下完成对中间件行为的完整验证。阅读本文后,你将掌握createMockContext()、createMockNext()、createMockApp()的完整参数与内部原理,并能像官方仓库的etag、proxy、serve、send等中间件测试那样,写出可复用、可维护的中间件单元测试。
本文基于当前仓库(@oak/oak17.2.0,见 deno.json)的官方文档 docs/testing.md 与 testing.ts 源码展开,所有示例均与仓库实现保持一致。
为什么中间件需要 Mock?——动机与设计思路
单元测试中间件的难点在于:中间件函数(ctx, next) => ...依赖一个完整的Context对象——它包含request(请求)、response(响应)、cookies(Cookie 存取)、state(应用状态)等复杂结构。若要在测试中构造真实环境,往往需要:
- 打开一个网络端口(
app.listen(...)); - 通过真实 HTTP 请求把数据送入应用;
- 再断言响应的结果。
这个过程既慢又不稳定,而且会让原本简单的单元测试退化成集成测试。oak 的解决方案是:将内部用于自身测试的 mocking 库直接作为公共 API 导出,让使用者在测试中免去网络开销,直接构造行为可控的 mock 上下文。
从源码结构看,该能力沉淀在独立的 testing.ts 模块中,模块头部注释明确了其定位:
A collection of utility APIs which can make testing of an oak application easier.
导入方式:testing命名空间
testing命名空间从 oak 的入口 mod.ts 统一导出:
// mod.ts /** Utilities for making testing oak servers easier. */ export * as testing from "./testing.ts";因此在测试文件中,你可以像官方文档那样从模块入口一次性导入所有工具:
import { testing } from "https://deno.land/x/oak/mod.ts";当前仓库也发布在 JSR 上(deno.json中name为@oak/oak),并专门为测试模块声明了子路径导出"./testing": "./testing.ts"(见 deno.json),因此也可以按需导入:
import { createMockApp, createMockContext, createMockNext, mockContextState, } from "jsr:@oak/oak/testing";如果你更习惯通过主入口使用,testing.createMockContext(...)这种命名空间式的调用在仓库内部测试与官方文档示例中都很常见。
createMockApp():模拟 Application
createMockApp(state?)用于创建一个 mock 的Application实例,它接收一个可选的state参数,表示应用的初始状态:
export function createMockApp< S extends Record<PropertyKey, any> = Record<string, any>, >( state: S = {} as S, ): Application<S> { // ... }关键行为(来自 testing.ts 的实现):
state:默认值为{}。传入的对象会直接作为app.state返回;use():返回app自身,支持链式调用,便于在测试里「假装注册」中间件而不产生副作用;Deno.customInspect与nodejs.util.inspect.custom:分别针对 Deno 与 Node/Bun 环境定制了控制台输出(打印为MockApplication {}),避免调试输出与真实应用混淆。
仓库中的 testing.test.ts 对它有直接验证:
Deno.test({ name: "testing - createMockApp()", fn() { const app = createMockApp(); assertEquals(app.state, {}); }, }); Deno.test({ name: "testing - createMockApp() - with state", fn() { const app = createMockApp({ a: "a" }); assertEquals(app.state, { a: "a" }); }, });createMockContext():最核心的 Mock 工具
createMockContext()是这套工具中使用频率最高、能力最完整的 API:它生成一个既可作为普通Context、也可作为RouterContext使用的 mock 上下文,并接受一组配置选项。
完整选项说明
官方文档给出了如下接口:
export interface MockContextOptions< P extends RouteParams = RouteParams, S extends State = Record<string, any>, > { app?: Application<S>; method?: string; params?: P; path?: string; state?: S; }而当前仓库源码 testing.ts 中,该接口已进一步扩展为:
export interface MockContextOptions< R extends string, P extends RouteParams<R> = RouteParams<R>, S extends State = Record<string, any>, > { app?: Application<S>; ip?: string; method?: string; params?: P; path?: string; state?: S; headers?: [string, string][]; body?: ReadableStream; }各选项的语义与默认值如下:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
app | Application<S> | createMockApp(state) | 指定 mock 应用;不传时自动创建,并继承state |
ip | string | "127.0.0.1" | 模拟请求的来源 IP,可通过ctx.request.ip读取 |
method | string | "GET" | 模拟请求的 HTTP 方法 |
params | RouteParams<R> | undefined | 路由参数,用于把 mock 上下文当RouterContext使用 |
path | string | "/" | 模拟请求的路径,将反映在ctx.request.url中 |
state | S | 由app决定 | 应用状态;ctx.state会从app.state浅拷贝而来 |
headers | [string, string][] | 无 | 以二元组数组形式预设请求头,例如[["cookie", "..."]] |
body | ReadableStream | undefined | 请求体流;传入后ctx.request.hasBody为true,且ctx.request.body可用 |
提示:泛型参数
R extends string表示路由路径字符串字面量类型,P是由它推导出的路由参数类型,S是应用状态类型。例如createMockContext<"/users/:id">()能让ctx.params.id获得类型提示。
返回的 mock 上下文内部构成
从 testing.ts 的实现可以清晰看到 mock 上下文的「真实」与「模拟」边界:
request:一个按需构造的 mockRequest。它的sourcegetter 会基于path与method现场生成一个标准的globalThis.Request(new URL(path, "http://localhost/"));url同样解析为http://localhost/<path>的URL对象;searchParams是空URLSearchParams;accepts(...)根据Accept头调用@std/http的协商工具(见 deps.ts);acceptsEncodings()则读取全局的mockContextState.encodingsAccepted(详见下文);response:一个真实的Response实例,拥有完整的headers、status、body、redirect()、toDomResponse()等能力(参考 response.ts 的实现),因此对响应的断言与线上行为完全一致;cookies:一个真实的SecureCookieMap(来自@oak/commons),同时串起 mock 的 request 与 response,ctx.cookies.set()写入的Set-Cookie会出现在ctx.response.headers中;state:Object.assign({}, app.state),即对app.state的一次浅拷贝,避免测试间状态串扰;assert()与throw():与真实Context一致的便捷方法(context.ts 中同名方法行为相同),分别用于断言失败时抛出指定状态码的HttpError,或直接抛出错误,让依赖这两个方法的中间件在 mock 下也能正常工作。
仓库的 testing.test.ts 给出了行为验证:默认情况下ctx.request.method === "GET"、ctx.request.url.pathname === "/"、ctx.state为{};通过headers传入 Cookie 后ctx.cookies.get("sessionID")能取回对应值;而ctx.response.redirect("/hello/world")会把Location头设为/hello/world。
createMockNext():模拟 next() 调用链
createMockNext()创建一个可直接当作next参数传入中间件的函数,其实现非常简洁(testing.ts):
/** Creates a mock `next()` function which can be used when calling * middleware. */ export function createMockNext(): () => Promise<void> { return async function next() {}; }它返回一个立即 resolve 的异步函数,在测试中扮演「链上最后一个中间件」的角色:当被测中间件调用await next()时,控制流会立刻回到中间件内部继续执行——这正是测试「洋葱模型」中间件后半段逻辑(如设置响应体、追加响应头)的关键。
testing.test.ts 的验证确认了它返回的是一个返回Promise的函数。
实战示例:从官方用例到进阶用法
1. 官方标准用例:断言路径匹配后的响应
以下示例完整来自 docs/testing.md:创建一段检查请求路径是否为/a的中间件,若匹配则在响应中写入 body 与响应头,随后用 mock 上下文直接驱动它并断言:
import { testing } from "https://deno.land/x/oak/mod.ts"; import type { Middleware } from "https://deno.land/x/oak/mod.ts"; import { assert, assertEquals } from "https://deno.land/std/testing/asserts.ts"; const mw: Middleware = async (ctx, next) => { await next(); if (ctx.request.url.pathname === "/a") { ctx.response.body = "Hello a"; ctx.response.headers.set("x-hello-a", "hello"); } }; Deno.test({ name: "example test", async fn() { const ctx = testing.createMockContext({ path: "/a", }); const next = testing.createMockNext(); await mw(ctx, next); assertEquals(ctx.response.body, "Hello a"); assert(ctx.response.headers.has("x-hello-a")); }, });这个用例展示了中间件测试的三个固定动作:构造 mock 上下文 → 调用中间件 → 对 ctx.response 断言。由于不涉及任何端口与真实请求,它可以被deno test以毫秒级速度并行执行。
2. 以 RouterContext 身份测试路由中间件
createMockContext()的返回值类型是RouterContext,因此传入params后即可直接测试依赖路由参数ctx.params的中间件:
import { testing } from "jsr:@oak/oak"; import type { RouterMiddleware } from "jsr:@oak/oak"; const userMw: RouterMiddleware<"/users/:id"> = async (ctx, next) => { await next(); ctx.response.body = `user:${ctx.params.id}`; }; Deno.test({ name: "route params in mock context", async fn() { const ctx = testing.createMockContext({ path: "/users/42", params: { id: "42" }, }); await userMw(ctx, testing.createMockNext()); assertEquals(ctx.response.body, "user:42"); }, });3. 通过headers与cookies测试 Cookie 相关逻辑
mock 上下文内置真实的SecureCookieMap,因此涉及 Cookie 读写的中件间可以像真实环境一样测试:
Deno.test({ name: "mock cookies", async fn() { const ctx = testing.createMockContext({ headers: [["cookie", "sessionID=S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro;"]], }); assertEquals( await ctx.cookies.get("sessionID"), "S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro", ); await ctx.cookies.set("sessionID", "new-value", { httpOnly: true }); assert(ctx.response.headers.has("set-cookie")); }, });(以上断言逻辑与仓库 testing.test.ts 中的 Cookie 测试一致。)
4. 通过app与state测试依赖应用状态的中间件
const mw: Middleware<{ user: string }> = async (ctx, next) => { await next(); ctx.response.body = `hello, ${ctx.state.user}`; }; Deno.test({ name: "middleware reads app state", async fn() { const ctx = testing.createMockContext({ app: testing.createMockApp({ user: "oak" }), }); await mw(ctx, testing.createMockNext()); assertEquals(ctx.response.body, "hello, oak"); }, });mockContextState:控制内容协商结果的全局开关
除了三个create*函数,testing命名空间还导出一个特殊的全局状态对象mockContextState(testing.ts):
/** Allows external parties to modify the context state. */ export const mockContextState = { /** Adjusts the return value of the `acceptedEncodings` in the context's * `request` object. */ encodingsAccepted: "identity", };它用于调整 mock 上下文中ctx.request.acceptsEncodings()的返回值,默认是"identity"。当你需要测试「客户端请求 gzip / brotli 压缩」这类内容协商分支时,可以在测试中临时改写它,并在finally中恢复:
mockContextState.encodingsAccepted = "gzip"; try { const ctx = testing.createMockContext(); assertEquals(ctx.request.acceptsEncodings("gzip"), "gzip"); } finally { mockContextState.encodingsAccepted = "identity"; }这正是仓库 testing.test.ts 的写法。而 send.test.ts 更是把它用在了对send()静态文件服务的测试上:测试分别把encodingsAccepted设为"gzip"、"br"、"identity",再断言send()返回的是对应压缩后的文件字节(对比fixtures/test.json.gz等 fixture),从而在不启动真实服务器的情况下覆盖了完整的压缩分支逻辑。同理,middleware/etag.test.ts 在每个测试前也会重置encodingsAccepted以保证用例隔离。
仓库内部如何用它:官方中间件测试范式
testing命名空间并非仅面向外部用户——oak 自己的中间件与核心模块测试大量复用了这些工具,这正是验证其可用性与稳定性的最佳证据:
| 测试文件 | 用法要点 |
|---|---|
| middleware/etag.test.ts | 用createMockApp()+createMockContext({ app, path, method })构造上下文,测试etag中间件对不同 body 类型(字符串、Uint8Array、File、对象、异步可迭代对象)生成的 ETag 头 |
| middleware/proxy.test.ts | 大量使用createMockContext({ path })与createMockNext(),配合注入的fetch桩函数,测试代理中间件的转发、match匹配(字符串/正则/函数)等行为 |
| middleware/serve.test.ts | 用createMockApp()构造应用、createMockNext()提供 next,配合真实NativeRequest构造上下文,测试serve()与route() |
| send.test.ts | 结合mockContextState.encodingsAccepted测试静态文件发送与 gzip/br 压缩 |
| testing.test.ts | 对testing自身三个工厂函数与全局状态的专项测试 |
另外值得注意的是,router.test.ts 中虽然定义了一套独立的本地 mock 辅助函数,但它的结构与testing.ts完全同构(createMockApp、createMockContext、createMockNext),说明这套「mock 上下文驱动中间件」的测试范式就是 oak 开发团队内部统一采用的方法。
在本地运行测试
仓库在 deno.json 中提供了标准化的测试任务:
"tasks": { "coverage": "deno coverage --lcov ./cov", "example": "deno run --allow-net examples/echoServer.ts", "test": "deno test --allow-read --allow-write --allow-net --parallel --ignore=npm", "test:coverage": "deno test --coverage=./cov --junit-path=junit.xml --allow-read --allow-write --allow-net --cert ./examples/tls/RootCA.crt --parallel --ignore=npm" }你可以在仓库根目录运行deno task test执行全部测试(包括testing.test.ts与各中间件测试),或用deno test testing.test.ts单独运行 mock 工具自身的测试。由于 mock 上下文不涉及网络端口,这些测试天然适合--parallel并行执行。
小结
testing命名空间把「中间件单元测试」的成本降到了最低:
createMockContext()是绝对主角——一份完整的 mock 上下文,兼具真实Response、真实SecureCookieMap、可配置的 mockRequest,既能当Context又能当RouterContext用;createMockNext()用一行代码补齐中间件调用链;createMockApp(state)提供了测试所需的最小应用外壳;mockContextState为内容协商相关测试提供了全局开关。
官方文档与仓库源码的高度一致性(docs/testing.md 描述的能力与 testing.ts 的实现逐项对应)意味着:当你为中间件补测试时,完全可以把仓库内部中间件测试当作范本。唯一需要注意的是,当前源码中MockContextOptions比文档版多出ip、headers、body三个选项,且泛型约束为R extends string——以源码为准即可获得更强的类型提示与更完整的测试能力。
- 后端
【免费下载链接】oak
A middleware framework for handling HTTP with Deno, Node, Bun and Cloudflare Workers 🐿️ 🦕
相关推荐
EiBlog好玩功能探索:Disqus评论另类实现与谷歌统计异步集成
EiBlog好玩功能探索:Disqus评论另类实现与谷歌统计异步集成 EiBlog是一款基于Golang开发的快速博客系统,它不仅性能出色,还提供了许多实用的功
ZCF 测试修复实战:i18n 命名空间重构后的单元测试迁移指南
ZCF 测试修复实战:i18n 命名空间重构后的单元测试迁移指南 本文基于 zcf(Zero Config Code Flow)仓库中 .zcf/plan/hi
开发工具CLIAI 应用深入理解TestableMock:轻量级单元测试Mock工具
深入理解TestableMock:轻量级单元测试Mock工具 什么是TestableMock? TestableMock是一款专注于简化单元测试中Mock操作的
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考