Skip to content

单元测试 (Jest/Vitest)

概述

单元测试是测试金字塔的基础,用于验证最小可测试单元(函数、模块)的正确性。

一、测试框架对比

特性JestVitest
运行速度较慢极快 (基于 Vite)
配置复杂度开箱即用需 Vite 配置
ESM 支持需配置原生支持
快照测试
并行执行✅ (更优)
Watch 模式✅ (HMR 级别)
生态系统成熟快速发展

二、Jest 基础配置

安装

bash
npm install --save-dev jest @types/jest ts-jest

jest.config.js

javascript
module.exports = {
  // 测试环境
  testEnvironment: 'node', // 或 'jsdom' 用于浏览器环境
  
  // TypeScript 支持
  preset: 'ts-jest',
  
  // 测试文件匹配
  testMatch: [
    '**/__tests__/**/*.[jt]s?(x)',
    '**/?(*.)+(spec|test).[jt]s?(x)'
  ],
  
  // 忽略路径
  testPathIgnorePatterns: ['/node_modules/', '/dist/'],
  
  // 覆盖率配置
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  
  // 模块别名
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  
  // Setup 文件
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
};

Vitest 配置

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true, // 全局 API (describe, it, expect)
    setupFiles: ['./vitest.setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/']
    },
    // 并行与隔离
    isolate: true,
    pool: 'threads'
  }
});

三、Matcher 断言详解

基础匹配器

javascript
// 相等性
expect(2 + 2).toBe(4);                    // 严格相等 (===)
expect({ a: 1 }).toEqual({ a: 1 });       // 深度相等
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // 部分匹配

// 真值判断
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(value).toBeDefined();

// 数字比较
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3);       // 浮点数

// 字符串
expect('hello world').toMatch(/world/);
expect('hello').toContain('ell');

// 数组
expect([1, 2, 3]).toContain(2);
expect([{ a: 1 }, { b: 2 }]).toContainEqual({ a: 1 });
expect([1, 2, 3]).toHaveLength(3);

异常匹配器

javascript
function throwError() {
  throw new Error('Something went wrong');
}

expect(() => throwError()).toThrow();
expect(() => throwError()).toThrow(Error);
expect(() => throwError()).toThrow('Something went wrong');
expect(() => throwError()).toThrow(/wrong/);

异步匹配器

javascript
// Promise
await expect(Promise.resolve('data')).resolves.toBe('data');
await expect(Promise.reject('error')).rejects.toBe('error');

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

// 回调 (使用 done)
test('callback test', (done) => {
  fetchDataWithCallback((data) => {
    expect(data).toBe('expected');
    done();
  });
});

四、Mock 与 Spy

函数 Mock

javascript
// 创建 mock 函数
const mockFn = jest.fn();
mockFn('arg1', 'arg2');

// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);

// 返回值设置
const mockFn2 = jest.fn()
  .mockReturnValue('default')
  .mockReturnValueOnce('first call')
  .mockReturnValueOnce('second call');

console.log(mockFn2()); // 'first call'
console.log(mockFn2()); // 'second call'
console.log(mockFn2()); // 'default'

// 异步返回
const asyncMock = jest.fn()
  .mockResolvedValue('resolved')
  .mockRejectedValueOnce(new Error('error'));

// 实现替换
const mockImpl = jest.fn((x) => x * 2);
expect(mockImpl(5)).toBe(10);

模块 Mock

javascript
// 自动 mock 整个模块
jest.mock('./api');
import { fetchUser } from './api';

// 手动实现
jest.mock('./api', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Test' }))
}));

// 部分 mock
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  formatDate: jest.fn(() => '2024-01-01')
}));

Spy

javascript
const obj = {
  method: (x) => x + 1
};

// spyOn 保留原实现
const spy = jest.spyOn(obj, 'method');
obj.method(5); // 返回 6

expect(spy).toHaveBeenCalledWith(5);

// 替换实现
spy.mockImplementation((x) => x * 2);
obj.method(5); // 返回 10

// 恢复原实现
spy.mockRestore();

Timer Mock

javascript
jest.useFakeTimers();

test('timer test', () => {
  const callback = jest.fn();
  
  setTimeout(callback, 1000);
  
  // 快进时间
  jest.advanceTimersByTime(1000);
  
  expect(callback).toHaveBeenCalled();
});

// 运行所有 timer
jest.runAllTimers();

