news 2026/9/21 1:43:12

Egg 单元测试 Mock 模式实战指南:@eggjs/mock 的 mm()、mockHttpclient 与 mockCsrf 全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Egg 单元测试 Mock 模式实战指南:@eggjs/mock 的 mm()、mockHttpclient 与 mockCsrf 全解析
  • 后端
  • Web框架

【免费下载链接】egg

🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode

项目地址:https://gitcode.com/gh_mirrors/eg/egg
点击查看免费下载

导读

本文是 EGG 单元测试技能(egg-unittest)中 Mock 模式(references/mock.md)的完整实战指南,聚焦@eggjs/mock提供的四种核心 Mock 手段:mm()原型方法 Mock、mm.spy()调用记录、app.mockHttpclient()外部 HTTP 请求 Mock、app.mockCsrf()跳过 CSRF 校验。读完本文,你将掌握在 Vitest +@eggjs/mock/bootstrap测试环境下对 DI(依赖注入)对象、外部 API、安全校验进行精确打桩与断言的方法,并理解其底层实现原理与自动恢复机制。


一、为什么需要 Mock 模式

在 EGG 应用中,单元测试的目标是隔离被测对象、控制外部依赖。常见需要 Mock 的场景包括:

  • 被测 Service 依赖其他 DI 对象(如OrderService依赖UserServiceNotifyService);
  • 业务代码通过@Inject() httpclient: HttpClient发起的外部 HTTP 调用(第三方 API、支付网关等);
  • POST/PUT/DELETE 请求触发安全插件的 CSRF 校验导致 403。

Mock 的核心价值是在不修改业务代码的前提下,替换或观察依赖行为,从而让测试稳定、快速、可重复。测试运行环境由 egg-bin(Vitest)启动,app实例与mm工具从@eggjs/mock/bootstrap统一导入。

二、常见错误速查表

错误写法正确写法说明
mm(service, 'method', fn)mm(ServiceClass.prototype, 'method', fn)DI 对象需 mock 原型,不是实例
手动写afterEach(mm.restore)不需要egg-bin 自动注入 mock 恢复
new Ajv()mock 单独实例mock 原型方法DI 容器管理的对象通过原型 mock

为什么必须是原型而非实例?

EGG 的 tegg DI 容器在运行时通过app.getEggObject(Class)获取对象实例,实例的方法来自其原型链。如果对某个具体实例service直接mm(service, 'method', fn),该替换只存在于这一个实例上;而测试代码通过app.getEggObject()拿到的往往是容器新建或复用的另一个实例,Mock 根本不会生效。因此必须mm(ServiceClass.prototype, 'method', fn),从类型定义层面全局生效。这一规则与mm()的源码实现(基于mm库对目标对象属性进行替换)一致,见 plugins/mock/src/index.ts。

三、mm() — Mock Proto 方法

mm()是最常用的 Mock 方式,用于替换 DI 对象的原型方法,使其返回固定数据或执行自定义逻辑。

import assert from 'node:assert'; import { app, mm } from '@eggjs/mock/bootstrap'; import { UserService } from '../app/modules/user/UserService.ts'; import { OrderService } from '../app/modules/order/OrderService.ts'; describe('OrderService', () => { it('should mock user service', async () => { mm(UserService.prototype, 'getById', async () => { return { id: '1', name: 'mocked user' }; }); const orderService = await app.getEggObject(OrderService); const result = await orderService.createForUser('1'); assert.equal(result.userName, 'mocked user'); }); });

要点:

  • Mock 函数应为async 函数(若原始方法是异步方法),保证 Promise 语义一致;
  • 通过await app.getEggObject(OrderService)获取被测 DI 对象;
  • 断言应针对业务结果(result.userName === 'mocked user')而非 Mock 函数本身,验证OrderService正确消费了UserService的返回值。

调用信息断言:called / calledArguments / lastCalledArguments

Mock 函数会自动记录调用信息,可用来断言"是否正确调用、以什么参数调用":

