Skip to content

单元测试

单元测试(Unit Testing)是软件测试中最基础、最重要的一环。它针对程序中最小可测试单元——函数、类、模块——进行独立验证,确保每一个"零件"在隔离环境下都能正确工作。

前端工程化发展到今天,单元测试早已不是"锦上添花"的可选项,而是高质量项目的基础设施。本文将从底层原理到实战编写,从 Vitest 深度使用到面试高频题,全面拆解前端单元测试。


一、单元测试基础

1.1 什么是单元测试

单元测试是对软件中最小可测试单元进行隔离测试的过程。在前端语境下,"单元"通常指:

  • 纯函数:工具函数、数据处理函数、格式化函数
  • 类 / 对象方法:封装了特定逻辑的类实例方法
  • 模块:一个独立导出接口的文件
  • 自定义 Hook(React)/ Composable(Vue)

关键词是隔离。单元测试不关心数据库是否连通、API 是否可用、DOM 是否渲染正确——它只关心被测单元在给定输入下是否产生预期输出。

单元测试的本质:

  ┌─────────┐        ┌──────────────────┐        ┌──────────┐
  │  Input   │──────→ │   被测单元(SUT)   │──────→ │  Output  │
  │  (输入)   │        │  System Under    │        │  (输出)   │
  │          │        │     Test         │        │          │
  └─────────┘        └──────────────────┘        └──────────┘

                     隔离外部依赖(Mock)

1.2 为什么需要单元测试

很多开发者觉得"写测试浪费时间",实际上单元测试是一项高 ROI 投资

保证代码质量

单元测试是最快发现 Bug 的手段。一个函数写完,立刻写测试验证边界条件和异常情况,问题在萌芽阶段就被消灭。

重构信心

没有测试的代码就是"遗产代码"(Legacy Code)。有了单元测试,你可以大胆重构——只要测试全部通过,功能就没有被破坏。

活文档作用

良好的测试用例本身就是最好的文档。通过阅读测试代码,新人可以快速理解函数的输入输出契约、边界行为和异常处理方式。

快速反馈循环

单元测试通常在毫秒级别执行完毕,配合 watch 模式,改一行代码立即知道结果。相比手动在浏览器中点击验证,效率提升数十倍。

降低集成成本

如果每个单元都经过充分测试,那么集成时出现问题的概率大大降低,定位问题的速度也会大幅提升。

1.3 测试金字塔

测试金字塔(Test Pyramid)是 Mike Cohn 在《Succeeding with Agile》中提出的经典模型,描述了不同层次测试之间的数量和成本关系

                    ╱╲
                   ╱  ╲
                  ╱    ╲
                 ╱ E2E  ╲          少量 · 慢速 · 高成本
                ╱ Tests  ╲         验证完整用户流程
               ╱──────────╲
              ╱            ╲
             ╱  Integration  ╲     适量 · 中速 · 中成本
            ╱     Tests      ╲     验证模块间协作
           ╱──────────────────╲
          ╱                    ╲
         ╱    Unit Tests        ╲  大量 · 快速 · 低成本
        ╱    (单元测试)           ╲  验证单个函数/模块
       ╱────────────────────────╲
      ╱                          ╲
     ╱──────────────────────────────╲

     ←───────── 数量从多到少 ──────────→
     ←───────── 速度从快到慢 ──────────→
     ←───────── 成本从低到高 ──────────→

各层测试的定位:

测试层次测试目标执行速度维护成本代表工具
单元测试单个函数/类/模块毫秒级Vitest, Jest
集成测试模块间协作秒级Testing Library, Supertest
E2E 测试完整用户流程分钟级Playwright, Cypress

黄金比例:业界推荐的比例约为 70% 单元测试 : 20% 集成测试 : 10% E2E 测试。单元测试是整座金字塔的基石——数量最多、速度最快、成本最低。

1.4 测试相关核心概念

在深入工具之前,先理解几个核心概念:

SUT(System Under Test)

被测系统,在单元测试中通常就是被测的那个函数或类。

Test Double(测试替身)

用来替代真实依赖的假对象,统称 Test Double,具体包括:

Test Double 家族:

┌─────────────────────────────────────────────────────┐
│                    Test Double                       │
│                   (测试替身)                          │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│  Dummy   │  Stub    │  Spy     │  Mock    │  Fake   │
│  哑对象   │  桩       │  间谍     │  模拟     │  假实现  │
│          │          │          │          │         │
│ 仅占位    │ 返回固定  │ 记录调用  │ 预设期望  │ 简化的   │
│ 不使用    │ 值       │ 信息     │ 并验证   │ 真实实现  │
└──────────┴──────────┴──────────┴──────────┴─────────┘
  • Dummy:仅用于填充参数,不会被实际使用
  • Stub:返回预设的固定值,不关心如何被调用
  • Spy:包装真实函数,记录调用次数、参数等信息,同时保留原始行为
  • Mock:预先设定期望行为和返回值,测试结束时验证是否被正确调用
  • Fake:一个简化版的真实实现(如用内存数据库替代真实数据库)

测试覆盖率(Code Coverage)

衡量测试覆盖代码的程度,常见指标:

指标含义说明
Line Coverage行覆盖率被执行到的代码行比例
Branch Coverage分支覆盖率if/else 等分支被覆盖的比例
Function Coverage函数覆盖率被调用到的函数比例
Statement Coverage语句覆盖率被执行到的语句比例

注意:覆盖率 100% 不等于零 Bug。覆盖率只能说明代码被执行到了,不能保证断言覆盖了所有场景。追求合理覆盖率(如核心逻辑 > 90%)比追求 100% 更务实。


二、Vitest

2.1 为什么选 Vitest

Vitest 是由 Vite 团队打造的下一代测试框架,从诞生之初就为现代前端项目而设计。

Vitest 核心优势:

┌──────────────────────────────────────────────────┐
│                    Vitest                         │
├──────────────┬───────────────────────────────────┤
│  基于 Vite    │ 复用 Vite 的配置、插件、转换管道     │
│              │ 无需重复配置 TypeScript/JSX/CSS     │
├──────────────┼───────────────────────────────────┤
│  兼容 Jest   │ 几乎 100% 兼容 Jest API            │
│              │ 迁移成本极低                        │
├──────────────┼───────────────────────────────────┤
│  原生 ESM    │ 原生支持 ES Modules                 │
│              │ 不需要 babel-jest 转换              │
├──────────────┼───────────────────────────────────┤
│  极快速度    │ 基于 esbuild 转换                    │
│              │ 多线程执行 (Worker Threads)          │
├──────────────┼───────────────────────────────────┤
│  开箱即用    │ TypeScript / JSX / CSS Modules      │
│              │ 无需额外 Loader 配置                │
├──────────────┼───────────────────────────────────┤
│  智能Watch   │ 只重新运行受影响的测试文件             │
│              │ 基于 Vite 的模块依赖图               │
└──────────────┴───────────────────────────────────┘

