第 3 章 后台任务单元测试编写:使用 Jest/Mocha 测试异步代码、模拟依赖、断言

3.1 原理概述

后台任务通常运行在独立线程或异步事件循环中,与主请求/响应路径解耦。单元测试这类代码需要解决三个核心问题:

  • 异步等待 —— 测试框架必须能感知 Promise/回调完成后再断言。
  • 依赖隔离 —— 后台任务常调用消息队列、数据库、外部 API,必须用模拟(mock)替换真实实现。
  • 状态验证 —— 断言任务执行后的副作用(数据写入、消息发送、日志记录)。

Jest 和 Mocha 是 Node.js 生态最主流的测试框架,本章基于二者讲解后台任务单元测试的标准化写法。

3.2 工具选择对比

维度JestMocha + 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 实现流程

编写后台任务单元测试的通用步骤:

  1. 引入被测模块 —— 待测试的后台任务函数或类。
  2. 模拟外部依赖 —— 用 mock 替代数据库、消息队列、HTTP 客户端。
  3. 构造测试数据 —— 模拟任务入参或事件负载。
  4. 调用异步方法 —— 使用 await 或返回 Promise。
  5. 断言结果 —— 验证返回值、副作用调用次数、异常抛出。
  6. 清理 —— 恢复 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 快速回归。
后台任务单元测试执行流程 1. 加载被测模块 (require) 2. 模拟外部依赖 (jest.mock) 3. 构造测试数据 (event) 4. 调用异步方法 (await) 5. 断言结果 (expect) 6. 清理 mock (afterEach) 关键检查点 • mock 是否在 require 之前生效 • 异步路径是否覆盖 success/error • 断言参数是否精确匹配 • 清理函数是否恢复原始实现 • 测试超时是否合理设置 • CI 下是否生成覆盖率报告

3.11 小结

后台任务测试的核心在于异步编排与依赖隔离。Jest 提供的自动 mock、fake timers 和丰富的异步匹配器,使其成为后台任务测试的首选框架。本章给出了从模块模拟到 CI 集成的完整链路,下一环节将深入 GitLab CI 中的测试执行策略与并行化配置。