前端测试
概述
前端测试是保证 Web 应用质量和稳定性的关键环节。随着前端工程化的发展,测试工具链日趋成熟,从单元测试到端到端测试,从静态类型检查到视觉回归测试,形成了完整的质量保障体系。本文档系统性地介绍现代前端测试的核心工具与最佳实践。
Vitest
简介
Vitest 是 Vite 原生生态的单元测试框架,由 Vite 团队打造。它利用 Vite 的开发服务器和模块转换管道,实现了极快的测试执行速度和开箱即用的体验。Vitest 兼容 Jest API,能够作为 Jest 的直接替代品。
配置
Vitest 通过 vitest.config.ts 进行配置,与 Vite 共享配置:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
})断言与 Matchers
Vitest 内置了基于 Chai 的断言库,并提供 Jest 兼容的 expect API:
import { describe, it, expect } from 'vitest'
describe('数值计算', () => {
it('基本断言', () => {
expect(1 + 1).toBe(2)
expect({ a: 1 }).toEqual({ a: 1 })
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('hello').toContain('ell')
})
it('近似匹配', () => {
expect(0.1 + 0.2).toBeCloseTo(0.3)
})
it('异常断言', () => {
expect(() => JSON.parse('invalid')).toThrow()
})
})Vitest 特有的快照匹配和计时器模拟:
import { vi, it, expect } from 'vitest'
it('计时器模拟', () => {
vi.useFakeTimers()
const fn = vi.fn()
setTimeout(fn, 1000)
vi.advanceTimersByTime(1000)
expect(fn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})模拟(Mock)
Vitest 提供 vi 对象实现函数模拟和模块模拟:
import { vi } from 'vitest'
// 函数模拟
const mockFn = vi.fn()
mockFn('hello')
expect(mockFn).toHaveBeenCalledWith('hello')
// 模块模拟
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({ data: { id: 1 } }),
},
}))
// 部分模拟
const math = await import('../utils/math')
vi.spyOn(math, 'add').mockReturnValue(42)与 Jest 对比
| 特性 | Vitest | Jest |
|---|---|---|
| 执行速度 | 极快(基于 Vite HMR) | 较快 |
| 配置复杂度 | 低(与 Vite 共享配置) | 中等 |
| ESM 支持 | 原生支持 | 需额外配置 |
| 多线程 | 内置 | 内置 |
| Watch 模式 | 即时 | 较快 |
| 快照测试 | 支持 | 支持 |
| 代码覆盖率 | Istanbul / V8 | Istanbul |
| 生态兼容性 | 兼容 Jest API | 最广泛 |
| TypeScript 支持 | 原生 | 需 babel/ts-jest |
Jest
配置
Jest 通过 jest.config.js 或 package.json 中的 jest 字段配置:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterSetup: ['./src/setupTests.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
snapshotSerializers: ['enzyme-to-json/serializer'],
}快照测试
快照测试用于确保 UI 组件不会意外改变:
import renderer from 'react-test-renderer'
it('按钮组件快照', () => {
const tree = renderer.create(<Button label="提交" />).toJSON()
expect(tree).toMatchSnapshot()
})快照文件存储在 __snapshots__ 目录中,随代码一同入库。当 UI 有意变更时,通过 jest --updateSnapshot 更新快照。
覆盖率报告
Jest 内置 Istanbul 覆盖率收集,生成多种格式报告:
jest --coverage --coverageReporters=html --coverageReporters=lcov生成的覆盖率报告包含语句覆盖率(Statements)、分支覆盖率(Branches)、函数覆盖率(Functions)和行覆盖率(Lines)四个维度。
自定义环境
通过 @jest-environment 指令在测试文件中指定运行环境:
/**
* @jest-environment jsdom
*/也可以自定义测试环境:
const NodeEnvironment = require('jest-environment-node')
class CustomEnvironment extends NodeEnvironment {
async setup() {
await super.setup()
this.global.customProperty = 'custom value'
}
}
module.exports = CustomEnvironmentReact Testing Library
核心理念
React Testing Library(RTL)是 Kent C. Dodds 创建的测试工具库,核心哲学是"测试越接近用户使用方式,越能提供信心"。它不测试组件内部实现细节,而是测试用户可见的行为。
渲染与查询
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
test('登录表单提交', async () => {
const onSubmit = vi.fn()
render(<LoginForm onSubmit={onSubmit} />)
// 通过角色查询
await userEvent.type(screen.getByLabelText('用户名'), 'admin')
await userEvent.type(screen.getByLabelText('密码'), '123456')
// 通过文本查询
await userEvent.click(screen.getByRole('button', { name: '登录' }))
expect(onSubmit).toHaveBeenCalledWith({
username: 'admin',
password: '123456',
})
})查询优先级(按推荐顺序):getByRole > getByLabelText > getByPlaceholderText > getByText > getByDisplayValue > getByAltText > getByTitle > getByTestId。
用户事件
@testing-library/user-event 模拟真实的浏览器事件,比 fireEvent 更贴近用户操作:
import userEvent from '@testing-library/user-event'
test('用户交互', async () => {
const user = userEvent.setup()
render(<Select options={['A', 'B', 'C']} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('B'))
expect(screen.getByRole('combobox')).toHaveValue('B')
})异步测试
使用 waitFor 和 findBy 处理异步行为:
import { waitFor, screen } from '@testing-library/react'
test('异步数据加载', async () => {
render(<UserProfile userId={1} />)
expect(screen.getByText('加载中...')).toBeInTheDocument()
const name = await screen.findByText('张三')
expect(name).toBeInTheDocument()
})最佳实践
- 优先使用
getByRole查询,它最接近无障碍标准 - 使用
screen对象而非解构返回值,便于添加查询时无需修改导入 - 避免测试实现细节(组件内部 state、私有方法等)
- 使用
describe组织相关的测试用例 - 使用
beforeEach清理测试状态 - 自定义
render函数包装 Provider 上下文
Playwright
跨浏览器 E2E 测试
Playwright 是微软开发的现代化端到端测试框架,支持 Chromium、Firefox 和 WebKit 三大浏览器引擎,确保应用在所有主流浏览器中表现一致。
import { test, expect } from '@playwright/test'
test('用户登录流程', async ({ page }) => {
await page.goto('https://example.com/login')
await page.fill('[data-testid="username"]', 'admin')
await page.fill('[data-testid="password"]', 'password')
await page.click('button:has-text("登录")')
await expect(page.locator('.welcome')).toHaveText('欢迎回来,admin')
})自动等待
Playwright 的所有操作都内置自动等待机制,无需显式添加 sleep 或 wait:
// Playwright 自动等待元素可见后再操作
await page.click('.submit-btn')
// 自动等待导航完成
await page.waitForURL('**/dashboard')
// 自动等待网络请求完成
await expect(page.locator('.data-table')).toBeVisible()跟踪与截图
Playwright 提供强大的调试和诊断功能:
test('截图对比', async ({ page }) => {
await page.goto('https://example.com')
// 整页截图
await page.screenshot({ path: 'screenshots/homepage.png', fullPage: true })
// 元素截图
await page.locator('.hero-section').screenshot({ path: 'screenshots/hero.png' })
})
test('视觉回归', async ({ page }) => {
await page.goto('https://example.com')
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
})
})Playwright 的 Trace Viewer 可以记录完整的测试执行轨迹,包括 DOM 快照、网络请求、控制台日志和时间线,方便排查失败原因。
组件测试
Playwright 也支持组件级别的测试,无需单独启动 Storybook:
import { test, expect } from '@playwright/experimental-ct-react'
test('计数器组件', async ({ mount }) => {
const component = await mount(<Counter initial={0} />)
await component.locator('button.increment').click()
await expect(component.locator('.value')).toHaveText('1')
})Cypress
组件测试
Cypress 的组件测试运行器与开发服务器集成,提供所见即所得的测试体验:
import { mount } from 'cypress/react'
import Button from './Button'
describe('按钮组件', () => {
it('点击触发事件', () => {
const onClick = cy.stub()
mount(<Button onClick={onClick}>提交</Button>)
cy.contains('提交').click()
cy.wrap(onClick).should('have.been.calledOnce')
})
})E2E 测试
describe('购物车流程', () => {
it('添加商品到购物车', () => {
cy.visit('/products')
cy.get('[data-cy="product-card"]').first().click()
cy.get('[data-cy="add-to-cart"]').click()
cy.get('[data-cy="cart-count"]').should('have.text', '1')
cy.get('[data-cy="checkout"]').click()
cy.url().should('include', '/checkout')
})
})时间旅行调试
Cypress 最显著的特性是时间旅行——每一步操作都会生成 DOM 快照,调试时可以回放每一步的状态,查看每个命令执行时的应用状态、网络请求和断言结果。配合 cy.pause() 可以在测试执行过程中手动步进。
与 Playwright 对比
| 特性 | Cypress | Playwright |
|---|---|---|
| 浏览器支持 | Chromium 为主,Firefox/Edge 有限 | Chromium、Firefox、WebKit |
| 多标签页 | 有限支持 | 原生支持 |
| iframe 支持 | 有限 | 原生支持 |
| 网络拦截 | 内置 | 内置(更强大) |
| 时间旅行 | 原生支持 | 通过 Trace Viewer |
| 截图/录屏 | 支持 | 支持 |
| 移动端模拟 | 有限 | 原生支持(设备模拟) |
| 并行执行 | Dashboard 付费 | 内置 |
| 调试体验 | 时间旅行 + 交互调试 | Trace Viewer |
| 语言支持 | JavaScript/TypeScript | JavaScript/TypeScript/Python/Java/.NET |
测试金字塔
三层策略
测试金字塔是 Mike Cohn 提出的测试分层策略,现代前端实践将其调整为三层:
第一层:单元测试(占比约 70%)
测试独立的函数、工具方法、纯函数、Hooks 和小型组件。使用 Vitest 或 Jest 执行,追求快速的反馈循环。单元测试应当覆盖核心业务逻辑和数据转换,不依赖外部服务。
// 测试工具函数
test('formatDate 格式化日期', () => {
expect(formatDate(new Date('2026-07-17'))).toBe('2026-07-17')
})
// 测试自定义 Hook
test('useCounter 递增', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})第二层:集成测试(占比约 20%)
测试组件之间的交互、路由导航、状态管理和 API 请求。使用 React Testing Library 或 Vue Testing Library,模拟后端接口但不模拟组件内部实现。
test('用户创建流程', async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<UserCreatePage />
</MemoryRouter>
</QueryClientProvider>
)
await userEvent.type(screen.getByLabelText('姓名'), '李四')
await userEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(screen.getByText('用户创建成功')).toBeInTheDocument()
})
})第三层:E2E 测试(占比约 10%)
模拟真实用户场景,覆盖关键用户旅程。使用 Playwright 或 Cypress,运行在真实浏览器环境中。E2E 测试应当关注核心业务流程(登录、注册、支付等),而不是所有功能点。
各层权衡
| 维度 | 单元测试 | 集成测试 | E2E 测试 |
|---|---|---|---|
| 执行速度 | 毫秒级 | 秒级 | 分钟级 |
| 维护成本 | 低 | 中 | 高 |
| 故障定位 | 精确 | 较精确 | 模糊 |
| 覆盖率价值 | 代码逻辑 | 组件交互 | 用户流程 |
| 运行频率 | 每次提交 | 每次提交 | 每日/预发布 |
CI 集成与覆盖率门禁
GitHub Actions 集成
name: Test Suite
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test -- --coverage
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx playwright install
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/覆盖率门禁配置
覆盖率门禁确保代码质量不倒退:
Vitest 覆盖率门禁:
// vitest.config.ts
coverage: {
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
perFile: true, // 每个文件独立检查
},
}SonarQube 质量门禁:
在 sonar-project.properties 中配置:
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.qualitygate.wait=true合并请求检查
在 CI 中配置合并请求检查,包含以下维度:
- 单元测试全部通过
- 覆盖率不低于目标阈值
- 新增代码覆盖率不低于 90%
- 无测试快照冲突
- E2E 关键路径通过
覆盖率报告聚合
对于 monorepo 项目,使用 nyc 或 istanbul 合并多个包的覆盖率报告:
npx nyc merge coverage/packages/ coverage/merged/
npx nyc report --reporter=html --reporter=lcov可视化覆盖率看板
以下嵌入的 Demo 展示了一个实时的测试覆盖率可视化看板,包含各模块覆盖率详情、趋势图表和通过/失败测试统计: