单元测试(Vitest)

系统掌握 Vitest 单元测试框架的核心 API、配置策略与工程实践。涵盖测试编写范式、Mock 机制、异步测试、快照测试、Coverage 集成,以及从 Jest 迁移的完整路径。

为什么选择 Vitest

Vitest 是 Vite 生态的原生测试框架,由 Anthony Fu(antfu)主导开发。它与 Vite 共享配置和转换管线(transform pipeline),具备 极快的 HMR 级 watch 模式开箱即用的 ESM/TypeScript 支持。2024 年后,Vitest 已成为 React/Vue/Svelte 等现代前端项目的首选测试框架。


一、Vitest 核心架构

1.1 运行原理

源文件 → Vite Transform Pipeline → 执行环境(Node / jsdom / happy-dom)→ 测试结果
                |
          共享 vite.config.ts 的 resolve/alias/plugins

关键优势

  • 无需额外 Babel/SWC 配置:直接复用 Vite 的 esbuild/SWC 转换
  • 原生 ESM:无需 CJS 转换,import/export 直接运行
  • Watch 模式极快:基于 Vite 的模块依赖图,只重新运行受影响的测试

1.2 核心配置

// vitest.config.ts(或 vite.config.ts 中嵌入)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // 执行环境
    environment: 'jsdom', // 或 'happy-dom'(更快但兼容性稍弱)

    // 全局 API(省去每个文件 import)
    globals: true,

    // Setup 文件
    setupFiles: ['./src/test/setup.ts'],

    // 覆盖率
    coverage: {
      provider: 'istanbul', // 或 'v8'
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },

    // 包含/排除
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    exclude: ['node_modules', 'dist', 'e2e'],
  },
});

1.3 环境选择指南

环境 适用场景 特点
node 纯逻辑、工具函数、Node API 最快,无 DOM
jsdom 组件测试、DOM 操作 完整 DOM 模拟,兼容性最好
happy-dom 组件测试(追求速度) 比 jsdom 快 2-3x,但部分 API 缺失
edge-runtime Edge Function 测试 模拟边缘运行时

二、测试编写基础

2.1 基本结构 — describe / it / expect

import { describe, it, expect } from 'vitest';
import { add, divide } from './math';

describe('math utilities', () => {
  describe('add', () => {
    it('should add two positive numbers', () => {
      expect(add(1, 2)).toBe(3);
    });

    it('should handle negative numbers', () => {
      expect(add(-1, -2)).toBe(-3);
    });
  });

  describe('divide', () => {
    it('should divide correctly', () => {
      expect(divide(10, 2)).toBe(5);
    });

    it('should throw on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });
});

2.2 常用 Matcher(断言方法)

// 相等性
expect(value).toBe(primitive);          // Object.is 严格相等
expect(value).toEqual(object);          // 深度相等(忽略 undefined 属性)
expect(value).toStrictEqual(object);    // 深度严格相等

// 真值性
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// 数字
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3, 5);     // 浮点数比较

// 字符串
expect(str).toMatch(/regex/);
expect(str).toContain('substring');

// 数组 / 集合
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));

// 对象
expect(obj).toHaveProperty('key', 'value');
expect(obj).toMatchObject({ partial: 'match' });

// 异常
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('specific message');
expect(() => fn()).toThrowError(CustomError);

2.3 测试生命周期

describe('database operations', () => {
  let db: Database;

  beforeAll(async () => {
    // 整个 describe 块开始前执行一次
    db = await createTestDatabase();
  });

  afterAll(async () => {
    // 整个 describe 块结束后执行一次
    await db.close();
  });

  beforeEach(async () => {
    // 每个 test 前执行
    await db.clear();
    await db.seed(testData);
  });

  afterEach(() => {
    // 每个 test 后执行(清理)
    vi.restoreAllMocks();
  });

  it('should insert record', async () => {
    await db.insert({ name: 'test' });
    expect(await db.count()).toBe(testData.length + 1);
  });
});

三、Mock 机制深入

3.1 vi.fn() — 创建 Mock 函数

import { vi, describe, it, expect } from 'vitest';