核心优势总结:

  1. 零配置集成 Vite 项目:与 Vite 共享配置文件(vite.config.ts),别名、插件、transform 全部复用
  2. 兼容 Jest APIdescribeitexpectvi.fn() 等 API 几乎一一对应,迁移只需改 import
  3. 原生 ESM:不再需要 babel-jest 做 CJS 转换,直接支持 import/export
  4. 极致速度:底层使用 esbuild 做代码转换,Worker Threads 并行执行测试文件
  5. 智能 Watch 模式:基于 Vite 的模块依赖图,只重跑受变更影响的测试
  6. 内置 UI 面板vitest --ui 提供一个可视化的测试结果界面

2.2 安装与配置

基础安装

bash
npm install -D vitest

vitest.config.ts 配置文件

ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    exclude: ['node_modules', 'dist'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
      thresholds: {
        lines: 80,
        branches: 80,
        functions: 80,
        statements: 80,
      },
    },
    testTimeout: 10000,
    hookTimeout: 10000,
  },
})

与 Vite 项目共享配置

如果项目已有 vite.config.ts,Vitest 会自动读取并合并配置。也可以在 vite.config.ts 中直接添加 test 字段:

ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
})

package.json scripts

json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}

TypeScript 类型支持(globals: true 时)

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

2.3 核心 API

describe — 测试套件

describe 用于将相关的测试用例组织在一起,形成逻辑分组:

ts
describe('MathUtils', () => {
  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('multiply', () => {
    it('should multiply two numbers', () => {
      expect(multiply(3, 4)).toBe(12)
    })
  })
})

describe 可以嵌套,形成树状结构,使测试输出更加清晰有层次。

it / test — 测试用例

ittest 完全等价,只是语义表达不同:

ts
it('should return true when value is positive', () => {
  expect(isPositive(5)).toBe(true)
})

test('returns true when value is positive', () => {
  expect(isPositive(5)).toBe(true)
})

常用修饰符:

ts
it.only('only this test will run', () => {})

it.skip('this test will be skipped', () => {})

it.todo('implement this later')

it.each([
  [1, 2, 3],
  [2, 3, 5],
  [10, 20, 30],
])('add(%i, %i) should return %i', (a, b, expected) => {
  expect(add(a, b)).toBe(expected)
})

it.each 是参数化测试的利器——用同一段测试逻辑验证多组输入输出,大幅减少重复代码。

expect — 断言

expect 是断言的入口,返回一个包含各种匹配器的对象:

ts
const result = calculate(10, 5)

expect(result).toBe(15)
expect(result).not.toBe(10)

生命周期钩子

测试生命周期执行顺序:

  beforeAll ──────────────────────────────────────┐
  │                                                │
  │  beforeEach ──────────────┐                   │
  │  │                         │                   │
  │  │    it('test 1', ...)    │  ← 第一个测试用例   │
  │  │                         │                   │
  │  afterEach ───────────────┘                   │
  │                                                │
  │  beforeEach ──────────────┐                   │
  │  │                         │                   │
  │  │    it('test 2', ...)    │  ← 第二个测试用例   │
  │  │                         │                   │
  │  afterEach ───────────────┘                   │
  │                                                │
  afterAll ───────────────────────────────────────┘
ts
describe('UserService', () => {
  let db: Database

  beforeAll(async () => {
    db = await Database.connect()
  })

  afterAll(async () => {
    await db.disconnect()
  })

  beforeEach(async () => {
    await db.clear()
    await db.seed()
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('should create a user', async () => {
    const user = await UserService.create({ name: 'Alice' })
    expect(user.name).toBe('Alice')
  })
})
  • beforeAll / afterAll:在整个 describe 块的所有测试前/后执行一次
  • beforeEach / afterEach:在每个 it 前/后执行一次

2.4 匹配器(Matchers)

Vitest 提供丰富的匹配器,覆盖各种断言场景:

基础匹配器

ts
expect(2 + 2).toBe(4)
expect({ name: 'Alice' }).toEqual({ name: 'Alice' })
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect(1).toBeDefined()
expect(1).toBeTruthy()
expect(0).toBeFalsy()

toBe vs toEqual 的区别非常关键:

ts
const obj1 = { a: 1 }
const obj2 = { a: 1 }

expect(obj1).toBe(obj1)
expect(obj1).not.toBe(obj2)
expect(obj1).toEqual(obj2)
  • toBe 使用 Object.is 进行严格引用比较
  • toEqual 进行深度递归比较,只要结构和值一致就通过

数值匹配器

ts
expect(10).toBeGreaterThan(5)
expect(10).toBeGreaterThanOrEqual(10)
expect(5).toBeLessThan(10)
expect(5).toBeLessThanOrEqual(5)

expect(0.1 + 0.2).toBeCloseTo(0.3, 5)

toBeCloseTo 专门解决浮点数精度问题,第二个参数指定精度位数。

字符串匹配器

ts
expect('Hello World').toContain('World')
expect('Hello World').toMatch(/hello/i)
expect('vitest').toHaveLength(6)

数组 / 集合匹配器

ts
const fruits = ['apple', 'banana', 'cherry']

expect(fruits).toContain('banana')
expect(fruits).toHaveLength(3)
expect(fruits).toEqual(expect.arrayContaining(['apple', 'cherry']))

对象匹配器

ts
const user = { name: 'Alice', age: 25, role: 'admin' }

expect(user).toHaveProperty('name')
expect(user).toHaveProperty('name', 'Alice')
expect(user).toMatchObject({ name: 'Alice', role: 'admin' })
expect(user).toEqual(expect.objectContaining({ name: 'Alice' }))

异常匹配器

ts
function divide(a: number, b: number) {
  if (b === 0) throw new Error('Division by zero')
  return a / b
}

expect(() => divide(1, 0)).toThrow()
expect(() => divide(1, 0)).toThrow('Division by zero')
expect(() => divide(1, 0)).toThrow(/zero/)
expect(() => divide(1, 0)).toThrowError(Error)

函数调用匹配器

ts
const fn = vi.fn()

fn('hello', 42)
fn('world')

expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledWith('hello', 42)
expect(fn).toHaveBeenLastCalledWith('world')
expect(fn).toHaveBeenNthCalledWith(1, 'hello', 42)

非对称匹配器

非对称匹配器可以作为 toEqual 等匹配器的参数,提供灵活的部分匹配能力:

ts
expect({ name: 'Alice', createdAt: new Date() }).toEqual({
  name: 'Alice',
  createdAt: expect.any(Date),
})

expect('hello world').toEqual(expect.stringContaining('world'))
expect('hello world').toEqual(expect.stringMatching(/^hello/))
expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 3]))
expect({ a: 1, b: 2 }).toEqual(expect.objectContaining({ a: 1 }))

2.5 Mock 系统

Mock 是单元测试的核心技术,用于隔离被测单元与外部依赖

vi.fn() — 创建模拟函数

ts
const mockFn = vi.fn()