import assert from 'node:assert'; import { app, mm } from '@eggjs/mock/bootstrap'; import { NotifyService } from '../app/modules/notify/NotifyService.ts'; import { OrderService } from '../app/modules/order/OrderService.ts'; it('should call notify with correct args', async () => { const mockFn = async (userId: string, message: string) => {}; mm(NotifyService.prototype, 'send', mockFn); const orderService = await app.getEggObject(OrderService); await orderService.create({ productId: '1' }); assert.equal(mockFn.called, 1); // 调用次数 assert.deepStrictEqual(mockFn.lastCalledArguments, ['user-1', '订单创建成功']); // 最后一次调用参数 // mockFn.calledArguments — 所有调用参数的数组 });

Mock 函数上自动附加的断言属性:

属性含义示例用法
called被调用次数assert.equal(mockFn.called, 1)
calledArguments所有调用参数的数组(二维)assert.deepStrictEqual(mockFn.calledArguments, [['user-1', 'msg']])
lastCalledArguments最后一次调用的参数列表assert.deepStrictEqual(mockFn.lastCalledArguments, ['user-1', 'msg'])

这种"行为打桩 + 调用记录"的组合,是验证 Service 之间协作关系的标准手法。

四、mm.spy() — 不替换实现,只记录调用

当希望原方法正常执行、同时又想观察它是否被调用时,使用mm.spy()

it('should spy on method', async () => { mm.spy(NotifyService.prototype, 'send'); const orderService = await app.getEggObject(OrderService); await orderService.create({ productId: '1' }); // 原方法正常执行,同时记录了调用信息 const sendFn = NotifyService.prototype.send; assert.equal(sendFn.called, 1); assert.equal(sendFn.lastCalledArguments[0], 'user-1'); });

mm()的区别:

  • mm()用自定义函数替换原实现,控制返回值/行为;
  • mm.spy()保留原实现,仅附加calledcalledArgumentslastCalledArguments等记录属性,用于"确认发生过调用、参数正确"的验证型断言;
  • 由于原型方法被记录属性包装,断言可直接从NotifyService.prototype.send读取。

适用场景:日志发送、消息通知、埋点上报等"副作用型"方法——你关心的是"有没有调、参数对不对",而不是返回值。

五、app.mockHttpclient() — Mock HttpClient 请求

业务代码通过@Inject() httpclient: HttpClient注入的 HttpClient 发起的请求,可用app.mockHttpclient()整体打桩,避免测试真正访问外部网络:

it('should mock external API', () => { app.mockHttpclient('https://api.example.com/users', { data: JSON.stringify({ name: 'test' }), }); return app.httpRequest().get('/api/proxy/users').expect(200).expect({ name: 'test' }); });

参数签名与重载

实现见 plugins/mock/src/app/extend/application.ts 与 plugins/mock/src/lib/mock_httpclient.ts:

mockHttpclient( mockUrl: string | RegExp, // 匹配的 URL(支持正则) mockMethod?: string | string[], // HTTP 方法,默认 '*' mockResult?: string | MockResultOptions | MockResultFunction, ): this
  • 二参重载app.mockHttpclient(url, mockResult)mockMethod默认为*(匹配所有方法);
  • 三参重载app.mockHttpclient(url, 'GET', mockResult)指定方法,方法会统一转为大写;
  • mockUrl为字符串时解析为origin + pathname匹配;为RegExp时按origin + path整体正则匹配(可命中多条 Mock 配置)。

MockResultOptions 完整字段

字段类型默认值说明
datastring \| Buffer \| Object''响应体;Object 会被自动JSON.stringify序列化
statusnumber200HTTP 状态码
headersRecord<string, string>{}响应头
delaynumber延迟响应的毫秒数,可模拟慢接口
persistbooleantrue是否无限次命中该 Mock,默认始终生效
repeatsnumber固定命中次数(persist: false时生效)

源码层面的行为细节(mock_httpclient.ts):

  • data为对象时转为 JSON Buffer,为字符串时按 UTF-8 编码为 Buffer,否则抛错;
  • 底层基于urllib的 MockAgent(undici 拦截器)实现,persist默认true,意味着同一条 URL 的 Mock 在恢复前会一直命中,同一测试内多次调用无需重复声明;
  • mockResult也支持函数形式:(url, options) => MockResultOptions | string,可依据请求pathmethodbody动态返回结果,适合区分同一接口的不同请求场景。

组合使用建议

外部 API 的 Mock 常与接口测试链式配合:先用mockHttpclient挡住下游 HTTP 依赖,再通过app.httpRequest()(supertest)驱动 Controller 完整链路,最终只断言业务侧响应。

六、app.mockCsrf() — 跳过 CSRF 校验

POST/PUT/DELETE 请求默认会触发安全插件的 CSRF 校验(未携带 token 时返回 403)。测试中可直接跳过:

it('should POST without CSRF error', () => { app.mockCsrf(); return app.httpRequest().post('/api/users').send({ name: 'test' }).expect(200); });

实现位于 application.ts:

mockCsrf(): this { mock(this.context, 'assertCSRF', () => {}); mock(this.context, 'assertCsrf', () => {}); return this; }

即对 Context 原型上的assertCSRF/assertCsrf方法做空替换,使校验直接通过。使用注意:

  • 只需在需要写操作的测试中调用;GET 等读操作无需;
  • 该方法只影响当前app实例,afterEach恢复后自动失效;
  • 若测试本身要验证 CSRF 防护逻辑(如 403 场景),则不要调用mockCsrf()

七、Mock 恢复:自动注入,无需手写

egg-bin 自动注入@eggjs/mock/setup_vitest,该模块在 Vitest 生命周期中自动完成三件事:

  1. beforeAll:单次启动 MockApplication(app),并对启动 Promise 做缓存,同一 worker 内只启动一次;
  2. afterEach:等待后台任务结束(app.backgroundTasksFinished()),并调用mm.restore()恢复所有 Mock;
  3. afterAll:按共享模式判断是否关闭 app(isolate/threads 池场景下由 worker 线程回收)。

因此:

  • 不要手动编写afterEach(mm.restore),重复恢复没有意义且可能干扰生命周期;
  • restore()的实现(restore.ts)会依次执行mm库恢复、cluster 恢复、MockAgent 恢复,保证mm()mm.spy()mockHttpclientmockCsrf等所有 Mock 全部还原到初始状态,测试之间互不污染。

八、与其他 Mock API 的配合

在 DI 场景下,mm()通常与以下 API 组合使用,构成完整测试体系(详见 egg-unittest 技能总览):

API用途
app.getEggObject(Class)获取 SingletonProto / ContextProto 实例
app.mockContext(data)Mock Context 属性
app.mockService(name, method, fn)Mock Service(字符串路径或类)
app.mockServiceError(name, method, err)Mock Service 抛出指定错误
app.mockSession(data)/app.mockCookies(obj)Mock Session / Cookie
app.mockHeaders(headers)/app.mockLog()Mock 请求头 / 捕获日志
mm.env(env)/app.mockEnv(env)Mock 运行环境(test/prod 等)

需要 Mock 单测依赖而当前测试文件又没有app生命周期时,可参考mm.app()/mm.cluster()的创建入口(index.ts)手动创建 MockApplication。

九、总结

EGG 单元测试的 Mock 模式可以归纳为三条原则:

  1. DI 对象一律 Mock 原型mm(Class.prototype, 'method', fn),不要 Mock 实例;
  2. 验证型场景用mm.spy(),控制型场景用mm():前者记录调用、保留实现,后者替换实现、决定返回;
  3. 外部依赖与安全校验交给 app 级 APIapp.mockHttpclient()拦截 HttpClient,app.mockCsrf()跳过 CSRF,恢复由setup_vitestafterEach自动完成。

按此模式编写测试,即可获得稳定、隔离、可断言的 Service/DI 单元测试,为 EGG 应用的持续集成提供可靠保障。

  • 后端
  • Web框架

【免费下载链接】egg

🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode

项目地址:https://gitcode.com/gh_mirrors/eg/egg
点击查看免费下载

相关推荐

上一篇:打造无屏编程体验:claude-code-local语音交互模式全攻略
下一篇:VirtualApp CI/CD监控:监控构建和部署状态

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 1:42:33

Turbo Console Log 快捷键冲突?用 TaoToken 接入的 Codex 逐项排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/21 1:39:58

从技术要点到完整博客:素材驱动的写作方法论

简介&#xff1a;这是一份系统讲解OpenCV多传感器融合与位姿估计优化的技术文档&#xff0c;共483页&#xff0c;面向机器人、自动驾驶与视觉SLAM方向的中高级开发者&#xff0c;旨在解决时间同步、状态估计和传感器标定等工程落地难题。资源为单个PDF文件&#xff0c;大小12.7…

作者头像 李华
网站建设 2026/9/21 1:39:38

BrowserSkill截图指南:视口、元素、整页3种模式与参数速查表

BrowserSkill截图指南&#xff1a;视口、元素、整页3种模式与参数速查表 【免费下载链接】BrowserSkill Let AI agents use your real, logged-in browser without interrupting your work. CLI extension for browser automation across any shell-capable AI agent. 项目地…

作者头像 李华
网站建设 2026/9/21 1:38:16

Sails 框架 res.json() 完全指南:用法、源码实现与最佳实践

Sails 框架 res.json() 完全指南&#xff1a;用法、源码实现与最佳实践 【免费下载链接】sails Realtime MVC Framework for Node.js 项目地址: https://gitcode.com/gh_mirrors/sa/sails 导读 res.json() 是 Sails&#xff08;基于 Node.js 的 Realtime MVC 框架&…

作者头像 李华