第 3 章 后台任务单元测试编写:使用 Jest/Mocha 测试异步代码、模拟依赖、断言
3.1 原理概述
后台任务通常运行在独立线程或异步事件循环中,与主请求/响应路径解耦。单元测试这类代码需要解决三个核心问题:
- 异步等待 —— 测试框架必须能感知 Promise/回调完成后再断言。
- 依赖隔离 —— 后台任务常调用消息队列、数据库、外部 API,必须用模拟(mock)替换真实实现。
- 状态验证 —— 断言任务执行后的副作用(数据写入、消息发送、日志记录)。
Jest 和 Mocha 是 Node.js 生态最主流的测试框架,本章基于二者讲解后台任务单元测试的标准化写法。
3.2 工具选择对比
| 维度 | Jest | Mocha + Chai + Sinon |
|---|---|---|
| 内置断言 | 是(expect API) | 需额外引入 Chai |
| 内置 mock/spy | 是(jest.fn / jest.mock) | 需引入 Sinon |
| 异步支持 | 直接返回 Promise / async | 配合 done 回调或返回 Promise |
| 覆盖率 | 内置 istanbul | 需要 nyc 或 c8 |
| GitLab CI 集成 | 直接运行 jest | 配置 npm test 即可 |
推荐:后台任务项目优先选择 Jest,零配置即可满足大多数场景。
3.3 实现流程
编写后台任务单元测试的通用步骤:
- 引入被测模块 —— 待测试的后台任务函数或类。
- 模拟外部依赖 —— 用 mock 替代数据库、消息队列、HTTP 客户端。
- 构造测试数据 —— 模拟任务入参或事件负载。
- 调用异步方法 —— 使用
await或返回 Promise。 - 断言结果 —— 验证返回值、副作用调用次数、异常抛出。
- 清理 —— 恢复 mock、清除定时器等。
3.4 测试后台异步任务(代码示例)
假设有一个发送邮件的后台任务 sendEmailTask,内部调用 Redis 队列并发送 HTTP 请求:
// emailTask.js — 被测试的后台任务模块
const { getQueue } = require('./queue');
const { sendEmail } = require('./emailClient');
async function processEmailTask(event) {
const { to, subject, body } = event;
if (!to || !subject) {
throw new Error('Invalid email event');
}
// 模拟业务延迟
await getQueue().add('email', { to, subject, body });
const result = await sendEmail({ to, subject, body });
return { status: 'sent', messageId: result.id };
}
module.exports = { processEmailTask };
Jest 版测试:
// emailTask.test.js
const { processEmailTask } = require('./emailTask');
const queue = require('./queue');
const emailClient = require('./emailClient');
jest.mock('./queue', () => ({
getQueue: jest.fn()
}));
jest.mock('./emailClient', () => ({
sendEmail: jest.fn()
}));
describe('processEmailTask', () => {
beforeEach(() => {
jest.clearAllMocks();
queue.getQueue.mockReturnValue({
add: jest.fn().mockResolvedValue({ id: 'q-1' })
});
emailClient.sendEmail.mockResolvedValue({ id: 'msg-001' });
});
test('发送有效邮件返回成功状态', async () => {
const event = { to: 'a@b.com', subject: 'Hello', body: 'World' };
const result = await processEmailTask(event);
expect(result).toEqual({ status: 'sent', messageId: 'msg-001' });
expect(queue.getQueue().add).toHaveBeenCalledWith(
'email',
{ to: 'a@b.com', subject: 'Hello', body: 'World' }
);
expect(emailClient.sendEmail).toHaveBeenCalledTimes(1);
});
test('缺少必要字段抛出异常', async () => {
const event = { to: '', subject: 'Hello' };
await expect(processEmailTask(event)).rejects.toThrow('Invalid email event');
});
});
Mocha + Sinon 版测试:
// emailTask.test.mocha.js
const { expect, use } = require('chai');
const sinon = require('sinon');
const { processEmailTask } = require('./emailTask');
const queue = require('./queue');
const emailClient = require('./emailClient');
describe('processEmailTask (Mocha)', () => {
let queueAddStub, sendEmailStub;
beforeEach(() => {
queueAddStub = sinon.stub().resolves({ id: 'q-1' });
sinon.stub(queue, 'getQueue').returns({ add: queueAddStub });
sendEmailStub = sinon.stub(emailClient, 'sendEmail').resolves({ id: 'msg-001' });
});
afterEach(() => {
sinon.restore();
});
it('发送有效邮件返回成功状态', async () => {
const event = { to: 'a@b.com', subject: 'Hello', body: 'World' };
const result = await processEmailTask(event);
expect(result).to.deep.equal({ status: 'sent', messageId: 'msg-001' });
expect(queueAddStub.calledOnce).to.be.true;
expect(sendEmailStub.calledOnce).to.be.true;
});
it('缺少必需字段抛出异常', async () => {
const event = { to: '', subject: 'Hi' };
try {
await processEmailTask(event);
} catch (err) {
expect(err.message).to.include('Invalid email event');
}
});
});
3.5 模拟依赖的策略
| 依赖类型 | 模拟方式(Jest) | 模拟方式(Mocha+Sinon) | 关键点 |
|---|---|---|---|
| 模块级函数 | jest.mock('模块路径') | sinon.stub(module, 'func') | 在顶层或 beforeEach 中调用 |
| 类/构造函数 | jest.mock('模块', () => ({ default: jest.fn() })) | sinon.createStubInstance | 确保返回 mock 实例 |
| 第三方 HTTP 客户端 | jest.mock('axios') | sinon.stub(axios, 'post') | 全局模拟,避免真实网络 |
| 定时器(setTimeout) | jest.useFakeTimers() | sinon.useFakeTimers() | 配合 advanceTimersByTime |
⚠️ 模拟后必须在
afterEach / afterAll 中清理(jest.clearAllMocks() / sinon.restore()),否则后续测试会收到副作用。3.6 异步断言技巧
- Promise 返回式:直接
return expect(fn()).resolves.toBe(...)或await fn()。 - 超时处理:Jest 默认超时 5000ms,长任务可调:
test('...', async () => { ... }, 30000)。 - toHaveBeenCalledWith:精确验证参数,对异步回调十分有效。
- rejects:断言异步错误:
await expect(promise).rejects.toThrow()。
3.7 调试与运行
# 安装依赖
npm install --save-dev jest # 或 mocha + chai + sinon
# 运行所有测试
npx jest --verbose # Jest
npx mocha --timeout 10000 # Mocha
# 单文件监视模式
npx jest --watch emailTask.test.js
# 调试模式(在断点处暂停)
node --inspect-brk node_modules/.bin/jest --runInBand emailTask
3.8 集成到 GitLab CI
# .gitlab-ci.yml 片段 —— 后台任务单元测试作业
test:backend-unit:
stage: test
image: node:18-alpine
before_script:
- npm ci
script:
- npm run test:unit # 假设 package.json 中配置 "test:unit": "jest --coverage"
artifacts:
reports:
junit: ./junit.xml # 若 Jest 配置生成 JUnit 报告
coverage_report:
coverage_format: cobertura
path: ./coverage/cobertura-coverage.xml
💡 使用
--maxWorkers=2 或 --runInBand 控制 CI 环境下的并行度,避免内存溢出。3.9 常见错误与解决方案
| 错误现象 | 原因 | 修复 |
|---|---|---|
| 测试一直挂起直到超时 | 模拟返回未解决的 Promise | 确认 mock 返回了 mockResolvedValue 或 resolves() |
| mock 未生效 | 模块加载顺序问题 | 在文件顶部先 jest.mock,再 require 被测模块 |
| 测试间相互影响 | mock 状态未清理 | 每个 beforeEach 中执行 jest.clearAllMocks() |
| 时序依赖测试不稳定 | 使用了真实 setTimeout | 替换为 jest.useFakeTimers() 并手动推进时间 |
3.10 性能与稳定性优化
- 并行安全:每个测试文件使用独立的 mock 实例,避免共享 state。
- CI 资源控制:设置
jest --maxWorkers=50%防止抢占 CPU。 - 慢测试分化:将 I/O 密集模拟的测试移入单独的
slow-tests目录,CI 中分开调度。 - 快照依赖:对后台任务输出结果使用
toMatchSnapshot快速回归。
3.11 小结
后台任务测试的核心在于异步编排与依赖隔离。Jest 提供的自动 mock、fake timers 和丰富的异步匹配器,使其成为后台任务测试的首选框架。本章给出了从模块模拟到 CI 集成的完整链路,下一环节将深入 GitLab CI 中的测试执行策略与并行化配置。