单元测试:Jest

Jest 是由 Facebook 开发的零配置测试框架,广泛应用于 JavaScript 项目的单元测试。它集成了断言库、Mock 功能、覆盖率报告等,使得测试变得简单高效。本章将带你从零开始学习如何在 Node.js 中使用 Jest 编写和运行单元测试。

1. 为什么需要单元测试?

单元测试是对软件中的最小可测试单元(如函数、模块)进行检查和验证。它的好处包括:

  • 提高代码质量,及早发现 bug。
  • 保证重构时的正确性,避免回归错误。
  • 帮助开发者理解代码的行为,起到文档作用。
  • 促进模块化和解耦。

2. 安装 Jest

首先创建一个 Node.js 项目并安装 Jest:

mkdir jest-demo
cd jest-demo
npm init -y
npm install --save-dev jest

然后在 package.json 中添加测试脚本:

"scripts": {
  "test": "jest"
}

现在可以运行 npm test 执行测试(默认会查找 *.test.js*.spec.js 文件)。

3. 编写第一个测试

创建一个简单的函数模块 math.js

// math.js
function add(a, b) {
  return a + b;
}
function subtract(a, b) {
  return a - b;
}
module.exports = { add, subtract };

编写测试文件 math.test.js

// math.test.js
const { add, subtract } = require('./math');

test('adds 1 + 2 to equal 3', () => {
  expect(add(1, 2)).toBe(3);
});

test('subtracts 5 - 2 to equal 3', () => {
  expect(subtract(5, 2)).toBe(3);
});

运行 npm test,Jest 会执行测试并显示结果。

4. Jest 的核心概念

4.1 全局函数

  • test(name, fn, timeout):定义一个测试用例。
  • describe(name, fn):将多个相关测试分组。
  • expect(value):生成断言对象。

示例:

describe('数学函数', () => {
  test('加法', () => {
    expect(add(1, 2)).toBe(3);
  });
  test('减法', () => {
    expect(subtract(5, 2)).toBe(3);
  });
});

4.2 匹配器(Matchers)

Jest 提供了丰富的匹配器来断言不同情况:

  • .toBe(value):严格相等(===)。
  • .toEqual(value):递归检查对象或数组的每个字段。
  • .toBeTruthy() / .toBeFalsy()
  • .toContain(item):数组或可迭代对象包含。
  • .toThrow(error):函数执行是否抛出异常。
  • 更多见 Jest 文档
test('对象相等', () => {
  const data = { one: 1 };
  data['two'] = 2;
  expect(data).toEqual({ one: 1, two: 2 });
});

test('数组包含', () => {
  expect(['apple', 'banana']).toContain('apple');
});

5. 异步代码测试

Node.js 中有大量异步操作,Jest 支持多种方式测试异步代码。

5.1 回调函数

使用 done 参数,当异步完成时调用 done()

test('异步回调', (done) => {
  function callback(data) {
    try {
      expect(data).toBe('hello');
      done();
    } catch (err) {
      done(err);
    }
  }
  fetchData(callback);
});

5.2 Promise

直接返回 Promise,Jest 会等待其解决。

test('promise 解决', () => {
  return fetchData().then(data => {
    expect(data).toBe('hello');
  });
});

5.3 async/await

使用 async/await 使测试更简洁。

test('async/await', async () => {
  const data = await fetchData();
  expect(data).toBe('hello');
});

6. 钩子函数

Jest 提供钩子在测试前后执行准备和清理工作:

  • beforeAll(fn, timeout):在所有测试开始前执行一次。
  • afterAll(fn):在所有测试结束后执行一次。
  • beforeEach(fn):在每个测试开始前执行。
  • afterEach(fn):在每个测试结束后执行。

常用于初始化数据库连接、创建测试数据等。

let db;

beforeAll(() => {
  db = connectDatabase();
});

afterAll(() => {
  db.close();
});

test('查询用户', () => {
  const user = db.findUser(1);
  expect(user.name).toBe('Alice');
});

7. Mock 函数

Mock 函数可以模拟外部依赖,控制其行为并记录调用信息。Jest 使用 jest.fn() 创建模拟函数。