mockFn(1, 2)
mockFn(3, 4)

expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn.mock.calls).toEqual([[1, 2], [3, 4]])
expect(mockFn.mock.results).toEqual([
  { type: 'return', value: undefined },
  { type: 'return', value: undefined },
])

设置返回值

ts
const mockFn = vi.fn()
  .mockReturnValue('default')
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')

console.log(mockFn())
console.log(mockFn())
console.log(mockFn())

设置实现

ts
const mockFn = vi.fn().mockImplementation((a: number, b: number) => a + b)

expect(mockFn(1, 2)).toBe(3)

const mockAsync = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' })

const result = await mockAsync()
expect(result).toEqual({ id: 1, name: 'Alice' })

vi.spyOn() — 监听对象方法

vi.spyOn 在保留原始实现的同时,记录方法的调用信息:

ts
const calculator = {
  add(a: number, b: number) {
    return a + b
  },
}

const spy = vi.spyOn(calculator, 'add')

const result = calculator.add(1, 2)

expect(result).toBe(3)
expect(spy).toHaveBeenCalledWith(1, 2)
expect(spy).toHaveBeenCalledTimes(1)

spy.mockRestore()

也可以在 spy 上覆盖实现:

ts
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})

doSomething()

expect(spy).toHaveBeenCalledWith('expected message')

spy.mockRestore()

vi.mock() — 模块级 Mock

ts
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  fetchPosts: vi.fn().mockResolvedValue([]),
}))

import { fetchUser } from './api'

it('should use mocked api', async () => {
  const user = await fetchUser(1)
  expect(user).toEqual({ id: 1, name: 'Alice' })
})

部分 Mock

只 Mock 模块的部分导出,保留其余真实实现:

ts
vi.mock('./utils', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./utils')>()
  return {
    ...actual,
    formatDate: vi.fn().mockReturnValue('2024-01-01'),
  }
})

Mock 原理揭秘

vi.mock() 看起来放在文件中间,但实际上 Vitest 会把它提升到文件顶部(hoisting),在所有 import 语句之前执行:

vi.mock 的执行顺序:

  源代码中的书写顺序:              实际执行顺序:
  ┌──────────────────┐            ┌──────────────────┐
  │ import { foo }   │            │ vi.mock('./mod')  │  ← 被提升
  │   from './mod'   │            │                   │
  │                  │            │ import { foo }    │
  │ vi.mock('./mod') │            │   from './mod'    │
  │                  │   ─────→   │                   │
  │ test('...', ...) │            │ test('...', ...)  │
  └──────────────────┘            └──────────────────┘

这就是为什么 vi.mock 可以写在 import 下面却仍然能拦截模块加载。

2.6 异步测试

async/await

最推荐的异步测试写法:

ts
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error('User not found')
  return response.json()
}

it('should fetch user by id', async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ id: 1, name: 'Alice' }),
  })

  const user = await fetchUser(1)
  expect(user).toEqual({ id: 1, name: 'Alice' })
})

it('should throw when user not found', async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: false,
  })

  await expect(fetchUser(999)).rejects.toThrow('User not found')
})

返回 Promise

也可以直接返回 Promise,Vitest 会等待它 resolve:

ts
it('should resolve with data', () => {
  return fetchData().then(data => {
    expect(data).toBeDefined()
  })
})

vi.useFakeTimers — 定时器测试

定时器(setTimeoutsetIntervalDate)在测试中需要特殊处理:

ts
function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  let timer: ReturnType<typeof setTimeout>
  return ((...args: unknown[]) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }) as T
}

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

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

  it('should delay function 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 subsequent calls', () => {
    const fn = vi.fn()
    const debounced = debounce(fn, 300)

    debounced()
    vi.advanceTimersByTime(200)
    debounced()
    vi.advanceTimersByTime(200)

    expect(fn).not.toHaveBeenCalled()

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

常用的定时器控制 API:

API作用
vi.useFakeTimers()启用假定时器
vi.useRealTimers()恢复真实定时器
vi.advanceTimersByTime(ms)快进指定毫秒
vi.advanceTimersToNextTimer()快进到下一个定时器触发
vi.runAllTimers()执行所有待运行的定时器
vi.runOnlyPendingTimers()只执行当前待运行的定时器
vi.setSystemTime(date)设置系统时间

测试 Date

ts
describe('date formatting', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    vi.setSystemTime(new Date('2024-06-15T10:30:00Z'))
  })

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

  it('should format current date', () => {
    const result = getCurrentDateString()
    expect(result).toBe('2024-06-15')
  })

  it('should detect if date is today', () => {
    expect(isToday(new Date('2024-06-15'))).toBe(true)
    expect(isToday(new Date('2024-06-14'))).toBe(false)
  })
})

2.7 快照测试

快照测试(Snapshot Testing)是一种特殊的断言方式:首次运行时记录输出快照,后续运行时对比当前输出与快照是否一致。

toMatchSnapshot

ts
function generateConfig(env: string) {
  return {
    apiUrl: env === 'production'
      ? 'https://api.example.com'
      : 'http://localhost:3000',
    debug: env !== 'production',
    version: '1.0.0',
  }
}

it('should generate production config', () => {
  expect(generateConfig('production')).toMatchSnapshot()
})

it('should generate development config', () => {
  expect(generateConfig('development')).toMatchSnapshot()
})

首次运行会创建 __snapshots__/xxx.test.ts.snap 文件:

exports[`should generate production config 1`] = `
{
  "apiUrl": "https://api.example.com",
  "debug": false,
  "version": "1.0.0",
}
`;

如果输出发生变化,测试会失败。使用 vitest -uvitest --update 更新快照。

toMatchInlineSnapshot

内联快照直接嵌入测试文件,不需要外部 snap 文件:

ts
it('should generate config', () => {
  expect(generateConfig('production')).toMatchInlineSnapshot(`
    {
      "apiUrl": "https://api.example.com",
      "debug": false,
      "version": "1.0.0",
    }
  `)
})

首次运行时 Vitest 会自动填充内联快照内容。

快照测试的适用场景

适用场景不适用场景
配置对象输出频繁变化的数据(含时间戳)
序列化数据结构随机值
错误消息格式大型复杂对象
CLI 输出内容需要精确断言的场景
组件渲染结果敏感数据

2.8 覆盖率

Vitest 内置覆盖率支持,提供两种 provider:

v8 Provider(推荐)

基于 V8 引擎的原生代码覆盖率功能,速度快、无额外开销:

bash
npm install -D @vitest/coverage-v8

Istanbul Provider

传统覆盖率方案,通过代码插桩(instrumentation)实现:

bash
npm install -D @vitest/coverage-istanbul
对比维度v8istanbul
原理V8 引擎原生支持代码插桩
速度更快较慢
准确性部分边界场景不够精确更精确
配置复杂度
推荐场景绝大多数项目需要精确覆盖率报告

配置覆盖率阈值

