- 后端
- Web框架
【免费下载链接】egg
🥚🥚🥚🥚 Born to build better enterprise frameworks and apps with Node.js & Koa. https://307.run/eggcode
导读:本文以仓库内 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()恢复所有 mock;afterAll中在非共享模式下关闭 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.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 | 必须return或await | 否则断言不会执行,测试永远通过 |
.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 |
不 awaitgetEggObject | const 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' | 标准测试入口(另可导出assert、mock) |
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 测试报 403 | 加app.mockCsrf() | 安全插件默认开启 CSRF |
手动写afterEach(mm.restore) | 不需要 | egg-bin 自动注入 mock 恢复 |
| 代码写在 describe 内、hooks 外 | 放入beforeAll/beforeEach | describe 体在加载阶段就执行 |
await app.ready()配合 bootstrap | 不需要 | bootstrap 自动处理生命周期 |
深入源码:bootstrap 入口与生命周期
想要彻底理解这套测试体系,建议按以下路径阅读@eggjs/mock(仓库内位于 plugins/mock)的关键实现:
- plugins/mock/src/bootstrap.ts:测试入口,导出
app、mm、mock、assert、getBootstrapApp;同时校验 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
相关推荐
手把手教你搭建i茅台智能预约系统:从零开始实现自动化预约
手把手教你搭建i茅台智能预约系统:从零开始实现自动化预约 还在为每天9点准时抢购茅台而烦恼吗?还在因为手速不够快而错过预约机会吗?今天我要为你介绍一个 i茅台智
后端Web框架BillionMail 自托管邮件营销平台:从部署到 AI 批量发信的完整指南
BillionMail 自托管邮件营销平台:从部署到 AI 批量发信的完整指南 还在为每月按量计费、又拿不回数据的邮件营销 SaaS 头疼?BillionMai
后端Web框架深入理解aws-inventory架构:核心组件与工作原理解析
深入理解aws inventory架构:核心组件与工作原理解析 想要全面掌握你的AWS云资源分布情况吗?aws inventory是一款强大的AWS资源发现工具
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考