ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Egg 单元测试实战指南:基于 egg-unittest 技能与 @eggjs/mock 的完整测试方案

2026/9/21 3:05:53 拓冰建站 浏览量
Egg 单元测试实战指南:基于 egg-unittest 技能与 @eggjs/mock 的完整测试方案 后端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点击查看免费下载导读本文以仓库内 packages/skills/egg-unittest/SKILL.md 为骨架系统讲解 EGG 应用的单元测试方法论覆盖 HTTP 接口测试、Service/DI 对象测试、Mock 数据模拟、BackgroundTask 与 EventBus 测试五大场景并深入eggjs/mock源码验证其底层生命周期管理。读完本文你将掌握egg-bin test的测试启动机制、app.httpRequest()、app.getEggObject()、mm()等核心 API 的准确用法以及一套可直接套用的测试决策流程。测试原理egg-bin test 如何驱动 Vitest在 EGG 项目中单元测试的统一入口是egg-bin test对应eggjs/bin包。其底层使用 Vitest 作为测试运行器并自动完成三件关键工作自动创建 MockApplication以当前项目目录为 baseDir创建并启动一个 MockApplication 实例即测试中的app测试代码无需手动new Application()注入生命周期钩子自动注入eggjs/mock/setup_vitest通过beforeAll启动 app、afterEach恢复 mock、afterAll关闭 app注入 Vitest 全局变量describe、it、beforeAll等无需手动 import直接可用。测试代码中通过import { app, mm } from eggjs/mock/bootstrap获取已启动的 app 实例和 mock 工具直接使用即可。从源码 plugins/mock/src/setup_vitest.ts 可以看到生命周期钩子的具体实现beforeAll中缓存startupPromise保证每个 worker 只启动一次 app并通过await app.ready()等待应用就绪afterEach中先调用app.backgroundTasksFinished()等待后台任务完成再调用mock.restore()恢复所有 mockafterAll中在非共享模式下关闭 app在isolate: false或 threads 池共享模式下则交由 worker 线程回收。该文件还做了两件兼容性处理为 Mocha 用户提供兼容别名before→beforeAll、after→afterAll、beforeEach→beforeEach、afterEach→afterEach见setup_vitest.ts第 5~11 行自动配置eggjs/tegg-vitestrunner使app.currentContext在测试中可用第 21~29 行。此外plugins/mock/src/bootstrap.ts 中有一条值得注意的约束egg 插件项目package.json中含eggPlugin字段禁止使用 bootstrap 测试会直接抛出DO NOT USE bootstrap to test plugin插件开发者应改用其他测试方式。前置配置检查package.json确保项目package.json中包含以下内容{ scripts: { test: egg-bin test }, devDependencies: { eggjs/bin: ^8, eggjs/mock: ^8 } }eggjs/bin提供egg-bin test命令负责 Vitest 的启动与配置注入eggjs/mock提供 MockApplication、bootstrap 入口与全套 mock API。测试文件约定测试目录固定为test/测试文件命名约定为*.test.ts测试文件内无需importVitest 全局变量describe/it开箱即用。自定义 setup 文件可选如果存在test/.setup.tsegg-bin 会自动将其加入 vitest 的 setupFiles并在eggjs/mock/setup_vitest之前执行即 app 启动之前。常用于设置环境变量等全局初始化// test/.setup.ts beforeAll(() { process.env.SOME_CONFIG test-value; });注意.setup.ts中的beforeAll早于 app 启动适合做与 app 无关的全局准备工作若需要基于 app 的初始化应放在测试用例内部。HTTP 接口测试基本用法通过app.httpRequest()发起 HTTP 请求返回 SuperTest 对象import { app } from eggjs/mock/bootstrap; describe(UserController, () { it(should GET /api/users, () { return app.httpRequest().get(/api/users).expect(200).expect({ users: [] }); }); });POST 请求 CSRFPOST/PUT/DELETE 请求需要先调用app.mockCsrf()跳过 CSRF 校验安全插件默认开启 CSRF不 mock 会返回 403it(should POST /api/users, () { app.mockCsrf(); return app .httpRequest() .post(/api/users) .send({ name: test, email: testexample.com }) .expect(200) .expect({ id: 1, name: test }); });表单提交使用.type(form)it(should POST form data, () { app.mockCsrf(); return app.httpRequest().post(/api/login).type(form).send({ username: admin, password: 123 }).expect(200); });请求构造app .httpRequest() .get(/api/users) .set(Authorization, Bearer token123) // 设置 header .set(Accept, application/json) // 设置 Accept .query({ page: 1, limit: 10 }) // 查询参数 .expect(200);响应断言使用.expect()链式断言支持状态码、body、正则、header、多状态码与自定义断言函数import assert from node:assert; import { app } from eggjs/mock/bootstrap; it(should validate response, () { return app .httpRequest() .get(/api/users/1) .expect(200) // 只校验状态码 .expect({ id: 1, name: test }) // 只校验 bodydeepStrictEqual 全量匹配 .expect(200, { id: 1, name: test }) // 状态码 body 合并 .expect(hello world) // body 字符串匹配 .expect(/hello/) // body 正则匹配 .expect(content-type, /json/) // header 匹配 .expect([200, 302]) // 多状态码匹配任一即可 .expect((res) { // 自定义断言函数 assert(res.body.id); }); });需要更灵活断言时直接获取result对象import assert from node:assert; import { app } from eggjs/mock/bootstrap; it(should validate response, async () { const result await app.httpRequest().get(/api/users/1); assert.equal(result.status, 200); assert.equal(result.body.name, test); assert(result.body.id); assert.match(result.headers[content-type], /json/); });端到端示例import assert from node:assert; import { app } from eggjs/mock/bootstrap; describe(test/controller/user.test.ts, () { describe(GET /api/users/:id, () { it(should return user, () { return app.httpRequest().get(/api/users/1).expect(200).expect({ id: 1, name: test }); }); it(should return 404 when user not found, () { return app.httpRequest().get(/api/users/999).expect(404); }); }); describe(POST /api/users, () { it(should create user, () { app.mockCsrf(); return app.httpRequest().post(/api/users).send({ name: new user, email: newexample.com }).expect(201); }); it(should return 422 with invalid params, () { app.mockCsrf(); return app.httpRequest().post(/api/users).send({ name: }).expect(422); }); }); });HTTP 测试易错点错误写法正确写法说明POST 测试不加app.mockCsrf()在 POST 前调用app.mockCsrf()安全插件默认开启 CSRF不 mock 会返回 403app.httpRequest().get(/).expect(200)不 return/await必须return或await否则断言不会执行测试永远通过.expect({ foo: bar })用于部分匹配使用result.body手动断言.expect(body)是全量匹配deepStrictEqualService / DI 对象测试Singleton 测试SingletonProto对象直接通过app.getEggObject()获取返回 Promise必须 awaitimport assert from node:assert; import { app } from eggjs/mock/bootstrap; import { ConfigService } from ../app/modules/foo/ConfigService.ts; describe(ConfigService, () { it(should get config, async () { const configService await app.getEggObject(ConfigService); const value configService.get(key); assert.equal(value, expected); }); });ContextProto 测试ContextProto对象既可以直接通过app.getEggObject()获取也可以在app.mockModuleContextScope中通过ctx.getEggObject()获取。后者会创建带 DI 生命周期的 ctx退出作用域时自动销毁import assert from node:assert; import { app } from eggjs/mock/bootstrap; import { UserService } from ../app/modules/user/UserService.ts; describe(UserService, () { it(should get user in context scope, async () { await app.mockModuleContextScope(async (ctx) { const userService await ctx.getEggObject(UserService); const user await userService.getById(1); assert(user); }); }); });Mock 被注入的依赖当 Service A 依赖 Service B 时通过 mock B 的原型方法来替换实现import assert from node:assert; import { app, mm } from eggjs/mock/bootstrap; import { OrderService } from ../app/modules/order/OrderService.ts; import { PaymentService } from ../app/modules/payment/PaymentService.ts; describe(OrderService, () { it(should create order with mocked payment, async () { mm(PaymentService.prototype, charge, async () { return { transactionId: mock-tx-001 }; }); const orderService await app.getEggObject(OrderService); const order await orderService.create({ productId: 1, amount: 100 }); assert.equal(order.transactionId, mock-tx-001); }); });DI 对象测试易错点错误写法正确写法说明ctx.service.user.get()ctx.getEggObject(UserService)旧写法新项目用 DI不 awaitgetEggObjectconst svc await ctx.getEggObject(Svc)返回 PromiseMock 模式mm() — Mock Proto 方法最常用的 mock 方式mock DI 对象的原型方法。注意是Class.prototype不是实例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 函数会自动记录调用信息可用来断言调用次数与参数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 — 所有调用参数的数组 });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); });app.mockHttpclient() — Mock HttpClient 请求Mock 通过Inject() httpclient: HttpClient注入的 HttpClient 发送的外部请求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 }); });app.mockCsrf() — 跳过 CSRFPOST/PUT/DELETE 测试时跳过 CSRF 校验it(should POST without CSRF error, () { app.mockCsrf(); return app.httpRequest().post(/api/users).send({ name: test }).expect(200); });Mock 恢复机制egg-bin 自动注入eggjs/mock/setup_vitest会在afterEach钩子中自动调用mock.restore()见 plugins/mock/src/setup_vitest.ts 第 53~58 行无需手动编写afterEach(mm.restore)。Mock 易错点错误写法正确写法说明mm(service, method, fn)mm(ServiceClass.prototype, method, fn)DI 对象需 mock 原型不是实例手动写afterEach(mm.restore)不需要egg-bin 自动注入 mock 恢复new Ajv()mock 单独实例mock 原型方法DI 容器管理的对象通过原型 mockBackgroundTask 后台任务测试后台任务异步执行断言前必须确保任务完成。两种等待方式方式一mockModuleContextScope自动等待mockModuleContextScope退出时会自动等待所有后台任务完成内部触发doPreDestroyscope 退出后直接断言即可import assert from node:assert; import { app } from eggjs/mock/bootstrap; import { CountService } from ../app/modules/count/CountService.ts; it(should complete background task, async () { await app.mockModuleContextScope(async (ctx) { const countService await ctx.getEggObject(CountService); // countService 内部通过 backgroundTaskHelper.run() 触发后台任务 await countService.doSomething(); }); // scope 退出后后台任务已完成直接断言 const countService await app.getEggObject(CountService); assert.equal(countService.count, 1); });方式二backgroundTasksFinished手动等待不通过mockModuleContextScope触发的场景如 HTTP 接口测试scope 退出的自动等待机制不适用需要手动调用app.backgroundTasksFinished()import assert from node:assert; import { app } from eggjs/mock/bootstrap; import { CountService } from ../app/modules/count/CountService.ts; it(should complete background task, async () { await app.httpRequest().get(/api/trigger-task).expect(200); // 等待后台任务完成 await app.backgroundTasksFinished(); const countService await app.getEggObject(CountService); assert.equal(countService.count, 1); });backgroundTasksFinished同时是afterEach自动调用的钩子见 plugins/mock/src/app/extend/application.ts因此即使某个用例忘记手动等待也会在用例结束后被强制等待一次。BackgroundTask 易错点错误写法正确写法说明不等待就断言用mockModuleContextScope自动等待或app.backgroundTasksFinished()手动等待后台任务异步执行必须等待完成后再断言在mockModuleContextScope回调内断言后台任务结果在mockModuleContextScope返回后断言回调内任务尚未完成返回后才会等待完成用TimerUtil.sleep等待用app.backgroundTasksFinished()sleep 时间不确定backgroundTasksFinished精确等待EventBus 事件测试核心模式是使用app.getEventWaiter()获取事件等待器先注册等待await再触发业务逻辑最后验证 handler 调用import assert from node:assert; import { app, mm } from eggjs/mock/bootstrap; import { HelloService } from ../app/modules/hello/HelloService.ts; import { HelloHandler } from ../app/modules/hello/HelloHandler.ts; describe(EventBus, () { it(should handle event, async () { // mock handler 捕获调用参数 const mockFn async (msg: string) {}; mm(HelloHandler.prototype, handle, mockFn); await app.mockModuleContextScope(async (ctx) { const helloService await ctx.getEggObject(HelloService); const eventWaiter await app.getEventWaiter(); // 1. 先注册等待必须在 emit 之前 const eventPromise eventWaiter.await(helloEgg); // 2. 触发业务逻辑内部会 emit 事件 helloService.hello(); // 3. 等待 handler 执行完成 await eventPromise; }); // 4. 验证 handler 被调用及参数 assert.equal(mockFn.called, 1); assert.deepStrictEqual(mockFn.lastCalledArguments, [hello]); }); });EventBus 易错点错误写法正确写法说明先 emit 再eventWaiter.await()先eventWaiter.await()再触发业务逻辑await 注册监听器必须在事件发出前不等待事件处理完成就断言使用eventWaiter.await(eventName)等待后再断言handler 异步执行不等待则断言时可能尚未完成测试场景决策树面对一个新的测试需求按以下决策树选择测试方案要测什么 1. HTTP 接口GET/POST/PUT/DELETE → 参考 references/http-test.md 2. Service / DI 对象的方法 → 参考 references/service-test.md 3. 需要 mock 外部依赖HTTP 调用、Service 方法、Session、CSRF → 参考 references/mock.md 4. BackgroundTaskHelper后台异步任务 → 参考 references/background-task-test.md 5. EventBus事件驱动 → 参考 references/eventbus-test.md快速参考核心 API 一览API说明import { app, mm } from eggjs/mock/bootstrap标准测试入口另可导出assert、mockapp.httpRequest().get(/path).expect(200)HTTP 接口测试app.getEggObject(Class)获取 SingletonProto / ContextProto 实例app.mockModuleContextScope(async (ctx) { ... })ContextProto 测试作用域退出自动销毁并等待后台任务mm(Class.prototype, method, fn)Mock Proto 方法mm.spy(Class.prototype, method)只记录调用不替换实现app.mockCsrf()跳过 CSRF 校验POST 测试必备app.mockHttpclient(url, data)Mock 外部 HTTP 调用app.backgroundTasksFinished()等待所有后台任务完成app.getEventWaiter()获取 EventBus 事件等待器常见错误速查错误写法正确写法说明import { app } from eggimport { app } from eggjs/mock/bootstrap测试使用 mock 包before()/after()beforeAll()/afterAll()Vitest 钩子不是 MochaPOST 测试报 403加app.mockCsrf()安全插件默认开启 CSRF手动写afterEach(mm.restore)不需要egg-bin 自动注入 mock 恢复代码写在 describe 内、hooks 外放入beforeAll/beforeEachdescribe 体在加载阶段就执行await app.ready()配合 bootstrap不需要bootstrap 自动处理生命周期深入源码bootstrap 入口与生命周期想要彻底理解这套测试体系建议按以下路径阅读eggjs/mock仓库内位于 plugins/mock的关键实现plugins/mock/src/bootstrap.ts测试入口导出app、mm、mock、assert、getBootstrapApp同时校验 egg 插件项目不可使用 bootstrapplugins/mock/src/setup_vitest.tsVitest 生命周期注入beforeAll启动、afterEach等待后台任务 恢复 mock、afterAll关闭非共享模式plugins/mock/src/lib/app_handler.tssetupApp()负责创建/复用 MockApplication 实例并缓存到globalThis.__eggMockAppInstance支撑多测试文件共享同一 appplugins/mock/src/app/extend/application.tsapp.backgroundTasksFinished()等扩展方法的实现位置。这套bootstrap 入口 setup 钩子 全局实例缓存的设计让开发者可以在任意测试文件中零样板地拿到就绪的 app 实例是 EGG 测试体验高效的关键。参考资料SKILL 文档及配套场景参考均位于 packages/skills/egg-unittestSKILL.md — 技能主文档本文骨架references/http-test.md — HTTP 接口测试references/service-test.md — Service/DI 对象测试references/mock.md — Mock 模式references/background-task-test.md — BackgroundTaskHelper 测试references/eventbus-test.md — EventBus 测试仓库中的真实测试示例还可参考 examples/helloworld-tegg/test/SimpleController.test.ts、examples/helloworld-tegg/test/ArgsController.test.ts 以及 plugins/mock/test 下的 mock 测试用例可对照本文所述 API 查看实际调用方式。赞分享后端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点击查看免费下载相关推荐手把手教你搭建i茅台智能预约系统从零开始实现自动化预约手把手教你搭建i茅台智能预约系统从零开始实现自动化预约 还在为每天9点准时抢购茅台而烦恼吗还在因为手速不够快而错过预约机会吗今天我要为你介绍一个 i茅台智后端Web框架BillionMail 自托管邮件营销平台从部署到 AI 批量发信的完整指南BillionMail 自托管邮件营销平台从部署到 AI 批量发信的完整指南 还在为每月按量计费、又拿不回数据的邮件营销 SaaS 头疼BillionMai后端Web框架深入理解aws-inventory架构核心组件与工作原理解析深入理解aws inventory架构核心组件与工作原理解析 想要全面掌握你的AWS云资源分布情况吗aws inventory是一款强大的AWS资源发现工具后端Web框架上一篇KeePassDX入门指南Android上最轻量级的密码保险箱使用教程下一篇Availup应用ID配置解锁高级功能的钥匙创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考