vitest.config.ts 中设置阈值,低于阈值时测试会失败:

ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 80,
        branches: 75,
        functions: 80,
        statements: 80,
      },
      all: true,
      include: ['src/**/*.ts'],
      exclude: [
        'src/**/*.test.ts',
        'src/**/*.d.ts',
        'src/types/**',
      ],
    },
  },
})

运行覆盖率

bash
npx vitest run --coverage

输出示例:

 % Coverage report from v8
-----------------------|---------|----------|---------|---------|
File                   | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files              |   85.32 |    78.45 |   90.12 |   84.67 |
 src/utils/math.ts     |   100   |    100   |   100   |   100   |
 src/utils/string.ts   |   92.31 |    85.71 |   100   |   91.67 |
 src/services/user.ts  |   73.68 |    60    |   80    |   72.22 |
-----------------------|---------|----------|---------|---------|

三、Jest(对比了解)

3.1 Jest 核心特性

Jest 是 Facebook 开源的测试框架,曾是前端测试的事实标准。核心特性包括:

  • 零配置:对 React 项目开箱即用
  • 快照测试:首创快照测试机制
  • 隔离执行:每个测试文件在独立的 worker 进程中运行
  • 丰富生态:大量第三方插件和集成方案
  • 代码覆盖率:内置 istanbul 覆盖率支持

Jest 的 API 与 Vitest 高度一致(Vitest 就是兼容 Jest API 设计的):

ts
const { describe, it, expect, jest } = require('@jest/globals')

describe('Calculator', () => {
  it('should add numbers', () => {
    expect(1 + 2).toBe(3)
  })

  it('should mock functions', () => {
    const mockFn = jest.fn().mockReturnValue(42)
    expect(mockFn()).toBe(42)
    expect(mockFn).toHaveBeenCalled()
  })
})

Jest 配置通常在 jest.config.jspackage.jsonjest 字段中:

