Skip to content

测试

前端测试最难的从来不是写断言,而是决定测什么、在哪一层测、以及怎么让测试不变成维护负担。本文按"先策略、后工具"的顺序展开:金字塔与分层 → Vitest 单元测试 → 组件测试 → E2E → mock 边界 → 覆盖率与 CI。示例基于 Vitest 3(2025-01 发布,Vite 6+ 时代的默认单测工具)+ Playwright(当前主线),可复制进 npm create vite@latest + vitest 模板直接运行。

一、测试策略:先分层,再写码

1.1 测试金字塔(前端版)

覆盖面速度成本主要工具
单元测试纯函数、状态逻辑、utils极快(ms)Vitest
组件/集成测试组件行为、store、表单流VTU / Testing Library
E2E关键用户路径(注册、下单)慢(s~min)Playwright
视觉回归样式不漂移Playwright 截图对比

金字塔的隐喻:底层多、顶层少。E2E 只覆盖"收益大于成本"的少数关键路径;把业务规则尽量下沉为可单测的纯逻辑(如把"折扣计算""权限判断"从组件里抽成函数),比在 E2E 里点一万次更划算。

1.2 三类被测对象的取舍

  • 实现细节不测:内部变量名、私有函数、CSS 类名——测了只会让重构寸步难行;
  • 行为/契约优先:输入→输出、用户可感知的交互结果;
  • 先写会坏的东西:被测代码未来会改且改了不能发现问题的,才值得写测试(防回归),一次性脚本不用测。

二、Vitest 单元测试

ts
// utils/price.ts
export function calcDiscount(price: number, code?: string) {
  if (code === 'SAVE20') return { price: round2(price * 0.8), off: round2(price * 0.2) }
  return { price, off: 0 }
}
const round2 = (n: number) => Math.round(n * 100) / 100
ts
// utils/price.test.ts
import { describe, it, expect } from 'vitest'
import { calcDiscount } from './price'

describe('calcDiscount', () => {
  it('无券时不优惠', () => {
    expect(calcDiscount(100)).toEqual({ price: 100, off: 0 })
  })
  it('SAVE20 打八折并返回立减额', () => {
    expect(calcDiscount(100, 'SAVE20')).toEqual({ price: 80, off: 20 })
  })
  it('金额做两位小数', () => {
    expect(calcDiscount(99.99, 'SAVE20').price).toBe(79.99)  // 80.00? 见实现:round2(79.992)=79.99
  })
})
sh
npx vitest run            # CI 一次性跑
npx vitest                # watch 模式(开发)
npx vitest --coverage     # 覆盖率(v8 provider)
ts
// vitest.config.ts —— 环境:纯逻辑用 node;涉及 DOM 用 jsdom/happy-dom
import { defineConfig } from 'vitest/config'
export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts?(x)'],
    coverage: { provider: 'v8', thresholds: { lines: 80, functions: 80 } },
  },
})

三、组件测试:测行为,不测实现

3.1 查询哲学(Testing Library 系)

组件测试应该像"用户使用"那样找元素:优先角色/可见文本,而不是类名与结构

tsx
// Counter.test.tsx(React + @testing-library/react + user-event)
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

test('点击按钮计数增加且可见', async () => {
  render(<Counter />)
  const btn = screen.getByRole('button', { name: '增加' })  // 可访问性角色,而非 .btn
  await userEvent.click(btn)
  expect(screen.getByText('1 次')).toBeInTheDocument()
})

Vue 侧两种风格:

ts
// ① @vue/test-utils:wrapper 驱动
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
it('点击 +1', async () => {
  const wrapper = mount(Counter)
  await wrapper.get('button.inc').trigger('click')
  expect(wrapper.text()).toContain('1')
})

// ② @testing-library/vue:行为优先(推荐,与 React 风格统一)
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Counter from './Counter.vue'
it('点击 +1', async () => {
  render(Counter)
  await userEvent.click(screen.getByRole('button', { name: '+' }))
  expect(screen.getByText(/已点击 1 次/)).toBeTruthy()
})

坑 1:快照测试(toMatchSnapshot)只适合"稳定且低价值"的整块 UI,小改动就红一片、reviewer 肌肉记忆地 -u——拿快照当回归保障是大坑;能断言的用行为断言,快照宁缺毋滥。