const callback = jest.fn();

test('mock 函数', () => {
  callback('hello');
  expect(callback).toHaveBeenCalled();
  expect(callback).toHaveBeenCalledWith('hello');
  expect(callback).toHaveBeenCalledTimes(1);
});

7.1 Mock 返回值

const mockFn = jest.fn();
mockFn.mockReturnValue(42);
console.log(mockFn()); // 42

7.2 Mock 模块

使用 jest.mock() 自动模拟整个模块。例如模拟 axios

const axios = require('axios');
jest.mock('axios');

axios.get.mockResolvedValue({ data: { id: 1 } });

test('获取数据', async () => {
  const result = await fetchUser();
  expect(axios.get).toHaveBeenCalledWith('/user/1');
  expect(result).toEqual({ id: 1 });
});

8. 测试覆盖率

Jest 内置了测试覆盖率报告,通过 --coverage 标志生成。在 package.json 中配置:

"scripts": {
  "test": "jest --coverage"
}

运行后会显示未覆盖的代码行、分支、函数等信息,并生成 HTML 报告(coverage/ 目录)。

9. 测试 Node.js 模块

对于 Node.js 特有的模块(如 fs),也可以使用 Jest 模拟。例如测试一个文件读取函数:

const fs = require('fs');
jest.mock('fs');

const { readConfig } = require('./config');

test('读取配置文件', () => {
  fs.readFileSync.mockReturnValue('{"name":"test"}');
  const config = readConfig('config.json');
  expect(config).toEqual({ name: 'test' });
  expect(fs.readFileSync).toHaveBeenCalledWith('config.json', 'utf8');
});

10. 集成到项目中

在真实的项目中,通常会将测试文件放在 __tests__ 目录下,或与源文件放在一起(如 math.js 旁边放 math.test.js)。Jest 会自动识别。

10.1 配置文件

可以通过 jest.config.js 自定义 Jest 行为:

module.exports = {
  testEnvironment: 'node',        // 环境 (node 或 jsdom)
  collectCoverage: true,
  coverageDirectory: 'coverage',
  testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)']
};

11. 实践示例:测试一个用户服务

假设有一个用户服务,依赖数据库和邮件服务:

// userService.js
const db = require('./db');
const email = require('./email');

async function createUser(name, emailAddress) {
  const user = { name, email: emailAddress };
  await db.save(user);
  await email.sendWelcome(emailAddress);
  return user;
}

module.exports = { createUser };

测试:

jest.mock('./db');
jest.mock('./email');

const { createUser } = require('./userService');
const db = require('./db');
const email = require('./email');

test('创建用户', async () => {
  db.save.mockResolvedValue(true);
  email.sendWelcome.mockResolvedValue(true);

  const user = await createUser('Alice', 'alice@example.com');

  expect(db.save).toHaveBeenCalledWith({ name: 'Alice', email: 'alice@example.com' });
  expect(email.sendWelcome).toHaveBeenCalledWith('alice@example.com');
  expect(user).toEqual({ name: 'Alice', email: 'alice@example.com' });
});

12. 常见问题

  • 测试异步代码超时:可以增加超时时间,如 test('name', fn, 10000)
  • Mock 模块后还原:使用 jest.restoreAllMocks()jest.clearAllMocks() 在钩子中清理。
  • 测试私有函数:通常建议只测试公共接口,但可以通过 rewirebabel-plugin-rewire 访问私有函数。
最佳实践:
  • 每个测试独立,避免依赖顺序。
  • 使用有意义的测试名称。
  • 保持测试简短,只测试一件事。
  • Mock 外部依赖,使测试快速且稳定。
  • 定期检查覆盖率,确保关键代码被测试。

小结

Jest 提供了零配置、功能强大的测试环境,非常适合 Node.js 项目的单元测试。通过本章学习,你应该掌握了 Jest 的基本用法、异步测试、Mock 和覆盖率报告。结合 TDD(测试驱动开发)理念,可以显著提升代码质量和可维护性。下一章我们将学习集成测试与端到端测试。