前端测试实践:Vitest、Testing Library 与 E2E 全栈指南
"前端很难测试"是过去的印象。随着 Vitest、Testing Library 与 Playwright 的成熟,前端测试已经非常顺手,难点只在于做好分层:哪些该用单元测试、哪些该用组件测试、哪些必须端到端,以及各自放在 CI 的哪个环节。把金字塔搭对,测试就是护城河而不是负担。
测试工程与前端搭建强相关。搭建可测试的工程,可从Vite 构建工具与TypeScript 指南入手;CI 环节可参考GitHub Actions CI/CD。
测试金字塔:先决定测什么
- 单元测试:测纯函数、工具函数、状态逻辑(store/reducer),速度快、定位准;
- 组件测试:渲染组件、模拟用户交互、断言 UI 状态,覆盖"组件能否正确响应";
- 集成/E2E 测试:跑真实浏览器,覆盖关键用户旅程(登录、下单、支付),最接近真实但最慢最脆;
- 实践建议:底层多、上层少。底层快速反馈、跑在每次提交;E2E 只保核心流程、跑在合并前。
单元测试:Vitest
Vitest 是由 Vite 驱动的新一代测试框架,复用 Vite 的配置与插件(还能直接读 vite.config.*),Node 20+ 即可,测试文件按 .test./.spec. 约定命名:
import { expect, test } from 'vitest'
import { sum } from './sum.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
常用能力:describe/it/expect 断言、vi.mock 模拟模块、快照测试、覆盖率报告(--coverage)、vitest run 单次执行。它还支持 Browser Mode(在真实浏览器里跑测试)与组件测试,适合需要 DOM 但不想起整套 E2E 的场景。
测试文件怎么摆
测试文件放哪、怎么命名,直接影响维护体验。常见做法是"就近放置":单测与组件测试放在源码同目录,文件名带 .test.tsx 后缀;共享的测试工具(render helper、MSW handlers)放 src/test/。一个 Vite + React 项目通常是这样的:
src/
components/
Button.tsx
Button.test.tsx
lib/
format.ts
format.test.ts
test/
setup.ts # 测试环境初始化
server.ts # MSW 服务器
vitest.config.ts 里把 test.environment 设为 jsdom(测 DOM)或 node(纯逻辑),并加载 setup.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: { provider: 'v8' },
},
})
组件测试:Testing Library
Testing Library 的核心原则一句话:"测试越接近用户使用软件的方式,越能给你信心。" 它不测试实现细节,而是像用户一样查询页面——按标签文本找表单、按文字找按钮,而不是按 data-testid(后者只是逃生舱):
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('logs in with credentials', async () => {
render(<Login />)
await userEvent.type(screen.getByLabelText('邮箱'), '[email protected]')
await userEvent.click(screen.getByRole('button', { name: '登录' }))
expect(await screen.findByText('欢迎回来')).toBeInTheDocument()
})
getByRole/getByLabelText/findByText 这类查询天然推动你写出更可访问的组件——查询不到往往意味着缺少正确的标签或语义。Testing Library 不是测试框架,需要与 Vitest/Jest 搭配使用。
查询的优先级
查询的优先级也有一套约定,从最贴近用户的方式开始:getByRole(按语义角色)→ getByLabelText(表单)→ getByPlaceholderText → getByText → getByTestId(最后手段)。按下表自查,能帮你在"可访问性"和"稳定查询"之间找到平衡:
| 查询 | 适用 | 备注 |
|---|---|---|
getByRole |
按钮、链接、标题 | 最接近真实用户与无障碍树 |
getByLabelText |
输入框、选择框 | 表单首选 |
getByText |
段落、普通文案 | 注意避免匹配过多元素 |
getByTestId |
难以语义化的容器 | 只作逃生舱,别滥用 |
一个真实的收益案例:某团队重构购物车组件,把状态从 useState 迁移到 useReducer。如果没有组件测试,这次改动要手工点一遍"加购 → 改数量 → 结算";有了 render(<Cart />) 加 userEvent 的测试,一次 npm test 就能确认所有交互没被改坏,重构前后心里都有底。
E2E 测试:Playwright 与 Cypress
- Playwright:微软出品,一套 API 覆盖 Chromium/Firefox/WebKit,自带自动等待(不用手写 sleep)、Trace Viewer(失败回放)、Codegen(录制脚本)与多浏览器并行;
- Cypress:开发者体验好,在真实浏览器里运行,支持时间旅行调试、网络请求 stub,并提供组件测试能力。
E2E 的典型形态:
test('user can complete checkout', async ({ page }) => {
await page.goto('/checkout')
await page.getByLabel('Email').fill('[email protected]')
await page.getByRole('button', { name: 'Pay' }).click()
await expect(page.getByText('Order confirmed')).toBeVisible()
})
Playwright 与 Cypress 怎么选?如果团队已有 Cypress 经验、偏好它的时间旅行调试,继续用即可;如果看重多浏览器覆盖、需要 Codegen 快速录制回归脚本,或希望测试代码与框架解耦,Playwright 通常更顺手。两者都能接入 CI,选择标准应当是"团队维护成本最低",而不是功能罗列。
Mock、快照与测试替身
vi.mock/jest.mock:mock 网络请求、定时器、第三方 SDK;- MSW(Mock Service Worker):在网络层拦截请求,返回真实结构的假数据,组件与 E2E 可共用;
- 快照测试:记录组件渲染结果防止意外变化,但不要过度使用——大快照脆弱且难读,优先断言关键内容。
Mock 的原则是"只替身、不造假":mock 掉的是与当前测试无关的外部依赖(网络、时间、第三方 SDK),而不是被测组件自身的逻辑。如果测试里 mock 得太多,等于在测一份"假实现",测了也白测。判断标准很简单——把某个依赖换成真实实现,测试是否依然成立、只是变慢;若是,则说明 mock 用得合理。
把测试接入 CI
- 单元与组件测试在每个 PR 运行:
vitest run(或npm test),失败即阻断合并; - E2E 在 PR 或合并前运行:Playwright 官方 CI 示例(GitHub Actions 内置安装浏览器),或 Cypress 的 CI 集成;
- 覆盖率只做趋势参考,别设"必须 100%"的硬指标——否则团队会为数字写无意义测试;
- 保持测试快速:把慢的 E2E 标记
@slow并行执行,让开发者愿意本地跑。
16IDC 观察
对独立开发者与小团队,建议从"先测纯逻辑,再测关键组件,最后补两条 E2E"开始,不必一步到位。测试的投入产出比在重构时最明显:有测试托底,升级 React/Vue 版本、调整组件结构都敢下手。配合前端搭建分类的组件规范,测试体系会随项目自然生长。
参考:https://vitest.dev/guide/、https://testing-library.com/docs/、https://playwright.dev/docs/intro