Node.js 单元测试完全指南:Jest 实战

小飞兽 Node.js 138 次阅读 2026-05-03

Jest 简介与安装

Jest 是 Facebook 开源的 JavaScript 测试框架,以零配置、运行速度快、内置 Mock 支持著称,是 Node.js 生态中最流行的测试工具。

npm install --save-dev jest
# 安装匹配器(更友好的断言错误提示)
npm install --save-dev @types/jest

配置 package.json

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageDirectory": "coverage",
    "collectCoverageFrom": ["src/**/*.js", "!src/index.js"],
    "testMatch": ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"]
  }
}

基础测试用例

// src/calculator.js
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function divide(a, b) {
  if (b === 0) throw new Error('除数不能为零');
  return a / b;
}

function factorial(n) {
  if (n < 0) throw new Error('负数没有阶乘');
  if (n === 0 || n === 1) return 1;
  return n * factorial(n - 1);
}

module.exports = { add, subtract, divide, factorial };
// __tests__/calculator.test.js

// 待测试模块
const { add, subtract, divide, factorial } = require('../src/calculator');

describe('计算器模块', () => {

  // ========== 基础功能测试 ==========

  describe('add()', () => {
    test('两个正数相加', () => {
      expect(add(2, 3)).toBe(5);
    });

    test('负数加正数', () => {
      expect(add(-1, 1)).toBe(0);
    });

    test('浮点数相加(精度问题)', () => {
      // 处理 JavaScript 浮点数精度问题
      expect(add(0.1, 0.2)).toBeCloseTo(0.3, 5);
    });
  });

  describe('divide()', () => {
    test('正常除法', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('除以零抛出错误', () => {
      expect(() => divide(10, 0)).toThrow('除数不能为零');
    });

    test('零除以任何数', () => {
      expect(divide(0, 5)).toBe(0);
    });
  });

  describe('factorial()', () => {
    test('0 的阶乘为 1', () => {
      expect(factorial(0)).toBe(1);
    });

    test('5 的阶乘为 120', () => {
      expect(factorial(5)).toBe(120);
    });

    test('负数抛出错误', () => {
      expect(() => factorial(-1)).toThrow('负数没有阶乘');
    });
  });
});

Mock 技术

// src/userService.js
const User = require('../models/User');
const { hashPassword } = require('../utils/password');
const mailer = require('../utils/mailer'); // 发送邮件

class UserService {
  async createUser({ email, name, password }) {
    const existing = await User.findOne({ email });
    if (existing) throw new Error('用户已存在');

    const passwordHash = await hashPassword(password);
    const user = await User.create({ email, name, passwordHash });

    // 发送欢迎邮件
    await mailer.sendWelcomeEmail(user.email, user.name);

    return user;
  }

  async deleteUser(userId) {
    return await User.findByIdAndDelete(userId);
  }
}

module.exports = new UserService();
// __tests__/userService.test.js
const userService = require('../src/userService');
const User = require('../models/User');
const mailer = require('../utils/mailer');

// ====== Mock 配置 ======

// Mock User 模型
jest.mock('../models/User', () => ({
  findOne: jest.fn(),
  create: jest.fn(),
  findByIdAndDelete: jest.fn()
}));

// Mock mailer(仅 mock sendWelcomeEmail,其他方法不变)
jest.mock('../utils/mailer', () => ({
  sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
  sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined)
}));