3.2 组件测试的异步与网络

ts
// 组件内 fetch 数据 → 用 MSW(Mock Service Worker)在网络层拦截,最接近真实
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { render, screen } from '@testing-library/vue'
import UserCard from './UserCard.vue'

const server = setupServer(
  http.get('/api/user/1', () => HttpResponse.json({ name: 'Ada' })),
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it('加载后显示用户名', async () => {
  render(UserCard, { props: { id: 1 } })
  expect(await screen.findByText('Ada')).toBeTruthy()   // findBy* 自动等待异步
})

四、Playwright E2E:测真实关键路径

ts
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test'

test('游客可完成下单', async ({ page }) => {
  await page.goto('/')
  await page.getByRole('link', { name: '加入购物车' }).first().click()
  await page.getByRole('button', { name: '去结算' }).click()

  // web-first 断言:自动重试直到出现,代替手写 sleep
  await expect(page.getByText('订单提交成功')).toBeVisible({ timeout: 10_000 })

  // 可截图留存(trace 失败时自动留痕)
  await page.screenshot({ path: 'test-results/order-ok.png' })
})
ts
// playwright.config.ts(要点)
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
  testDir: './tests',
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },   // 移动视口路径
  ],
  use: { trace: 'on-first-retry', baseURL: 'http://localhost:4173' },
  webServer: { command: 'npm run preview', port: 4173, reuseExistingServer: true },
})

要点:

  • 自动等待内建(actionability),别用 sleep;重试断言用 expect(...).toBeX({ timeout })
  • 数据准备与隔离:E2E 用独立测试库/账号,用 test.beforeEach 造数据,断言后别依赖清理顺序
  • npx playwright codegen 录制探索;失败自动截 trace,本地 npx playwright show-trace 回放。

五、mock 的边界:能少则少

该 mock不该 mock
网络层(MSW)——外部服务不可控自己团队刚写的纯函数(应该真测)
浏览器能力(matchMediaIntersectionObserver被测组件自己的内部逻辑(不然测了个寂寞)
第三方 SDK/鉴权(真实环境有副作用)数据结构本身(mock 数据要贴近真实形状,用 zod schema 校验)

坑 2:mock 粒度错了,测试会"绿得虚伪"——组件测试里把内部 store 全 mock 掉,最终只验证了模板拼接。规则:mock 一切"跨进程边界"的东西,测一切"自己进程内"的逻辑

六、覆盖率与 CI 门禁

sh
npx vitest run --coverage && npx playwright test
  • 覆盖率是发现盲区的地图,不是目标本身(100% 覆盖也可能全是废话测试);
  • CI 里两条门禁分开:单测(秒级、卡阈值)+ E2E(分钟级、卡关键路径);
  • 慢测试标注与切分:--shard=1/4 把 E2E 分片并行;
  • 配合 git pre-push / PR 检查:覆盖率下降、关键 spec 失败即拦截。

七、常见坑速查

  1. 测试依赖执行顺序/共享状态:用例间泄漏(module 级 let、没清理的 server)——beforeEach 重置,describe 内隔离;
  2. 异步没等待findBy*/waitFor 不用的轮询、await 忘加 → 偶发"绿了又红";
  3. E2E 用真实数据/真实支付:环境串联炸一片——独立账号/沙箱/testId 数据标记;
  4. 快照滥用(§3.1 坑 1);
  5. mock 掉一切(§5 坑 2);
  6. 把时间花在重复断言上:同一逻辑在单测、组件测、E2E 各写一遍——在最有价值的一层写一份,其他层做轻验证;
  7. 环境不一致:本地 node 环境、CI 用 --reporter/容器有差异(字体、viewport、时区)——E2E 设固定 viewport/timezoneId;
  8. 测试变"例程"没人看结果:红灯没人修 = 没有测试文化——把失败塞进通知与 PR 检查。

状态与参考

下一步

  • [ ] 用一个 Vite + Vue 工程落地"纯逻辑单测 + 组件行为测试(MSW)+ Playwright 关键路径"三层示例并跑通 CI
  • [ ] 补一篇"测试金字塔拆解真实业务(列表页/表单/权限)"的分层决策笔记
  • [ ] 调研视觉回归与 storybook 联动方案,评估团队落地成本

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