人人都会AI编程

18.1 单元测试:Jest / Vitest

更新时间:2026-07-10

单元测试是对软件中最小的可测试单元(通常是一个函数或一个模块)进行验证的自动化测试。在 Node.js 项目中,有两款测试框架占据了绝对主流:JestVitest。它们都提供了断言、Mock、测试覆盖率等全套能力,但在设计理念和性能上各有侧重。本节会从实际开发的角度,讲解如何在 Node.js 项目中使用这两款框架,以及如何做出合适的选择。

18.1.1 Jest:久经考验的全能选手

Jest 由 Facebook 开发,最初为 React 项目服务,但因其“零配置”和强大的功能集,迅速成为 JavaScript 生态中最流行的测试框架。在 Node.js 后端项目中,Jest 同样表现出色。

安装与基本配置

npm install --save-dev jest

package.json 中添加脚本:

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

对于 Node.js 项目,多数情况下甚至不需要额外的配置文件。Jest 会自动查找 .test.js.spec.js 文件,或放在 tests 目录下的文件。如果需要调整,可以在项目根目录创建 jest.config.js

module.exports = {
  testEnvironment: 'node',          // 指定运行环境为 Node.js
  roots: ['<rootDir>/src'],         // 测试文件所在目录
  testMatch: ['**/*.test.js'],      // 测试文件命名模式
  collectCoverage: true,            // 开启覆盖率收集
  coverageDirectory: 'coverage',    // 覆盖率输出目录
};

编写第一个测试

假设我们有一个工具函数 sum.js

// src/sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;

编写测试文件 src/sum.test.js

const sum = require('./sum');

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

运行 npm test,Jest 会执行测试并输出清晰的结果。test 函数定义一个测试用例,expect 返回一个“期望对象”,再通过匹配器(如 toBe)进行断言。Jest 的匹配器非常丰富:

  • toBe / toEqual:精确相等 / 深度相等
  • toBeNull / toBeUndefined / toBeDefined
  • toBeTruthy / toBeFalsy
  • toContain:数组或字符串包含
  • toThrow:是否抛出异常
  • toMatch / toMatchObject:字符串匹配 / 对象部分匹配

异步测试

Node.js 项目中有大量异步操作,Jest 提供了三种处理方式:

1. 回调风格:使用 done 参数,测试会等待 done 被调用。

test('fetch data with callback', (done) => {
  fetchData((err, data) => {
    expect(data).toBeDefined();
    done();
  });
});

2. Promise 风格:返回 Promise,Jest 会等待其 resolve 或 reject。

test('fetch data with promise', () => {
  return fetchData().then((data) => {
    expect(data).toBeDefined();
  });
});

3. async/await 风格:最推荐的方式,代码直观。

test('fetch data with async/await', async () => {
  const data = await fetchData();
  expect(data).toBeDefined();
});

Mock 与 Stub

单元测试的核心原则是隔离被测单元,不依赖外部资源(如数据库、文件系统、网络)。Jest 通过 Mock 功能轻松实现。

Mock 整个模块:用 jest.mock 自动模拟模块的所有导出。

jest.mock('axios'); // 自动将 axios 所有方法替换为 jest.fn()

const axios = require('axios');
const { getUser } = require('./userService');

test('should fetch user', async () => {
  axios.get.mockResolvedValue({ data: { id: 1, name: 'Alice' } });
  const user = await getUser(1);
  expect(user.name).toBe('Alice');
  expect(axios.get).toHaveBeenCalledWith('/users/1');
});

Mock 函数:用 jest.fn() 创建可追踪的 Mock 函数。

const callback = jest.fn();
someFunction(callback);
expect(callback).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith('expected_arg');

Mock 部分实现jest.spyOn 可以保留对象方法的原始实现,同时跟踪调用。

const fs = require('fs');
jest.spyOn(fs, 'readFileSync').mockReturnValue('mocked content');

测试覆盖率

Jest 内置覆盖率统计,运行 jest --coverage 即可生成 HTML 报告。通常项目会设置覆盖率阈值,防止持续下降:

// jest.config.js
module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

18.1.2 Vitest:面向未来的高性能新秀

Vitest 由 Vite 团队打造,设计目标是“为 Vite 提供原生测试支持”,但同样可以用于任何 Node.js 项目。它的优势在于速度极快、配置极简,并且原生支持 ESM、TypeScript 和 HMR(热模块替换)。

安装与配置

npm install --save-dev vitest

package.json 添加测试脚本:

{
  "scripts": {
    "test": "vitest"
  }
}