js
module.exports = {
  testEnvironment: 'node',
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: ['src/**/*.{ts,tsx}'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
}

3.2 Vitest vs Jest 对比

对比维度VitestJest
启动速度⚡ 极快(esbuild 转换)🐢 较慢(babel-jest 转换)
ESM 支持✅ 原生支持⚠️ 实验性支持,需配置
TypeScript✅ 开箱即用(esbuild)❌ 需要 ts-jest 或 @swc/jest
配置复杂度低(复用 Vite 配置)中(独立配置体系)
Watch 模式基于模块依赖图,精准基于文件修改时间
并行执行Worker Threads(同进程更快)Worker 进程(隔离性更好)
API 兼容性兼容 Jest API
社区生态快速增长成熟庞大
Vite 项目集成⭐ 无缝集成需要额外配置
Vue/React 支持通过 Vite 插件需要 preset 配置
快照测试✅ 支持✅ 支持(首创)
UI 面板✅ 内置 vitest --ui❌ 需要第三方工具
覆盖率v8 / istanbulistanbul
Mock 系统vi.fn() / vi.mock()jest.fn() / jest.mock()
成熟度较新(2022年发布)成熟(2016年开源)

选型建议

项目选型决策树:

  使用 Vite 构建?

     ├── 是 ──→ 选 Vitest(无缝集成,零配置)

     └── 否

          ├── 新项目 ──→ 倾向 Vitest(更现代、更快)

          └── 已有 Jest 测试

               ├── 测试量少 ──→ 考虑迁移到 Vitest

               └── 测试量大 ──→ 继续 Jest,按需迁移

从 Jest 迁移到 Vitest

迁移通常非常简单,核心改动:

ts
// jest.fn() → vi.fn()
// jest.mock() → vi.mock()
// jest.spyOn() → vi.spyOn()
// jest.useFakeTimers() → vi.useFakeTimers()

// jest.config.js → vitest.config.ts
// ts-jest → 不需要(Vitest 原生支持 TS)
// babel-jest → 不需要(Vitest 原生支持 ESM)

四、测试编写最佳实践

4.1 AAA 模式

AAA(Arrange-Act-Assert) 是编写清晰测试用例的标准模式:

AAA 模式结构:

  ┌─────────────────────────────────────────┐
  │  Arrange(准备)                          │
  │  准备测试数据、创建对象、配置 Mock          │
  ├─────────────────────────────────────────┤
  │  Act(执行)                              │
  │  调用被测方法,执行目标操作                  │
  ├─────────────────────────────────────────┤
  │  Assert(断言)                           │
  │  验证结果是否符合预期                       │
  └─────────────────────────────────────────┘
ts
it('should calculate total price with discount', () => {
  const items = [
    { name: 'Book', price: 100, quantity: 2 },
    { name: 'Pen', price: 10, quantity: 5 },
  ]
  const discount = 0.1

  const total = calculateTotal(items, discount)

  expect(total).toBe(225)
})

第一段是 Arrange(准备数据),中间一行是 Act(调用函数),最后是 Assert(验证结果)。三段之间用空行分隔,一目了然。

4.2 测试命名规范

良好的测试命名应该清晰表达被测行为和预期结果

推荐格式:should [expected behavior] when [condition]

ts
it('should return empty array when input is empty string', () => {})
it('should throw TypeError when argument is not a number', () => {})
it('should sort array in ascending order when no comparator provided', () => {})
it('should retry 3 times when request fails', () => {})

反面示例

ts
it('test1', () => {})
it('works', () => {})
it('handles correctly', () => {})
it('add function', () => {})

好的测试命名让你在 CI 报告中一眼就能看出哪个行为出了问题。

4.3 单一职责

一个测试用例只测试一件事。如果一个测试失败了,应该能立刻知道是哪个功能出了问题。

反面示例

ts
it('should handle user operations', () => {
  const user = createUser('Alice')
  expect(user.name).toBe('Alice')

  user.updateName('Bob')
  expect(user.name).toBe('Bob')

  user.setAge(25)
  expect(user.age).toBe(25)

  expect(() => user.setAge(-1)).toThrow()
})

正面示例

ts
it('should create user with given name', () => {
  const user = createUser('Alice')
  expect(user.name).toBe('Alice')
})

it('should update user name', () => {
  const user = createUser('Alice')
  user.updateName('Bob')
  expect(user.name).toBe('Bob')
})

it('should set user age', () => {
  const user = createUser('Alice')
  user.setAge(25)
  expect(user.age).toBe(25)
})

it('should throw when age is negative', () => {
  const user = createUser('Alice')
  expect(() => user.setAge(-1)).toThrow()
})

4.4 避免测试实现细节

测试应该关注行为(What),而不是实现(How)。测试实现细节会导致测试脆弱——重构不改变外部行为的内部实现时,测试也会失败。

反面示例(测试实现细节)

ts
it('should use Map internally for caching', () => {
  const cache = new Cache()
  cache.set('key', 'value')
  expect(cache._store instanceof Map).toBe(true)
  expect(cache._store.size).toBe(1)
})

正面示例(测试行为)

ts
it('should return cached value after setting', () => {
  const cache = new Cache()
  cache.set('key', 'value')
  expect(cache.get('key')).toBe('value')
})

it('should return undefined for non-existent key', () => {
  const cache = new Cache()
  expect(cache.get('missing')).toBeUndefined()
})

4.5 测试边界条件和异常情况

好的测试不只测试"Happy Path",更要覆盖各种边界条件:

ts
describe('divide', () => {
  it('should divide two positive numbers', () => {
    expect(divide(10, 2)).toBe(5)
  })

  it('should handle negative numbers', () => {
    expect(divide(-10, 2)).toBe(-5)
    expect(divide(10, -2)).toBe(-5)
    expect(divide(-10, -2)).toBe(5)
  })

  it('should handle zero dividend', () => {
    expect(divide(0, 5)).toBe(0)
  })

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

  it('should handle very large numbers', () => {
    expect(divide(Number.MAX_SAFE_INTEGER, 1)).toBe(Number.MAX_SAFE_INTEGER)
  })

  it('should handle decimal numbers', () => {
    expect(divide(1, 3)).toBeCloseTo(0.3333, 4)
  })
})

常见边界条件清单:

数据类型边界条件
字符串空字符串、超长字符串、特殊字符、Unicode
数值0、负数、小数、Infinity、NaN、MAX_SAFE_INTEGER
数组空数组、单元素、超大数组、嵌套数组
对象空对象、null、undefined、嵌套对象、循环引用
异步超时、网络错误、并发调用、重复调用

4.6 测试可维护性:DRY 与可读性的平衡

测试代码需要在DRY(Don't Repeat Yourself)可读性之间找到平衡。

过度 DRY 的反面示例

ts
function runTest(input: number, expected: number) {
  it(`should return ${expected} for input ${input}`, () => {
    expect(process(input)).toBe(expected)
  })
}

runTest(1, 2)
runTest(2, 4)
runTest(3, 6)

这种写法虽然没有重复,但当测试失败时,调试体验很差。

推荐做法:使用 it.each 进行参数化

ts
it.each([
  [1, 2],
  [2, 4],
  [3, 6],
])('should return %i when input is %i', (input, expected) => {
  expect(process(input)).toBe(expected)
})

推荐做法:使用 Setup 函数抽取公共逻辑

ts
describe('UserService', () => {
  function createTestUser(overrides = {}) {
    return {
      id: 1,
      name: 'Alice',
      email: 'alice@test.com',
      role: 'user',
      ...overrides,
    }
  }

  it('should format user display name', () => {
    const user = createTestUser({ name: 'Bob' })
    expect(formatDisplayName(user)).toBe('Bob')
  })

  it('should check admin permission', () => {
    const admin = createTestUser({ role: 'admin' })
    expect(hasPermission(admin, 'manage')).toBe(true)
  })
})

这种方式既避免了重复,又保持了每个测试用例的自描述性——直接看测试代码就能理解在测什么。


五、实战示例

5.1 测试纯函数

纯函数是最容易测试的——相同输入永远返回相同输出,无副作用。

被测代码:数据处理工具函数

ts
interface Product {
  id: number
  name: string
  price: number
  category: string
  inStock: boolean
}

function filterProducts(
  products: Product[],
  filters: {
    category?: string
    minPrice?: number
    maxPrice?: number
    inStockOnly?: boolean
  }
): Product[] {
  return products.filter(product => {
    if (filters.category && product.category !== filters.category) {
      return false
    }
    if (filters.minPrice !== undefined && product.price < filters.minPrice) {
      return false
    }
    if (filters.maxPrice !== undefined && product.price > filters.maxPrice) {
      return false
    }
    if (filters.inStockOnly && !product.inStock) {
      return false
    }
    return true
  })
}

function sortProducts(
  products: Product[],
  sortBy: 'price' | 'name',
  order: 'asc' | 'desc' = 'asc'
): Product[] {
  return [...products].sort((a, b) => {
    let comparison: number
    if (sortBy === 'price') {
      comparison = a.price - b.price
    } else {
      comparison = a.name.localeCompare(b.name)
    }
    return order === 'desc' ? -comparison : comparison
  })
}

function calculateCartTotal(
  items: Array<{ product: Product; quantity: number }>,
  taxRate: number = 0
): { subtotal: number; tax: number; total: number } {
  const subtotal = items.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0
  )
  const tax = subtotal * taxRate
  return {
    subtotal,
    tax: Math.round(tax * 100) / 100,
    total: Math.round((subtotal + tax) * 100) / 100,
  }
}

测试代码

ts
import { describe, it, expect } from 'vitest'
import { filterProducts, sortProducts, calculateCartTotal } from './product'

const mockProducts: Product[] = [
  { id: 1, name: 'Laptop', price: 999, category: 'electronics', inStock: true },
  { id: 2, name: 'Book', price: 29, category: 'books', inStock: true },
  { id: 3, name: 'Phone', price: 699, category: 'electronics', inStock: false },
  { id: 4, name: 'Pen', price: 5, category: 'stationery', inStock: true },
  { id: 5, name: 'Tablet', price: 499, category: 'electronics', inStock: true },
]

describe('filterProducts', () => {
  it('should return all products when no filters applied', () => {
    const result = filterProducts(mockProducts, {})
    expect(result).toHaveLength(5)
  })

  it('should filter by category', () => {
    const result = filterProducts(mockProducts, { category: 'electronics' })
    expect(result).toHaveLength(3)
    expect(result.every(p => p.category === 'electronics')).toBe(true)
  })

  it('should filter by price range', () => {
    const result = filterProducts(mockProducts, { minPrice: 100, maxPrice: 700 })
    expect(result).toHaveLength(2)
    expect(result.map(p => p.name)).toEqual(['Phone', 'Tablet'])
  })

  it('should filter in-stock only', () => {
    const result = filterProducts(mockProducts, { inStockOnly: true })
    expect(result).toHaveLength(4)
    expect(result.every(p => p.inStock)).toBe(true)
  })

  it('should combine multiple filters', () => {
    const result = filterProducts(mockProducts, {
      category: 'electronics',
      maxPrice: 700,
      inStockOnly: true,
    })
    expect(result).toHaveLength(1)
    expect(result[0].name).toBe('Tablet')
  })

  it('should return empty array when no products match', () => {
    const result = filterProducts(mockProducts, { category: 'food' })
    expect(result).toHaveLength(0)
  })

  it('should handle empty products array', () => {
    const result = filterProducts([], { category: 'electronics' })
    expect(result).toEqual([])
  })
})

describe('sortProducts', () => {
  it('should sort by price ascending by default', () => {
    const result = sortProducts(mockProducts, 'price')
    expect(result[0].price).toBe(5)
    expect(result[result.length - 1].price).toBe(999)
  })

  it('should sort by price descending', () => {
    const result = sortProducts(mockProducts, 'price', 'desc')
    expect(result[0].price).toBe(999)
    expect(result[result.length - 1].price).toBe(5)
  })

  it('should sort by name ascending', () => {
    const result = sortProducts(mockProducts, 'name')
    expect(result.map(p => p.name)).toEqual([
      'Book', 'Laptop', 'Pen', 'Phone', 'Tablet',
    ])
  })

  it('should not mutate original array', () => {
    const original = [...mockProducts]
    sortProducts(mockProducts, 'price')
    expect(mockProducts).toEqual(original)
  })
})

describe('calculateCartTotal', () => {
  it('should calculate subtotal correctly', () => {
    const items = [
      { product: mockProducts[0], quantity: 1 },
      { product: mockProducts[1], quantity: 3 },
    ]
    const result = calculateCartTotal(items)
    expect(result.subtotal).toBe(999 + 29 * 3)
    expect(result.tax).toBe(0)
    expect(result.total).toBe(1086)
  })

  it('should apply tax rate', () => {
    const items = [{ product: mockProducts[0], quantity: 1 }]
    const result = calculateCartTotal(items, 0.1)
    expect(result.subtotal).toBe(999)
    expect(result.tax).toBe(99.9)
    expect(result.total).toBe(1098.9)
  })

  it('should handle empty cart', () => {
    const result = calculateCartTotal([])
    expect(result).toEqual({ subtotal: 0, tax: 0, total: 0 })
  })

  it('should round tax to 2 decimal places', () => {
    const items = [
      { product: { ...mockProducts[1], price: 33 }, quantity: 1 },
    ]
    const result = calculateCartTotal(items, 0.07)
    expect(result.tax).toBe(2.31)
  })
})

5.2 测试异步函数

被测代码:API 服务层

ts
interface User {
  id: number
  name: string
  email: string
}

class UserService {
  private baseUrl: string

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  async getUser(id: number): Promise<User> {
    if (id <= 0) throw new Error('Invalid user ID')

    const response = await fetch(`${this.baseUrl}/users/${id}`)

    if (response.status === 404) {
      throw new Error(`User ${id} not found`)
    }

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`)
    }

    return response.json()
  }

  async createUser(data: Omit<User, 'id'>): Promise<User> {
    if (!data.name?.trim()) throw new Error('Name is required')
    if (!data.email?.includes('@')) throw new Error('Invalid email')

    const response = await fetch(`${this.baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    })

    if (!response.ok) {
      throw new Error('Failed to create user')
    }

    return response.json()
  }

  async getUsersWithRetry(
    retries: number = 3,
    delay: number = 1000
  ): Promise<User[]> {
    for (let i = 0; i < retries; i++) {
      try {
        const response = await fetch(`${this.baseUrl}/users`)
        if (response.ok) return response.json()
      } catch {
        if (i === retries - 1) throw new Error('Max retries exceeded')
        await new Promise(resolve => setTimeout(resolve, delay))
      }
    }
    throw new Error('Max retries exceeded')
  }
}

