Vite 测试实战:Vitest 单元测试、组件测试与 E2E 测试

系统覆盖 Vite 生态的测试方案:Vitest 单元测试(断言/参数化/快照)、组件测试(Vue/React Testing Library)、E2E 测试(Playwright)、Mock 与覆盖率、CI 集成,以及 Vite 插件兼容性。

引言

测试是前端工程化的最后一公里。Vitest 由 Vite 团队打造,直接复用 Vite 的配置、转换与模块图——无需另起炉灶就能为你的 Vite 项目配好单元测试、组件测试与 E2E 测试。本文按「单测 → 组件测 → E2E」三层递进:先讲 Vitest 的核心能力(断言、参数化、快照、Mock),再讲 Vue/React 组件测试的挂载与交互,最后用 Playwright 打通浏览器端到端链路,并给出覆盖率与 CI 集成方案。

前置:/vite-scaffold-engineering/(项目工程化)、/vite-config-guide/(Vite 配置)。测试理论见 [[testing]]。


目录


1. 为什么是 Vitest:与 Vite 同源的优势

Vitest 直接复用 Vite 的转换管线——同一个 vite.config.ts 即可驱动开发与测试,无需 Jest 的 babel 转译配置:

传统 Jest:   babel/jest 配置 → 双份转换 → 类型/别名/环境要同步维护
Vitest:      vite.config.ts 一套配置 → esbuild 转换 → 开箱即用
特性JestVitest
配置jest.config + babel复用 vite.config
转换Babel 生态esbuild(快 10-100×)
TS 支持需 ts-jest原生
ESM支持差原生
别名需映射复用 resolve.alias
组件测试需额外配置@vue/test-utils / Testing Library 直挂

心智:Vite 项目选 Vitest 不是「二选一」,而是「同源免费」——配置、别名、转换全继承。


2. 快速上手:第一个单元测试

安装:

npm i -D vitest
# 组件测试可选
npm i -D @vue/test-utils jsdom   # Vue
npm i -D @testing-library/react  # React

测试目标(一个纯函数):

// src/utils/format.ts
export function formatPrice(n: number, currency = '¥'): string {
  return `${currency}${n.toFixed(2)}`
}

测试文件:

// src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './format'

describe('formatPrice', () => {
  it('保留两位小数', () => {
    expect(formatPrice(3.14159)).toBe('¥3.14')
  })
  it('支持自定义货币符号', () => {
    expect(formatPrice(10, '$')).toBe('$10.00')
  })
  it('处理负数', () => {
    expect(formatPrice(-5.5)).toBe('¥-5.50')
  })
})

运行:

vitest            # watch 模式
vitest run        # 一次性
vitest run --coverage   # 带覆盖率

package.json 脚本:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

记忆:Vitest 文件按 *.test.ts / *.spec.ts 约定自动发现——测试放 src 旁(就近)或 test/ 目录皆可。


3. 断言、参数化与快照测试

常用断言(vitest 内置 chai 风格 + jest 兼容):

expect(sum(1, 2)).toBe(3)              // 严格相等(Object.is)
expect(arr).toEqual([1, 2, 3])          // 深比较
expect(obj).toMatchObject({ a: 1 })     // 部分匹配
expect(n).toBeGreaterThan(0)
expect('abc').toMatch(/b/)
expect(fn).toHaveBeenCalledWith(1)
expect(() => risky()).toThrow('bad')

参数化测试(同一逻辑多组数据):

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

const cases = [
  [2, 2, 4],
  [3, 5, 8],
  [-1, 1, 0],
] as const

describe.each(cases)('add(%i, %i)', (a, b, expected) => {
  it(`返回 ${expected}`, () => {
    expect(add(a, b)).toBe(expected)
  })
})

快照测试(锁定大型对象/渲染输出):

it('组件快照', () => {
  const wrapper = mount(MyComponent, { props: { name: 'Leeting' } })
  expect(wrapper.html()).toMatchSnapshot()
})
// 更新快照:vitest -u
断言类型场景
toBe / toEqual基础值/对象深比较
toMatch字符串正则
toThrow异常路径
toHaveBeenCalledMock 调用断言
toMatchSnapshotUI/序列化结果锁定

记忆:参数化消灭重复用例,快照防止意外回归——但快照别滥用,大而频繁变更的快照价值低。


4. Mock:模拟模块、函数与定时器

模拟模块(避免真实网络/副作用):

// src/services/api.ts
export async function fetchUser(id: number) { /* 真实请求 */ }

// 测试
import { vi } from 'vitest'

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

import { fetchUser } from './api'
const user = await fetchUser(1)
expect(fetchUser).toHaveBeenCalledWith(1)
expect(user.name).toBe('Alice')

局部 mock(spy):

import { getUser } from './user'

const spy = vi.spyOn(console, 'log')   // 监听原生
getUser(1)
expect(spy).toHaveBeenCalled()
spy.mockRestore()                       // 还原

定时器 mock:

vi.useFakeTimers()

it('debounce 延迟触发', () => {
  const fn = vi.fn()
  debounce(fn, 300)()
  vi.advanceTimersByTime(300)
  expect(fn).toHaveBeenCalledTimes(1)
})

全局 mock 目录:src/__mocks__/ 或 testSetup.ts 里统一 vi.mock('@/utils/storage')。