const mockCallback = vi.fn();

// 设置返回值
mockCallback.mockReturnValue(42);
mockCallback.mockReturnValueOnce(1).mockReturnValueOnce(2);
mockCallback.mockResolvedValue({ data: 'async' });
mockCallback.mockImplementation((x: number) => x * 2);

// 断言调用情况
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockCallback).toHaveBeenLastCalledWith('latest');
expect(mockCallback).toHaveReturnedWith(42);

3.2 vi.spyOn() — 监听已有方法

import * as mathModule from './math';

const spy = vi.spyOn(mathModule, 'add');

// 保留原始实现,但可以监听调用
mathModule.add(1, 2); // 真正执行
expect(spy).toHaveBeenCalledWith(1, 2);

// 也可以替换实现
spy.mockImplementation(() => 999);
expect(mathModule.add(1, 2)).toBe(999);

// 恢复
spy.mockRestore();

3.3 vi.mock() — 模块级 Mock

// Mock 整个模块
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
  fetchPosts: vi.fn().mockResolvedValue([]),
}));

// 部分 Mock(保留真实实现)
vi.mock('./utils', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./utils')>();
  return {
    ...actual,
    // 只 Mock 特定导出
    formatDate: vi.fn().mockReturnValue('2025-01-01'),
  };
});

Hoisting 机制vi.mock() 调用会被 Vitest 自动提升(hoist)到文件顶部,这意味着它会在所有 import 之前执行。这是 Vitest/Jest 的核心 Mock 机制。

3.4 手动 Mock(mocks 目录)

src/
  utils/
    __mocks__/
      api.ts          ← 手动 Mock 文件
    api.ts            ← 真实模块
    api.test.ts
// api.test.ts
vi.mock('./api'); // 自动使用 __mocks__/api.ts

四、异步测试

4.1 Promise / async-await

it('should fetch user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

// 或使用 resolves/rejects matcher
it('should resolve with user', () => {
  return expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
});

it('should reject for invalid id', () => {
  return expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});

4.2 定时器(Fake Timers)

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should delay execution', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 300);

    debounced();
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(299);
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);
    expect(fn).toHaveBeenCalledTimes(1);
  });

  it('should reset timer on repeated calls', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 300);

    debounced();
    vi.advanceTimersByTime(200);
    debounced(); // 重置计时器
    vi.advanceTimersByTime(200);
    expect(fn).not.toHaveBeenCalled(); // 还没到 300ms

    vi.advanceTimersByTime(100);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

4.3 日期 Mock

vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-15T10:00:00Z'));

expect(new Date().toISOString()).toBe('2025-06-15T10:00:00.000Z');
expect(Date.now()).toBe(new Date('2025-06-15T10:00:00Z').getTime());

vi.useRealTimers();

五、快照测试(Snapshot Testing)

5.1 Inline Snapshot

it('should format config correctly', () => {
  const config = generateConfig({ mode: 'production' });

  expect(config).toMatchInlineSnapshot(`
    {
      "mode": "production",
      "minify": true,
      "sourcemap": false,
    }
  `);
});

5.2 File Snapshot

it('should generate correct HTML', () => {
  const html = renderToString(<Header title="Hello" />);
  expect(html).toMatchSnapshot(); // 存储在 __snapshots__/xxx.test.ts.snap
});

5.3 快照测试的最佳实践

  • 小而精确:避免对整个页面做快照,聚焦于特定输出
  • 有意义的更新vitest --update 更新快照前,仔细 review 变化
  • 搭配断言使用:快照不替代精确断言,而是补充

六、并行与隔离

6.1 测试并行执行

Vitest 默认文件级并行执行:

// vitest.config.ts
export default defineConfig({
  test: {
    // 文件级并行(默认开启)
    fileParallelism: true,

    // 单文件内的测试是否并行
    sequence: {
      concurrent: false, // 默认关闭,需要时在 describe 级别开启
    },

    // Worker 池配置
    pool: 'forks', // 'threads' | 'forks' | 'vmThreads'
    poolOptions: {
      forks: {
        maxForks: 4,
      },
    },
  },
});