Vitest 默认读取项目根目录下的 vite.config.js(如果存在),也可以单独使用 vitest.config.js。对于纯 Node.js 项目,一个最基本的配置就可以运行:

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',           // 指定运行环境
    include: ['src/**/*.test.js'], // 测试文件匹配
    globals: true,                 // 自动注入 test、expect 等,无需手动引入
  },
});

如果不想使用全局注入,可以在每个测试文件中手动从 vitest 导入:

import { describe, test, expect } from 'vitest';

编写测试

Vitest 的 API 与 Jest 高度兼容,这是有意为之的设计——方便从 Jest 迁移。以上小节的 sum 函数为例,用 Vitest 编写:

import { describe, test, expect } from 'vitest';
import { sum } from './sum';

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

所有 Jest 常用的匹配器(toBe, toEqual, toContain 等)Vitest 都支持。异步测试同样支持 async/await.resolves/.rejects

Mock 与 Stub

Vitest 使用 vi 对象提供 Mock 功能,设计上与 Jest 类似,但利用了 ES Module 的静态特性。

Mock 模块

import { vi } from 'vitest';
import axios from 'axios';

vi.mock('axios'); // 自动提升到文件顶部

test('mocked axios', async () => {
  axios.get.mockResolvedValue({ data: { id: 1 } });
  // ...
});

Mock 函数vi.fn() 创建可追踪的函数。

const mockFn = vi.fn((x) => x * 2);
mockFn(3);
expect(mockFn).toHaveBeenCalledWith(3);
expect(mockFn).toHaveReturnedWith(6);

Spyvi.spyOn 与 Jest 类似,但在对象方法上使用。

const obj = { method: () => 'original' };
const spy = vi.spyOn(obj, 'method').mockReturnValue('mocked');
obj.method(); // 'mocked'
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // 恢复原始实现

Vitest 的差异化优势

相比 Jest,Vitest 在 Node.js 项目中的突出优势包括:

  • 极快的启动和运行速度:基于 esbuild 的转译,无需 Babel 编译配置,运行大型测试套件时速度优势明显。
  • 原生 ESM 和 TypeScript 支持:无需额外配置,可以直接测试 .ts 文件,对全栈 TypeScript 项目尤其友好。
  • 兼容 Vite 生态:如果项目本身使用 Vite 作为构建工具,Vitest 可以直接复用 Vite 的配置和插件,减少重复配置。
  • HMR 测试开发体验:在 watch 模式下,修改测试文件后仅重新运行相关用例,反馈极快。

18.1.3 Jest 与 Vitest 的对比与选型

| 特性 | Jest | Vitest |
|----------------------|-------------------------------|--------------------------------|
| 成熟度 | 极高,生态庞大 | 快速增长,1.0 后已稳定 |
| 性能 | 中等(依赖 Babel 转译) | 极快(esbuild 转译) |
| ESM/TypeScript 支持 | 需配置 | 原生支持,几乎零配置 |
| 配置复杂度 | 较低,默认约定好 | 极低,同时兼容 Vite 配置 |
| 社区与插件 | 极丰富 | 快速增长,常用插件已覆盖 |
| 兼容性 | 广泛支持各种环境 | 对非 Vite 项目支持良好 |
| 适合场景 | 传统 Node.js 项目、遗留系统 | 新项目、TypeScript 项目、Vite 构建的项目 |

对于团队来说,如果项目已经使用 Jest 且稳定运行,没有强烈的迁移需求,继续使用 Jest 完全没有问题。但对新启动的 Node.js 项目,尤其是全栈 TypeScript 项目,Vitest 几乎是更优的选择——它让你在编写和运行测试时享受与编写业务代码一样的零配置体验。

18.1.4 实际测试策略建议

无论选择哪个框架,单元测试的编写都应遵循几个实用原则:

  • 只测试公共接口:测试模块暴露的函数/类,不测试内部实现细节,避免因重构导致大量测试修改。
  • 隔离外部依赖:数据库、文件系统、网络请求一律 Mock,保证测试快速且稳定。
  • 一个测试只验证一个行为:清晰的测试意图能帮助快速定位问题。
  • 避免测试实现而非行为:断言返回值或副作用,而不是断言内部方法调用,除非该方法调用本身就是重要的契约。
  • 与 CI 集成:在持续集成流水线中运行完整的测试套件和覆盖率检查,防止退化。

最后,单元测试不是越多越好。优先覆盖核心业务逻辑、容易出错的边界条件、以及历史出现过的 Bug 修复点。一个好的测试套件,既能让人放心重构,又不会成为维护的负担。