测试代码

ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

describe('UserService', () => {
  let service: UserService

  beforeEach(() => {
    service = new UserService('https://api.example.com')
    global.fetch = vi.fn()
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  describe('getUser', () => {
    it('should fetch user by id', async () => {
      const mockUser = { id: 1, name: 'Alice', email: 'alice@test.com' }
      vi.mocked(fetch).mockResolvedValue({
        ok: true,
        status: 200,
        json: () => Promise.resolve(mockUser),
      } as Response)

      const user = await service.getUser(1)

      expect(user).toEqual(mockUser)
      expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1')
    })

    it('should throw when id is invalid', async () => {
      await expect(service.getUser(0)).rejects.toThrow('Invalid user ID')
      await expect(service.getUser(-1)).rejects.toThrow('Invalid user ID')
      expect(fetch).not.toHaveBeenCalled()
    })

    it('should throw when user not found', async () => {
      vi.mocked(fetch).mockResolvedValue({
        ok: false,
        status: 404,
      } as Response)

      await expect(service.getUser(999)).rejects.toThrow('User 999 not found')
    })

    it('should throw on server error', async () => {
      vi.mocked(fetch).mockResolvedValue({
        ok: false,
        status: 500,
      } as Response)

      await expect(service.getUser(1)).rejects.toThrow('HTTP Error: 500')
    })
  })

  describe('createUser', () => {
    it('should create user with valid data', async () => {
      const newUser = { name: 'Bob', email: 'bob@test.com' }
      const createdUser = { id: 2, ...newUser }
      vi.mocked(fetch).mockResolvedValue({
        ok: true,
        json: () => Promise.resolve(createdUser),
      } as Response)

      const result = await service.createUser(newUser)

      expect(result).toEqual(createdUser)
      expect(fetch).toHaveBeenCalledWith(
        'https://api.example.com/users',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify(newUser),
        })
      )
    })

    it('should throw when name is empty', async () => {
      await expect(
        service.createUser({ name: '', email: 'test@test.com' })
      ).rejects.toThrow('Name is required')
    })

    it('should throw when email is invalid', async () => {
      await expect(
        service.createUser({ name: 'Alice', email: 'invalid' })
      ).rejects.toThrow('Invalid email')
    })
  })

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

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

    it('should return users on first success', async () => {
      const mockUsers = [{ id: 1, name: 'Alice', email: 'a@t.com' }]
      vi.mocked(fetch).mockResolvedValue({
        ok: true,
        json: () => Promise.resolve(mockUsers),
      } as Response)

      const result = await service.getUsersWithRetry()

      expect(result).toEqual(mockUsers)
      expect(fetch).toHaveBeenCalledTimes(1)
    })

    it('should retry on failure', async () => {
      vi.mocked(fetch)
        .mockRejectedValueOnce(new Error('Network error'))
        .mockRejectedValueOnce(new Error('Network error'))
        .mockResolvedValue({
          ok: true,
          json: () => Promise.resolve([]),
        } as Response)

      const promise = service.getUsersWithRetry(3, 100)

      await vi.advanceTimersByTimeAsync(100)
      await vi.advanceTimersByTimeAsync(100)

      const result = await promise
      expect(result).toEqual([])
      expect(fetch).toHaveBeenCalledTimes(3)
    })

    it('should throw after max retries', async () => {
      vi.mocked(fetch).mockRejectedValue(new Error('Network error'))

      const promise = service.getUsersWithRetry(3, 100)

      await vi.advanceTimersByTimeAsync(100)
      await vi.advanceTimersByTimeAsync(100)

      await expect(promise).rejects.toThrow('Max retries exceeded')
    })
  })
})

5.3 测试类 / 模块

被测代码:事件发射器

ts
type EventHandler = (...args: unknown[]) => void

class EventEmitter {
  private events: Map<string, Set<EventHandler>> = new Map()