Mock 方式用法场景
vi.mock(模块)整体替换网络/环境依赖
vi.fn()造函数回调/事件
vi.spyOn(obj, ’m')包裹原方法监听/断言
vi.useFakeTimers假定时器防抖/延时

铁律:Mock 只该隔离「外部副作用」,不该掩盖被测逻辑本身——mock 过度会让测试失去意义。


5. 组件测试:Vue 与 React 的挂载交互

Vue 组件测试(@vue/test-utils + jsdom 环境):

// vitest.config 或 vite.config 指定环境
// export default defineConfig({ test: { environment: 'jsdom' } })

import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

it('点击累加', async () => {
  const wrapper = mount(Counter)
  await wrapper.find('button').trigger('click')
  expect(wrapper.text()).toContain('1')
})

React 组件测试(@testing-library/react):

import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './Counter'

it('点击累加', () => {
  render(<Counter />)
  fireEvent.click(screen.getByRole('button', { name: '+' }))
  expect(screen.getByText('count: 1')).toBeInTheDocument()
})

组件测试要点:

要点说明
jsdom 环境test.environment: 'jsdom'
异步更新await flush(nextTick / findBy)
选择器优先 role/text(用户视角)
触发事件fireEvent / userEvent
挂载清理Testing Library 自动清理

心智:组件测试测「行为」不测「实现细节」——按用户能看到/交互到的角度断言,别锁死内部 DOM 结构。


6. E2E 测试:Playwright 浏览器链路

单元/组件测试在 Node 里模拟 DOM,E2E 在真实浏览器跑完整链路(登录 → 操作 → 跳转 → 断言):

npm i -D @playwright/test
npx playwright install chromium

配置:

// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:5173',   // Vite dev server
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npm run dev',             // 自动起 dev server
    port: 5173,
    reuseExistingServer: true,
  },
})

测试用例:

// e2e/cart.spec.ts
import { test, expect } from '@playwright/test'

test('加入购物车全链路', async ({ page }) => {
  await page.goto('/products/1')
  await page.getByRole('button', { name: '加入购物车' }).click()
  await expect(page.getByText('购物车(1)')).toBeVisible()
  await page.getByRole('link', { name: '结算' }).click()
  await expect(page).toHaveURL(/\/checkout/)
})

E2E 分层建议:

层级覆盖工具
单元函数/逻辑Vitest
组件组件行为@vue/test-utils
E2E关键用户旅程Playwright

记忆:测试金字塔——单测多、组件中、E2E 精——E2E 慢且脆,只覆盖核心流程,别全站脚本化。


7. 覆盖率统计与阈值门禁

开启覆盖率:

npm i -D @vitest/coverage-v8
vitest run --coverage

配置阈值(低于则失败,CI 门禁):

// vite.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{ts,vue,tsx}'],
      exclude: ['src/**/*.test.*', 'src/main.ts'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
        statements: 80,
      },
    },
  },
})

覆盖率报告解读:

指标含义关注点
Lines代码行执行比例基础覆盖
Functions函数调用比例逻辑分支覆盖
Branchesif/switch 分支边界条件
Statements语句执行比例与 lines 近似

记忆:覆盖率是「兜底」不是「目标」——先保证关键路径测到位,再堆数值;阈值设 80/70 防劣化即可。


8. CI 集成:GitHub Actions 与缓存加速

GitHub Actions 示例:

# .github/workflows/test.yml
name: Test
on:
  push:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test        # vitest run
      - run: pnpm test:e2e    # playwright

加速技巧:

手段效果
pnpm cache依赖命中秒装
vitest --reporter=dot缩短日志
分片vitest run --shard=1/3 并行 Job
Playwright 只装需用浏览器--with-deps chromium
跳过 E2E(仅核心 commit)path 过滤

记忆:CI 的价值在「每次提交都跑」——把单测设为必过门禁、E2E 设为合并前必跑,守住主干质量。


9. 常见问题与最佳实践

常见坑:

问题解法
别名解析失败用 vite resolve.alias 统一,测试自动继承
ESM 依赖报错test.server.deps.inline 或该依赖预构建
jsdom 缺 API加 test.environmentOptions / 用 happy-dom
异步组件不更新await wrapper.vm.$nextTick()
快照频繁变化减小快照粒度或改用断言
CI 内存不足限制 worker:--pool=forks --poolOptions.forks.maxForks=2

最佳实践:

1. 测试贴近源码(.test.ts 同目录),便于迁移与发现
2. 纯逻辑优先测(utils/hooks/状态机)
3. 组件测用户行为,E2E 测关键旅程
4. 覆盖率阈值防劣化,但不追求 100%
5. 依赖用真实行为 mock,避免过度 mock

一句话记忆:Vitest 与 Vite 同源零配置,单元测逻辑、组件测行为、Playwright 测旅程;Mock 只隔离副作用,覆盖率做门禁——测试金字塔守住工程质量。


10. 速查表

需求做法
单元测试Vitest + vitest run
组件测试@vue/test-utils / Testing Library + jsdom
E2E 测试Playwright + webServer 起 dev server
Mock 模块vi.mock('./mod', () => ...)
假定时器vi.useFakeTimers()
覆盖率@vitest/coverage-v8 + thresholds
快照toMatchSnapshot() + vitest -u
CIGitHub Actions + pnpm cache + shard
异步断言await / findBy* / nextTick

延伸阅读

  • /vite-scaffold-engineering/ — 工程化起步与目录规范
  • /vite-config-guide/ — test 相关配置与别名解析
  • /vite-ci-cd-optimization/ — CI 构建优化与缓存
  • [[testing]] — 测试方法论与测试金字塔
  • [[nodejs]] — Node 工具链与脚本

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「vite」更多文章

  1. Vite 静态资源与媒体资产处理:图片、字体、SVG 与 Worker
  2. Vite 环境变量与生产构建最佳实践:import.meta.env、构建模式与产物优化
  3. Vite 浏览器兼容与 Legacy 构建:build.target、Polyfill 与兼容插件