6.2 测试隔离

// 确保每个测试文件有独立的模块注册表
export default defineConfig({
  test: {
    isolate: true, // 默认 true — 每个文件独立的模块环境
  },
});

权衡isolate: true 更安全但更慢。对于确认没有全局状态污染的项目,可以设为 false 提升速度。


七、从 Jest 迁移

7.1 API 兼容性

Vitest 的 API 高度兼容 Jest,大部分测试代码无需修改

Jest Vitest 差异
jest.fn() vi.fn() 命名空间不同
jest.mock() vi.mock() 语义相同
jest.spyOn() vi.spyOn() 语义相同
jest.useFakeTimers() vi.useFakeTimers() 语义相同
@jest/globals vitest 导入源不同

7.2 迁移步骤

# 1. 安装
npm install -D vitest @vitest/coverage-istanbul

# 2. 配置(复用 vite.config.ts)
# 3. 全局替换
# jest.fn → vi.fn / jest.mock → vi.mock / jest.spyOn → vi.spyOn

# 4. 更新 scripts
# "test": "jest" → "test": "vitest"

# 5. 移除 Jest 相关
npm uninstall jest ts-jest babel-jest @types/jest

7.3 关键差异

  • ESM 优先:Vitest 原生支持 ESM,无需 ts-jestbabel-jest
  • Hoisting 差异vi.mock 的 hoisting 行为与 jest.mock 略有不同
  • 配置统一:无需 jest.config.js,直接在 vite.config.ts 中配置

八、高级特性

8.1 Type Testing

import { expectTypeOf, describe, it } from 'vitest';

describe('type tests', () => {
  it('should infer correct return type', () => {
    expectTypeOf(add).toBeFunction();
    expectTypeOf(add).parameter(0).toBeNumber();
    expectTypeOf(add(1, 2)).toBeNumber();
  });

  it('should match complex types', () => {
    type User = { id: number; name: string };
    expectTypeOf<User>().toMatchTypeOf<{ id: number }>();
  });
});

8.2 Benchmark

import { bench, describe } from 'vitest';

describe('array sorting', () => {
  const data = Array.from({ length: 10000 }, () => Math.random());

  bench('Array.sort', () => {
    [...data].sort((a, b) => a - b);
  });

  bench('custom quicksort', () => {
    quicksort([...data]);
  });
});

8.3 Browser Mode(实验性)

// vitest.config.ts
export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: 'playwright',
      name: 'chromium',
    },
  },
});

九、面试高频题

题型 1:vi.fn / vi.spyOn / vi.mock 的区别

  • vi.fn():创建独立的 Mock 函数,不关联任何模块
  • vi.spyOn():监听已有对象的方法,可选择替换实现或保留原始
  • vi.mock():Mock 整个模块的导出,所有导入该模块的地方都受影响

题型 2:如何测试一个调用了 fetch 的函数?

// 方案 1:vi.mock + vi.fn
vi.mock('./api', () => ({
  fetchData: vi.fn().mockResolvedValue({ result: 'mocked' }),
}));

// 方案 2:MSW(推荐,更接近真实)
// 见 mocking-strategies 章节

// 方案 3:vi.spyOn(globalThis, 'fetch')
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
  new Response(JSON.stringify({ result: 'mocked' }))
);

题型 3:如何保证测试之间互不影响?

  1. afterEach(() => vi.restoreAllMocks()) — 恢复所有 Mock
  2. afterEach(() => vi.clearAllTimers()) — 清理定时器
  3. beforeEach 中重新初始化测试数据
  4. 避免修改模块级别的全局变量
  5. 开启 isolate: true(默认)确保模块隔离

十、与其他主题的关联

关联主题 关系
testing-philosophy 单元测试是 Testing Pyramid 的底层
component-testing 组件测试基于 Vitest + Testing Library
mocking-strategies Mock 是单元测试的核心技术
test-coverage 覆盖率通过 Vitest 的 coverage 插件收集
typescript Vitest 原生支持 TypeScript,无需额外配置

参考资料

延展阅读