  on(event: string, handler: EventHandler): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set())
    }
    this.events.get(event)!.add(handler)

    return () => this.off(event, handler)
  }

  off(event: string, handler: EventHandler): void {
    this.events.get(event)?.delete(handler)
  }

  emit(event: string, ...args: unknown[]): void {
    this.events.get(event)?.forEach(handler => handler(...args))
  }

  once(event: string, handler: EventHandler): () => void {
    const wrappedHandler = (...args: unknown[]) => {
      handler(...args)
      this.off(event, wrappedHandler)
    }
    return this.on(event, wrappedHandler)
  }

  listenerCount(event: string): number {
    return this.events.get(event)?.size ?? 0
  }

  removeAllListeners(event?: string): void {
    if (event) {
      this.events.delete(event)
    } else {
      this.events.clear()
    }
  }
}

测试代码

ts
import { describe, it, expect, vi } from 'vitest'
import { EventEmitter } from './event-emitter'

describe('EventEmitter', () => {
  let emitter: EventEmitter

  beforeEach(() => {
    emitter = new EventEmitter()
  })

  describe('on / emit', () => {
    it('should register and trigger event handler', () => {
      const handler = vi.fn()
      emitter.on('click', handler)

      emitter.emit('click')

      expect(handler).toHaveBeenCalledTimes(1)
    })

    it('should pass arguments to handler', () => {
      const handler = vi.fn()
      emitter.on('data', handler)

      emitter.emit('data', 'hello', 42)

      expect(handler).toHaveBeenCalledWith('hello', 42)
    })

    it('should support multiple handlers for same event', () => {
      const handler1 = vi.fn()
      const handler2 = vi.fn()
      emitter.on('click', handler1)
      emitter.on('click', handler2)

      emitter.emit('click')

      expect(handler1).toHaveBeenCalledTimes(1)
      expect(handler2).toHaveBeenCalledTimes(1)
    })

    it('should not trigger handlers for different events', () => {
      const handler = vi.fn()
      emitter.on('click', handler)

      emitter.emit('hover')

      expect(handler).not.toHaveBeenCalled()
    })
  })

  describe('off', () => {
    it('should remove event handler', () => {
      const handler = vi.fn()
      emitter.on('click', handler)
      emitter.off('click', handler)

      emitter.emit('click')

      expect(handler).not.toHaveBeenCalled()
    })

    it('should return unsubscribe function from on()', () => {
      const handler = vi.fn()
      const unsubscribe = emitter.on('click', handler)

      unsubscribe()
      emitter.emit('click')

      expect(handler).not.toHaveBeenCalled()
    })
  })

  describe('once', () => {
    it('should trigger handler only once', () => {
      const handler = vi.fn()
      emitter.once('click', handler)

      emitter.emit('click')
      emitter.emit('click')
      emitter.emit('click')

      expect(handler).toHaveBeenCalledTimes(1)
    })

    it('should pass arguments on single invocation', () => {
      const handler = vi.fn()
      emitter.once('data', handler)

      emitter.emit('data', 'payload')

      expect(handler).toHaveBeenCalledWith('payload')
    })
  })

  describe('listenerCount', () => {
    it('should return 0 for event with no listeners', () => {
      expect(emitter.listenerCount('click')).toBe(0)
    })

    it('should return correct count', () => {
      emitter.on('click', vi.fn())
      emitter.on('click', vi.fn())
      emitter.on('hover', vi.fn())

      expect(emitter.listenerCount('click')).toBe(2)
      expect(emitter.listenerCount('hover')).toBe(1)
    })
  })

  describe('removeAllListeners', () => {
    it('should remove all listeners for specific event', () => {
      emitter.on('click', vi.fn())
      emitter.on('click', vi.fn())
      emitter.on('hover', vi.fn())

      emitter.removeAllListeners('click')

      expect(emitter.listenerCount('click')).toBe(0)
      expect(emitter.listenerCount('hover')).toBe(1)
    })

    it('should remove all listeners when no event specified', () => {
      emitter.on('click', vi.fn())
      emitter.on('hover', vi.fn())

      emitter.removeAllListeners()

      expect(emitter.listenerCount('click')).toBe(0)
      expect(emitter.listenerCount('hover')).toBe(0)
    })
  })
})

5.4 测试错误处理

被测代码:表单验证器

ts
interface ValidationResult {
  valid: boolean
  errors: string[]
}

interface UserInput {
  username: string
  email: string
  password: string
  age: number
}

function validateUserInput(input: Partial<UserInput>): ValidationResult {
  const errors: string[] = []

  if (!input.username || input.username.trim().length < 3) {
    errors.push('Username must be at least 3 characters')
  }

  if (input.username && input.username.length > 20) {
    errors.push('Username must not exceed 20 characters')
  }

  if (input.username && !/^[a-zA-Z0-9_]+$/.test(input.username)) {
    errors.push('Username can only contain letters, numbers, and underscores')
  }

  if (!input.email || !input.email.includes('@')) {
    errors.push('Valid email is required')
  }

  if (!input.password || input.password.length < 8) {
    errors.push('Password must be at least 8 characters')
  }

  if (input.password && !/[A-Z]/.test(input.password)) {
    errors.push('Password must contain at least one uppercase letter')
  }

  if (input.password && !/[0-9]/.test(input.password)) {
    errors.push('Password must contain at least one number')
  }

  if (input.age !== undefined && (input.age < 0 || input.age > 150)) {
    errors.push('Age must be between 0 and 150')
  }

  return { valid: errors.length === 0, errors }
}

测试代码

ts
import { describe, it, expect } from 'vitest'
import { validateUserInput } from './validator'

describe('validateUserInput', () => {
  const validInput = {
    username: 'alice_01',
    email: 'alice@example.com',
    password: 'SecurePass1',
    age: 25,
  }

  it('should pass with valid input', () => {
    const result = validateUserInput(validInput)
    expect(result.valid).toBe(true)
    expect(result.errors).toHaveLength(0)
  })

  describe('username validation', () => {
    it('should fail when username is empty', () => {
      const result = validateUserInput({ ...validInput, username: '' })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain('Username must be at least 3 characters')
    })

    it('should fail when username is too short', () => {
      const result = validateUserInput({ ...validInput, username: 'ab' })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain('Username must be at least 3 characters')
    })

    it('should fail when username is too long', () => {
      const result = validateUserInput({
        ...validInput,
        username: 'a'.repeat(21),
      })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain('Username must not exceed 20 characters')
    })

    it('should fail when username has special characters', () => {
      const result = validateUserInput({ ...validInput, username: 'alice@!' })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain(
        'Username can only contain letters, numbers, and underscores'
      )
    })

    it('should accept username with exactly 3 characters', () => {
      const result = validateUserInput({ ...validInput, username: 'abc' })
      expect(result.valid).toBe(true)
    })
  })

  describe('email validation', () => {
    it('should fail when email is missing', () => {
      const result = validateUserInput({ ...validInput, email: '' })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain('Valid email is required')
    })

    it('should fail when email has no @ symbol', () => {
      const result = validateUserInput({ ...validInput, email: 'invalid' })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain('Valid email is required')
    })
  })

  describe('password validation', () => {
    it('should fail when password is too short', () => {
      const result = validateUserInput({ ...validInput, password: 'Ab1' })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain(
        'Password must be at least 8 characters'
      )
    })

    it('should fail when password has no uppercase', () => {
      const result = validateUserInput({
        ...validInput,
        password: 'lowercase1',
      })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain(
        'Password must contain at least one uppercase letter'
      )
    })

    it('should fail when password has no number', () => {
      const result = validateUserInput({
        ...validInput,
        password: 'NoNumberHere',
      })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain(
        'Password must contain at least one number'
      )
    })
  })

  describe('age validation', () => {
    it('should fail when age is negative', () => {
      const result = validateUserInput({ ...validInput, age: -1 })
      expect(result.valid).toBe(false)
      expect(result.errors).toContain('Age must be between 0 and 150')
    })

    it('should fail when age exceeds 150', () => {
      const result = validateUserInput({ ...validInput, age: 200 })
      expect(result.valid).toBe(false)
    })

    it('should pass when age is omitted', () => {
      const { age, ...inputWithoutAge } = validInput
      const result = validateUserInput(inputWithoutAge)
      expect(result.valid).toBe(true)
    })
  })

  describe('multiple errors', () => {
    it('should collect all validation errors', () => {
      const result = validateUserInput({})
      expect(result.valid).toBe(false)
      expect(result.errors.length).toBeGreaterThan(1)
    })
  })
})