describe('UserService', () => {

  beforeEach(() => {
    // 每个测试前清理所有 mock 调用记录
    jest.clearAllMocks();
  });

  describe('createUser()', () => {
    test('成功创建用户并发送欢迎邮件', async () => {
      const userData = { email: 'test@example.com', name: 'Test', password: 'password123' };
      const mockUser = { _id: 'user123', ...userData };

      User.findOne.mockResolvedValue(null);  // 用户不存在
      User.create.mockResolvedValue(mockUser);

      const result = await userService.createUser(userData);

      expect(result).toEqual(mockUser);
      expect(User.findOne).toHaveBeenCalledWith({ email: userData.email });
      expect(User.create).toHaveBeenCalledWith({
        email: userData.email,
        name: userData.name,
        passwordHash: expect.any(String)
      });
      expect(mailer.sendWelcomeEmail).toHaveBeenCalledWith(
        userData.email,
        userData.name
      );
    });

    test('用户已存在时抛出错误', async () => {
      User.findOne.mockResolvedValue({ email: 'existing@example.com' });

      await expect(
        userService.createUser({ email: 'existing@example.com', name: 'Test', password: 'pass' })
      ).rejects.toThrow('用户已存在');

      // 确保没有创建用户
      expect(User.create).not.toHaveBeenCalled();
      expect(mailer.sendWelcomeEmail).not.toHaveBeenCalled();
    });
  });

  describe('deleteUser()', () => {
    test('成功删除用户', async () => {
      const mockUser = { _id: 'user123', email: 'test@example.com' };
      User.findByIdAndDelete.mockResolvedValue(mockUser);

      const result = await userService.deleteUser('user123');

      expect(result).toEqual(mockUser);
      expect(User.findByIdAndDelete).toHaveBeenCalledWith('user123');
    });
  });
});

异步代码测试

// __tests__/async.test.js
const { promisify } = require('util');
const fs = require('fs');
const readFile = promisify(fs.readFile);

// ===== Promise 测试 =====

test('Promise 风格的异步测试', async () => {
  const data = await readFile('./package.json', 'utf8');
  const pkg = JSON.parse(data);
  expect(pkg.name).toBeDefined();
});

// 或者用 .resolves / .rejects 匹配器
test('Promise 结果匹配', async () => {
  await expect(Promise.resolve(42)).resolves.toBe(42);
  await expect(Promise.reject(new Error('error'))).rejects.toThrow('error');
});

// ===== 错误处理测试 =====
async function fetchData(shouldFail = false) {
  if (shouldFail) throw new Error('Network error');
  return { id: 1, name: 'Alice' };
}

test('异步函数抛出错误', async () => {
  await expect(fetchData(true)).rejects.toThrow('Network error');
});

test('异步函数正常返回', async () => {
  const data = await fetchData(false);
  expect(data.name).toBe('Alice');
});

覆盖率报告

# 运行测试并生成覆盖率报告
npm run test:coverage

# 覆盖率报告会自动输出:
# - 终端彩色表格(每个文件的行/分支/函数/语句覆盖率)
# - HTML 报告在 coverage/lcov-report/index.html
// 覆盖率阈值配置(package.json jest 配置)
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 70,
        "functions": 70,
        "lines": 70,
        "statements": 70
      }
    }
  }
}

常见问题

Q1: <code>jest.clearAllMocks()</code> 和 <code>jest.resetAllMocks()</code> 有什么区别?

clearAllMocks() 只清除 mock 的调用记录和返回值,但保留 mock 的配置(mockImplementation 等);resetAllMocks() 更彻底,同时重置 mock 的实现,通常需要配合 restoreAllMocks() 恢复真实方法。一般在 beforeEach 中用 clearAllMocks() 即可。

Q2: 如何测试不导出内部的私有方法?

设计良好的单元测试只测试公开 API(模块的导出)。如果某个私有逻辑很复杂,可以将其提取到独立的模块/函数中单独测试;或者使用重构将逻辑外移。不要为了"测试覆盖率"而强行测试私有方法。

Q3: Jest 的 <code>--watch</code> 模式如何只运行相关测试?

运行 jest --watch 后,Jest 会监控 git 变化只运行变更文件的测试。按 w 可以看到更多选项:按文件名运行(p)、按测试名运行(t)、取消 watch 模式(q")。

延伸阅读

  • <a href="https://jestjs.io/zh-Hans/">Jest 官方文档</a>
  • <a href="https://jestjs.io/zh-Hans/docs/api">Jest API 参考</a>
  • <a href="https://martinfowler.com/articles/practical-test-pyramid.html">测试金字塔:如何合理分配测试层次</a>