news 2026/9/21 3:05:13

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 作为测试运行器,并自动完成三件关键工作:

  1. 自动创建 MockApplication:以当前项目目录为 baseDir,创建并启动一个 MockApplication 实例(即测试中的app),测试代码无需手动new Application()
  2. 注入生命周期钩子:自动注入@eggjs/mock/setup_vitest,通过beforeAll启动 app、afterEach恢复 mock、afterAll关闭 app;
  3. 注入 Vitest 全局变量describeitbeforeAll等无需手动 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()恢复所有 mock;
  • afterAll中在非共享模式下关闭 app(在isolate: false或 threads 池共享模式下则交由 worker 线程回收)。

该文件还做了两件兼容性处理:

  • 为 Mocha 用户提供兼容别名:beforebeforeAllafterafterAllbeforeEachbeforeEachafterEachafterEach(见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.ts,egg-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 请求 + CSRF

POST/PUT/DELETE 请求需要先调用app.mockCsrf()跳过 CSRF 校验(安全插件默认开启 CSRF,不 mock 会返回 403):

it('should POST /api/users', () => { app.mockCsrf(); return app .httpRequest() .post('/api/users') .send({ name: 'test', email: 'test@example.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' }) // 只校验 body(deepStrictEqual 全量匹配) .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: 'new@example.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 会返回 403
app.httpRequest().get('/').expect(200)不 return/await必须returnawait否则断言不会执行,测试永远通过
.expect({ foo: 'bar' })用于部分匹配使用result.body手动断言.expect(body)是全量匹配(deepStrictEqual)

Service / DI 对象测试

Singleton 测试

@SingletonProto对象直接通过app.getEggObject()获取(返回 Promise,必须 await):

import 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)返回 Promise

Mock 模式

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() — 跳过 CSRF

POST/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 容器管理的对象通过原型 mock

BackgroundTask 后台任务测试

后台任务异步执行,断言前必须确保任务完成。两种等待方式:

方式一:mockModuleContextScope(自动等待)

mockModuleContextScope退出时会自动等待所有后台任务完成(内部触发doPreDestroy),scope 退出后直接断言即可:

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'标准测试入口(另可导出assertmock
app.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 'egg'import { app } from '@eggjs/mock/bootstrap'测试使用 mock 包
before()/after()beforeAll()/afterAll()Vitest 钩子,不是 Mocha
POST 测试报 403app.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:测试入口,导出appmmmockassertgetBootstrapApp;同时校验 egg 插件项目不可使用 bootstrap;
  • plugins/mock/src/setup_vitest.ts:Vitest 生命周期注入,beforeAll启动、afterEach等待后台任务 + 恢复 mock、afterAll关闭(非共享模式);
  • plugins/mock/src/lib/app_handler.ts:setupApp()负责创建/复用 MockApplication 实例并缓存到globalThis.__eggMockAppInstance,支撑多测试文件共享同一 app;
  • plugins/mock/src/app/extend/application.ts:app.backgroundTasksFinished()等扩展方法的实现位置。

这套"bootstrap 入口 + setup 钩子 + 全局实例缓存"的设计,让开发者可以在任意测试文件中零样板地拿到就绪的 app 实例,是 EGG 测试体验高效的关键。


参考资料

SKILL 文档及配套场景参考(均位于 packages/skills/egg-unittest):

  • SKILL.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
点击查看免费下载

相关推荐

上一篇:KeePassDX入门指南:Android上最轻量级的密码保险箱使用教程
下一篇:Availup应用ID配置:解锁高级功能的钥匙

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

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

2026研发管理系统选型指南:从跨部门协同到工具落地的完整路径

/* 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 2:59:36

蓝鲸PaaS apiserver 项目结构完全解析:Django+DRF 分层架构设计

蓝鲸PaaS apiserver 项目结构完全解析:DjangoDRF 分层架构设计 【免费下载链接】blueking-paas 蓝鲸智云 PaaS 平台是一个开放式的开发平台,让开发者可以方便快捷地创建、开发、部署和管理 SaaS 应用。它提供了完善的前后台开发框架、服务总线&#xff0…

作者头像 李华
网站建设 2026/9/21 2:51:43

STM32 HardFault深度解析:寄存器快照与堆栈回溯实战指南

/* 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 2:50:12

睡眠耳机怎么选?蓝牙主动降噪与久戴不痛的终极指南

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

作者头像 李华