5.5 测试环境设置与 Setup 文件

对于需要全局配置的场景(如 DOM 环境、全局 Mock),可以使用 setup 文件:

ts
import { vi, afterEach } from 'vitest'

afterEach(() => {
  vi.restoreAllMocks()
  vi.clearAllTimers()
})

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

Object.defineProperty(window, 'localStorage', {
  value: (() => {
    let store: Record<string, string> = {}
    return {
      getItem: (key: string) => store[key] ?? null,
      setItem: (key: string, value: string) => { store[key] = value },
      removeItem: (key: string) => { delete store[key] },
      clear: () => { store = {} },
      get length() { return Object.keys(store).length },
      key: (index: number) => Object.keys(store)[index] ?? null,
    }
  })(),
})

在配置文件中引用:

ts
export default defineConfig({
  test: {
    setupFiles: ['./src/test/setup.ts'],
  },
})

六、面试高频问题

问题 1:单元测试和集成测试的区别是什么?什么时候该写哪种?

回答思路

单元测试隔离测试单个函数/模块,所有外部依赖都被 Mock 替换;集成测试则测试多个模块协作的正确性,通常不 Mock 或只 Mock 外部系统(如 API、数据库)。

选择策略:纯逻辑(工具函数、数据处理)→ 单元测试;模块交互、数据流转 → 集成测试;用户操作流程 → E2E 测试。遵循测试金字塔,单元测试占大多数。

问题 2:什么是 Mock?vi.fn()、vi.spyOn()、vi.mock() 三者有什么区别?

回答思路

API作用域典型用途
vi.fn()创建独立的模拟函数作为回调参数传入、验证是否被调用
vi.spyOn()监听/替换已有对象的方法监听 console.log、替换实例方法
vi.mock()模块级别替换Mock 整个文件的导出(如 API 模块)

vi.fn() 创建一个全新的空函数;vi.spyOn() 包装已存在的函数,默认保留原实现,也可覆盖;vi.mock() 在模块系统层面拦截 import,替换整个模块的导出。

问题 3:为什么 vi.mock() 可以写在 import 下面却仍然能 Mock 成功?

回答思路

Vitest(和 Jest)会对测试文件进行静态分析,将 vi.mock() 调用提升(hoist)到文件顶部,在所有 import 语句之前执行。这样当 import 执行时,模块系统已经被替换成了 Mock 版本。这就是为什么 vi.mock() 的回调里不能引用外部变量(除非用 vi.hoisted() 提前声明)。

问题 4:快照测试有什么优缺点?什么时候适合使用?

回答思路

优点:编写简单(一行断言搞定),能快速捕获意外变更,对大型输出结构很方便。

缺点:快照容易过时,开发者可能盲目更新而不审查变化;大型快照难以审阅 diff;测试意图不明确。

适用场景:配置对象、序列化数据、错误消息格式、组件渲染结果。不适用:包含时间戳/随机值的数据、频繁变化的输出、需要精确逻辑断言的场景。

问题 5:如何测试异步函数?遇到定时器怎么处理?

回答思路

异步函数测试使用 async/await + rejects/resolves 匹配器。关键是要 Mock 外部依赖(如 fetch),确保测试不依赖网络。

定时器使用 vi.useFakeTimers() 接管 setTimeout/setInterval/Date,然后用 vi.advanceTimersByTime() 手动推进时间。测试结束后务必 vi.useRealTimers() 恢复。

问题 6:测试覆盖率 100% 就代表没有 Bug 吗?

回答思路

绝对不是。覆盖率只能衡量代码被执行到了多少比例,但无法保证:

  1. 断言覆盖了所有场景(代码跑了但没有 assert)
  2. 边界条件被测到了(如空值、极端值、并发)
  3. 业务逻辑正确性(逻辑本身就写错了,测试也按错误逻辑写的)
  4. 非功能性问题(性能、安全、兼容性)

覆盖率是"必要不充分条件"——覆盖率低说明测试不够,覆盖率高不说明测试充分。合理目标是核心模块 > 90%,整体 > 80%。

问题 7:什么是 AAA 模式?测试中 DRY 原则应该怎么运用?

回答思路

AAA = Arrange(准备数据和环境)→ Act(执行被测操作)→ Assert(验证结果)。这个模式让每个测试用例结构清晰,三段用空行分隔,可读性强。

关于 DRY:测试代码中不应过度追求 DRY。过度抽象会降低测试的可读性和调试体验。推荐做法是用 beforeEach 处理重复的 setup,用工厂函数(如 createTestUser)生成测试数据,用 it.each 做参数化测试。但每个测试用例的 Act 和 Assert 应该保持内联,一眼就能看出在测什么。

问题 8:Vitest 相比 Jest 有哪些优势?为什么越来越多项目选择 Vitest?

回答思路

核心优势:

  1. 速度:基于 esbuild 转换,Worker Threads 并行执行,比 Jest 快数倍
  2. ESM 原生支持:不需要 babel-jest 转换,直接运行 ESM 代码
  3. Vite 集成:与 Vite 共享配置和插件,对 Vite 项目零配置
  4. TypeScript 开箱即用:不需要 ts-jest 或额外配置
  5. 智能 Watch:基于 Vite 的模块依赖图,只重跑受影响的测试
  6. 内置 UIvitest --ui 提供可视化界面

局限:相比 Jest 社区生态稍年轻,部分 Jest 生态插件还没有 Vitest 适配版本。但 API 高度兼容,迁移成本很低。


七、延伸阅读

用心学习,用代码说话 💻