// 只运行待执行的 timer
jest.runOnlyPendingTimers();

// 恢复
jest.useRealTimers();

五、测试组织

describe 与 it

javascript
describe('Calculator', () => {
  let calculator;
  
  // 每个测试前执行
  beforeEach(() => {
    calculator = new Calculator();
  });
  
  // 每个测试后执行
  afterEach(() => {
    calculator = null;
  });
  
  // 所有测试前执行一次
  beforeAll(() => {
    // 初始化数据库连接等
  });
  
  // 所有测试后执行一次
  afterAll(() => {
    // 清理
  });
  
  describe('add', () => {
    it('should add two positive numbers', () => {
      expect(calculator.add(1, 2)).toBe(3);
    });
    
    it('should handle negative numbers', () => {
      expect(calculator.add(-1, -2)).toBe(-3);
    });
  });
  
  // 跳过测试
  describe.skip('subtract', () => {
    // 这些测试会被跳过
  });
  
  // 只运行这个
  describe.only('multiply', () => {
    // 只运行这个 describe
  });
});

参数化测试

javascript
describe.each([
  [1, 1, 2],
  [1, 2, 3],
  [2, 2, 4],
])('add(%i, %i)', (a, b, expected) => {
  test(`returns ${expected}`, () => {
    expect(add(a, b)).toBe(expected);
  });
});

// 对象形式
test.each([
  { a: 1, b: 1, expected: 2 },
  { a: 1, b: 2, expected: 3 },
])('add($a, $b) = $expected', ({ a, b, expected }) => {
  expect(add(a, b)).toBe(expected);
});

六、快照测试

javascript
// 基础快照
test('snapshot', () => {
  const tree = renderer.create(<Button>Click</Button>).toJSON();
  expect(tree).toMatchSnapshot();
});

// 内联快照
test('inline snapshot', () => {
  expect({ name: 'test' }).toMatchInlineSnapshot(`
    {
      "name": "test",
    }
  `);
});

// 更新快照
// jest --updateSnapshot 或 jest -u

七、覆盖率报告

bash
# 生成覆盖率报告
jest --coverage

# 输出格式
# - text: 终端输出
# - html: 可视化 HTML 报告
# - lcov: CI 工具使用
# - json: 程序处理

覆盖率指标

指标含义
Statements语句覆盖率
Branches分支覆盖率 (if/else, switch)
Functions函数覆盖率
Lines行覆盖率

八、最佳实践

1. AAA 模式

javascript
test('should calculate total price', () => {
  // Arrange (准备)
  const cart = new Cart();
  cart.addItem({ price: 10, quantity: 2 });
  cart.addItem({ price: 5, quantity: 1 });
  
  // Act (执行)
  const total = cart.getTotal();
  
  // Assert (断言)
  expect(total).toBe(25);
});

2. 测试命名

javascript
// ❌ 不好
test('test1', () => {});

// ✅ 好 - 描述行为
test('should return user when id exists', () => {});
test('should throw error when id is invalid', () => {});

// ✅ Given-When-Then 格式
test('given valid credentials, when login, then return token', () => {});

3. 单一职责

javascript
// ❌ 测试多个功能
test('user operations', () => {
  // 测试创建
  // 测试更新
  // 测试删除
});

// ✅ 每个测试一个断言焦点
test('should create user', () => {});
test('should update user', () => {});
test('should delete user', () => {});

面试高频题

Q1: Jest 和 Vitest 如何选择?

:

  • 新项目使用 Vite → 选 Vitest
  • 现有 Jest 项目 → 继续用 Jest
  • 需要极快的测试速度 → Vitest
  • 需要成熟的生态 → Jest

Q2: Mock 和 Spy 的区别?

:

  • Mock: 完全替换函数实现
  • Spy: 包装原函数,可以追踪调用同时保留原实现

Q3: 如何测试私有方法?

:

  1. 不直接测试私有方法,通过公共接口间接测试
  2. 如果必须测试,可以通过模块 export 暴露(仅测试环境)
  3. 使用 rewire 等工具访问

Q4: 如何提高测试速度?

:

  1. 并行执行 (--runInBand 关闭并行用于调试)
  2. 使用 --onlyChanged 只测试变更文件
  3. 合理使用 beforeAll 减少重复初始化
  4. Mock 外部依赖减少 I/O
  5. 使用 Vitest 替代 Jest

前端面试知识库