前端测试
🚀 第一步:掌握核心概念
- 单元测试: 验证纯逻辑、工具函数、Hooks/Composables。不依赖 DOM。
- 组件测试: 验证组件的渲染输出、交互行为、Props/Events。模拟部分依赖。
- E2E 测试: 在真实浏览器中验证完整用户流程。最重要但最慢。
- 视觉回归测试: 自动对比 UI 截图,防止样式意外变更。
💡 2026 年黄金法则
优先编写 E2E 和组件测试,它们比单纯的单元测试更能发现真实问题。遵循 "Testing Trophy" 而非传统的测试金字塔。
🛠️ 第二步:选择主流框架(按优先级排序)
1. 单元测试 & 组件测试
| 框架 | 适用场景 | 2026 年状态 | 推荐理由 |
|---|---|---|---|
| Vitest | Vite 项目 (Vue/React/Svelte) | 行业标准 | 原生兼容 Vite,极速 HMR,API 兼容 Jest,配置几乎为零 |
| Jest | Webpack/Legacy 项目 | 维护模式 | 生态最大,但配置复杂,新项目不再推荐 |
| React Testing Library | React 组件测试 | 必学 | 强制你像用户一样测试(查文本/角色),而非测实现细节 |
| Vue Test Utils | Vue 组件测试 | Vue 官方 | 配合 Vitest 使用,支持挂载、插槽、全局插件模拟 |
2. E2E 测试
| 框架 | 特点 | 推荐理由 |
|---|---|---|
| Playwright | 多浏览器、自动等待、Trace Viewer | 2026 年首选。调试体验碾压级,支持移动端模拟、网络拦截、视觉对比 |
| Cypress | 时间旅行调试、丰富插件 | 社区庞大,上手简单,但在性能和跨标签页场景弱于 Playwright |
3. 视觉回归 & 辅助测试
- Chromatic / Percy: CI 中自动截图对比,集成 Storybook
- axe-core / @axe-core/playwright: 自动化无障碍检测,确保符合 WCAG 标准
📚 第三步:实践
⚙️ 1. 快速搭建环境
# 核心依赖 npm install -D vitest @vue/test-utils happy-dom # 可选但强烈推荐 npm install -D @testing-library/vue msw # 用户中心测试 + API Mock
vitest.config.ts 基础配置
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom', // 或 'jsdom'
globals: true, // 启用 describe/it/expect 全局API
setupFiles: ['./src/test/setup.ts'], // 全局setup(如注册全局组件)
coverage: {
provider: 'v8', // 推荐使用 v8 而非 istanbul
reporter: ['text', 'html'],
},
},
})
package.json 基础配置
{ "name": "my-vue3-project", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite","test": "vitest", "test:run": "vitest run", //单元测试 "test:coverage": "vitest run --coverage", "e2e": "playwright test", //e2e测试 "e2e:ui": "playwright test --ui", "e2e:view": "playwright test --headed" },"devDependencies": {"@vitejs/plugin-vue": "^5.2.1", "@vitest/coverage-v8": "^3.2.7", "@vue/test-utils": "^2.5.0", "fast-glob": "^3.3.3", "jsdom": "^29.1.1", "happy-dom": "^20.12.2", "less": "^4.3.0", "less-loader": "^12.3.0", "postcss-pxtorem": "^6.1.0", "typescript": "^5.3.3", "vite": "^6.2.0", "vite-plugin-svg-icons": "^2.0.1", "vitest": "^3.2.7" }, }
💻 2. 实战示例
单元测试
使用框架:Vitest
说明:Vue + Vitest 是目前 Vue 生态测试的官方推荐组合。Vitest 由 Vite 团队打造,与 Vue 项目无缝集成,无需额外转换配置,且原生支持 ESM、TypeScript 和 JSX/TSX。
待测单元函数件说明:auth.ts文件
const exactPathnames: string[] = [ '/sysuser/login/pwdAuth', '/sysuser/login/refresh/token', '/sysuser/login/getPubKey', '/sysuser/login/pageConfInfo', '/sysuser/login/dmpAuth', '/sysuser/user/getUserProfile', '/sysuser/menu/getRouters' ] // 简易通配:以星号结尾表示前缀匹配 const prefixPatterns: string[] = [ '/public/', '/sysuser/login/' ] export const isAuthFree = (url: string | undefined): boolean => { if (!url) return false try { // 只基于路径匹配,忽略查询 const pathname = url.split('?')[0] if (exactPathnames.includes(pathname)) return true for (const prefix of prefixPatterns) { if (pathname.startsWith(prefix)) return true } return false } catch { return false } } export default { isAuthFree }
单元测试文件:authEndpoints.spec.ts(以spec.ts结尾,执行测试命令,会自动执行)
记住一句话:单元测试是验证函数的输入输出,你就会写测试用例了!
import { isAuthFree } from './authEndpoints';
describe('isAuthFree', () => {
it('为空时,返回false', () => {
expect(isAuthFree('')).toBe(false);
});
it('未定义时,返回false', () => {
expect(isAuthFree(undefined)).toBe(false);
});
it('精确白名单中的用户资料接口视为免鉴权', () => {
expect(isAuthFree('/sysuser/user/getUserProfile')).toBe(true);
});
it('登录路径前缀下的其它接口视为免鉴权', () => {
expect(isAuthFree('/sysuser/login/validCodeAuth')).toBe(true);
});
it('/public/ 前缀视为免鉴权', () => {
expect(isAuthFree('/public/captcha.png')).toBe(true);
});
it('忽略查询串,只按路径判断', () => {
expect(isAuthFree('/sysuser/user/getUserProfile?redirect=/home')).toBe(
true,
);
});
it('普通业务接口需要鉴权', () => {
expect(isAuthFree('/sysuser/device/list')).toBe(false);
});
});
组件测试 :
使用框架:Vitest+@vue/test-utils+happy-dom
说明:验证组件的渲染输出、交互行为、Props/Events。模拟部分依赖。
待测单元组件件说明:SdtDeviceKeywordInput(批量查询抽屉组件,点击输入框,抽屉弹出,输入确定,带回输入框)
SdtDeviceKeywordInput/index.vue
<template> <div class="sdt-device-keyword-input" :style="wrapStyle"> <el-input ref="singleInputRef" v-model="innerValue" :placeholder="triggerPlaceholder" :clearable="true" :disabled="disabled" class="sdt-device-keyword-input__trigger" @click="openDrawer" @clear="onClear" > <template #suffix> <el-icon class="sdt-device-keyword-input__search-icon"> <Search /> </el-icon> </template> </el-input> <el-drawer v-model="drawerVisible" :title="t('common.sdtDeviceKeywordDialogTitle')" :size="drawerSize" direction="rtl" append-to-body destroy-on-close class="sdt-device-keyword-drawer" @closed="onDrawerClosed" > <div class="drawer-resize-handle" :class="{ 'is-dragging': dragging }" @mousedown.prevent="onResizeStart" /> <div class="sdt-device-keyword-drawer__body"> <div class="sdt-device-keyword-drawer__mode"> <span class="sdt-device-keyword-drawer__mode-label"> {{ t('common.sdtDeviceKeywordModeLabel') }} </span> <el-radio-group v-model="draftMode" class="sdt-device-keyword-drawer__mode-radio"> <el-radio v-for="item in modeOptions" :key="item.value" :value="item.value" > {{ t(item.labelKey) }} </el-radio> </el-radio-group> </div> <div class="sdt-device-keyword-drawer__textarea-wrap"> <el-input ref="drawerTextareaRef" v-model="draftValue" type="textarea" :placeholder="drawerPlaceholder" :rows="drawerRows" :maxlength="maxlength" :clearable="clearable" resize="none" @keydown.enter.ctrl="onDrawerConfirm" /> </div> <p class="sdt-device-keyword-drawer__hint">{{ tipText }}</p> <p class="sdt-device-keyword-drawer__count">{{ countText }}</p> </div> <template #footer> <el-button @click="onDrawerCancel">{{ t('common.cancel') }}</el-button> <el-button type="primary" @click="onDrawerConfirm">{{ t('common.confirm') }}</el-button> </template> </el-drawer> </div> </template> <script setup lang="ts"> import { ref, computed, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' import { Search } from '@element-plus/icons-vue' import { validateSdtDeviceKeyword, parseSdtDeviceKeywords, getSdtDeviceKeywordMax, SDT_DEVICE_KEYWORD_MODE_DEFAULT, type SdtDeviceKeywordMode } from '@/utils/sdtDeviceKeyword' import { pxToRem } from '@/utils/remAdapter' import { useResizableDrawer } from '@/composables/useResizableDrawer' const props = withDefaults( defineProps<{ modelValue?: string | null mode?: SdtDeviceKeywordMode width?: number | string placeholder?: string clearable?: boolean disabled?: boolean drawerRows?: number maxlength?: number }>(), { modelValue: '', mode: SDT_DEVICE_KEYWORD_MODE_DEFAULT, width: 240, clearable: true, disabled: false, drawerRows: 14, maxlength: 20000 } ) const emit = defineEmits<{ 'update:modelValue': [value: string] 'update:mode': [mode: SdtDeviceKeywordMode] enter: [] clear: [] }>() const { t } = useI18n() const { drawerSize, dragging, onResizeStart } = useResizableDrawer({ defaultSize: '480px', minWidth: 400, direction: 'rtl' }) const innerValue = ref(props.modelValue ?? '') const innerMode = ref<SdtDeviceKeywordMode>(props.mode) const draftValue = ref('') const draftMode = ref<SdtDeviceKeywordMode>(SDT_DEVICE_KEYWORD_MODE_DEFAULT) const modeOptions: { value: SdtDeviceKeywordMode; labelKey: string }[] = [ { value: 'fuzzy', labelKey: 'common.sdtDeviceKeywordModeFuzzy' }, { value: 'exact', labelKey: 'common.sdtDeviceKeywordModeExact' } ] const drawerVisible = ref(false) const singleInputRef = ref() const drawerTextareaRef = ref() watch( () => props.modelValue, (v) => { const next = v == null ? '' : String(v) if (next !== innerValue.value) { innerValue.value = next } }, { immediate: true } ) watch( () => props.mode, (v) => { const next = v ?? SDT_DEVICE_KEYWORD_MODE_DEFAULT if (next !== innerMode.value) { innerMode.value = next } }, { immediate: true } ) const triggerPlaceholder = computed( () => props.placeholder ?? t('common.sdtDeviceKeywordPh') ) const drawerPlaceholder = computed(() => t('common.sdtDeviceKeywordDrawerPh')) const currentMax = computed(() => getSdtDeviceKeywordMax(draftMode.value)) const keywordCount = computed(() => parseSdtDeviceKeywords(draftValue.value).length) const tipText = computed(() => { const base = t('common.sdtDeviceKeywordTip') const maxTip = t('common.sdtDeviceKeywordModeMaxTip', { max: currentMax.value }) return `${base}\n${maxTip}` }) const countText = computed(() => t('common.sdtDeviceKeywordCount', { count: keywordCount.value, max: currentMax.value }) ) const wrapStyle = computed(() => { const w = props.width if (w === undefined || w === null || w === '') { return { boxSizing: 'border-box' as const } } const widthVal = typeof w === 'number' ? pxToRem(w) : String(w) return { width: widthVal, maxWidth: widthVal, boxSizing: 'border-box' as const } }) function openDrawer (): void { if (props.disabled) return draftValue.value = innerValue.value draftMode.value = innerMode.value drawerVisible.value = true nextTick(() => { const el = drawerTextareaRef.value as { focus?: () => void } | undefined el?.focus?.() }) } function onDrawerClosed (): void { draftValue.value = innerValue.value draftMode.value = innerMode.value } function onDrawerCancel (): void { drawerVisible.value = false } function onDrawerConfirm (): void { const result = validateSdtDeviceKeyword(draftValue.value, draftMode.value) if (!result.valid) { ElMessage.warning( t('common.sdtDeviceKeywordMax', { max: result.max, count: result.count }) ) return } const next = draftValue.value.trim() innerValue.value = next innerMode.value = draftMode.value emit('update:modelValue', next) emit('update:mode', draftMode.value) drawerVisible.value = false emit('enter') } function onClear (): void { innerValue.value = '' draftValue.value = '' emit('update:modelValue', '') emit('clear') } defineExpose({ focus: () => openDrawer() }) </script>

测试用例文件:SdtDeviceKeywordInput/index.spec.ts
测试用例编写思路:
1、验证页面是否渲染正确:通过组件行为定义必须组件是否存在,比如输入框、确定按钮
2、组件配置属性有没有生效:props对组件行为的影响,比如disable模式,input输入框class里面有没有el-input--disable
3、交互行文是否正确:比如点击是否有没有出现弹窗
import { mount, type VueWrapper } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import ElementPlus, { ElMessage } from 'element-plus'
import SdtDeviceKeywordInput from './index.vue'
let wrapper: VueWrapper | undefined
function mountInput (props?: {
modelValue?: string | null
disabled?: boolean
}): VueWrapper {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': {
common: {
cancel: '取消',
confirm: '确定',
sdtDeviceKeywordPh: '多个BT/SN/DK,逗号分隔',
sdtDeviceKeywordDrawerPh: '每行一个关键字',
sdtDeviceKeywordTip: '可输入设备 BT、SN 或 DK',
sdtDeviceKeywordCount: '{count}/{max}',
sdtDeviceKeywordDialogTitle: '批量查询',
sdtDeviceKeywordMax: '批量查询最多支持 {max} 个关键字,当前 {count} 个',
sdtDeviceKeywordModeLabel: '匹配模式',
sdtDeviceKeywordModeFuzzy: '模糊查询',
sdtDeviceKeywordModeExact: '精准查询',
sdtDeviceKeywordModeMaxTip: '当前模式建议不超过 {max} 个关键字',
},
},
},
})
wrapper = mount(SdtDeviceKeywordInput, {
props: {
modelValue: '',
...props,
},
global: {
plugins: [ElementPlus, i18n],
stubs: {
ElDrawer: {
props: ['modelValue'],
template: `
<div v-if="modelValue">
<slot />
<slot name="footer" />
</div>
`,
},
ElIcon: true,
Search: true,
},
},
})
return wrapper
}
function buttonByText (root: VueWrapper, text: string) {
const found = root.findAll('button').find((btn) => btn.text().trim() === text)
if (!found) {
throw new Error(`未找到按钮「${text}」`)
}
return found
}
function isDrawerOpen (root: VueWrapper): boolean {
return root.findAll('button').some((btn) => btn.text().trim() === '确定')
}
async function openDrawer (root: VueWrapper): Promise<void> {
await root.get('.sdt-device-keyword-input__trigger .el-input__inner').trigger('click')
}
describe('SdtDeviceKeywordInput', () => {
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
vi.restoreAllMocks()
document.body.innerHTML = ''
})
it('禁用时点击输入框不会打开批量查询抽屉', async () => {
const root = mountInput({ disabled: true })
await openDrawer(root)
expect(isDrawerOpen(root)).toBe(false)
})
it('确认合法关键字后会回写并通知父组件', async () => {
const root = mountInput()
await openDrawer(root)
expect(isDrawerOpen(root)).toBe(true)
await root.get('textarea').setValue('SN001, SN002')
await buttonByText(root, '确定').trigger('click')
expect(root.emitted('update:modelValue')?.[0]).toEqual(['SN001, SN002'])
expect(root.emitted('update:mode')?.[0]).toEqual(['fuzzy'])
expect(root.emitted('enter')).toHaveLength(1)
expect(isDrawerOpen(root)).toBe(false)
})
it('切换精准查询后确认会回写 mode', async () => {
const root = mountInput()
await openDrawer(root)
const exactRadio = root.findAll('.el-radio').find((el) => el.text().includes('精准查询'))
if (!exactRadio) {
throw new Error('未找到精准查询选项')
}
await exactRadio.get('input[type="radio"]').setValue(true)
await root.get('textarea').setValue('SN001')
await buttonByText(root, '确定').trigger('click')
expect(root.emitted('update:mode')?.[0]).toEqual(['exact'])
})
it('取消编辑不会改掉已确认的关键字', async () => {
const root = mountInput({ modelValue: 'SN001' })
await openDrawer(root)
await root.get('textarea').setValue('SHOULD-NOT-SAVE')
await buttonByText(root, '取消').trigger('click')
expect(root.emitted('update:modelValue')).toBeUndefined()
expect(isDrawerOpen(root)).toBe(false)
})
it('清空输入会通知父组件', async () => {
const root = mountInput({ modelValue: 'SN001' })
await root.get('.sdt-device-keyword-input__trigger .el-input__inner').setValue('SN001')
await root.get('.el-input__clear').trigger('click')
expect(root.emitted('update:modelValue')?.[0]).toEqual([''])
expect(root.emitted('clear')).toHaveLength(1)
})
it('超过模糊查询上限时提示且不提交', async () => {
const warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({
close: () => {},
}) as ReturnType<typeof ElMessage.warning>)
const root = mountInput()
await openDrawer(root)
const overLimit = Array.from({ length: 501 }, (_, i) => `k${i}`).join(',')
await root.get('textarea').setValue(overLimit)
await buttonByText(root, '确定').trigger('click')
expect(root.emitted('update:modelValue')).toBeUndefined()
expect(isDrawerOpen(root)).toBe(true)
expect(warnSpy).toHaveBeenCalledWith('批量查询最多支持 500 个关键字,当前 501 个')
})
})
🎯 E2E 测试
定义:E2E(End-to-End)测试,即“端到端测试”,是模拟真实用户在真实浏览器中操作你的应用,验证完整业务流程是否正确的测试方式。
核心区别:E2E vs 组件测试:
| 维度 | 组件测试 (Vitest) | E2E 测试 (Playwright) |
|---|---|---|
| 运行环境 | Node.js + 模拟 DOM (happy-dom) | 真实浏览器 (Chrome/Firefox/Safari) |
| 测试范围 | 单个组件的渲染和交互 | 跨页面、跨组件的完整用户流程 |
| 后端依赖 | 全部 Mock | 连接真实/测试后端 API |
| 速度 | ⚡ 毫秒级 | 🐢 秒级(启动浏览器+网络请求) |
| 发现什么问题 | 逻辑错误、渲染异常 | 路由跳转失败、API 集成错误、登录态丢失、多标签页问题 |
| 核心价值 | 开发时快速反馈 | 上线前最后一道防线 |
💡 一句话理解
组件测试验证“零件好不好用”,E2E 测试验证“整辆车能不能从 A 开到 B”。
E2E 测试用例怎么写(以 Playwright + Vue 为例)
安装与初始化
npm init playwright@latest
# 选择 TypeScript,安装示例测试,生成 GitHub Actions 配置
一个完整的 E2E 测试用例
登录成功跳转到首页,登录失败弹窗提示错误
import { test, expect } from '@playwright/test'
import { openLoginPage, submitAccountLogin } from './helpers/login'
const LOGIN_USER = 'xxx'
const LOGIN_PASSWORD = 'xxxx'
const WRONG_PASSWORD = 'xxxx'
test.describe('登录页', () => {
test('打开后能看到账号登录表单', async ({ page }) => {
await page.goto('/login')
await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 30_000 })
await expect(page.getByPlaceholder('请输入密码')).toBeVisible()
await expect(page.getByRole('button', { name: '登录' })).toBeVisible()
})
test('账号密码正确时登录成功并进入首页', async ({ page }) => {
test.setTimeout(60_000)
await openLoginPage(page)
await submitAccountLogin(page, LOGIN_USER, LOGIN_PASSWORD)
await expect(page.locator('.el-message--success')).toContainText('登录成功', { timeout: 30_000 })
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 })
await expect(page.locator('.layout-container')).toBeVisible()
})
test('密码错误时登录失败并留在登录页', async ({ page }) => {
test.setTimeout(60_000)
await openLoginPage(page)
await submitAccountLogin(page, LOGIN_USER, WRONG_PASSWORD)
await expect(page.locator('.el-message--error')).toBeVisible({ timeout: 30_000 })
await expect(page).toHaveURL(/\/login/)
await expect(page.getByPlaceholder('请输入用户名')).toBeVisible()
})
})
E2E 测试用例设计清单:
📚 第四步:把它加入到ci流程里面
加入 CI 流程是 E2E 测试发挥真正价值的“分水岭”。如果只停留在本地跑,E2E 只能发挥 20% 的功力;接入 CI,才能发挥 100% 的价值
我们可以把 E2E 测试比作“一辆高性能汽车”,本地跑就像在自家院子里开,而加入 CI 则是把它开上了高速公路。
将 E2E 接入 CI(如 GitHub Actions, GitLab CI, Jenkins 等),本质上是把“人的自觉”变成了“机器的强制”。
| 维度 | 只在本地跑 | 加入 CI 流程(核心质变) |
|---|---|---|
| 执行触发 | 靠开发者自觉、手动执行 | 强制触发:每次提交 PR/MR 自动运行 |
| 质量管控 | 自己保证自己,容易放水 | 硬性门禁:E2E 不飘绿,代码根本无法合并 (Merge) |
| 运行环境 | 开发者各自的电脑(不可控) | 标准化容器(如 Docker),环境 100% 纯净一致 |
| 问题发现时机 | 开发完、甚至发版前 | 代码审查阶段 (PR),把 Bug 扼杀在摇篮里 |
| 结果沉淀 | 跑完就没了,失败了只能自己看 | 自动归档:每次运行生成 HTML 报告、Trace 录像、截图,全员可见 |
1、核心价值:质量左移
在 CI 中跑 E2E 最大的意义是在代码合并到主干之前(PR 阶段)就拦截住破坏核心流程的代码。在 PR 阶段修复一个 Bug 的成本,只有上线后修复成本的 1/10 甚至 1/100。
2、具体实现:
核心做法是:分层并行、独立门禁、按需触发。如果把单元测试、组件测试和 E2E 测试揉成一个任务串行执行,你会亲手毁掉 E2E 的价值——因为反馈太慢,开发者会开始绕过它
分层并行 + 质量门禁:
PR 提交 / 代码合并 │ ├──► Job A: 单元测试 (Unit) ──┐ │ ⚡ 1-3 分钟 │ 并行执行 ├──► Job B: 组件测试 (Component) ──┤ 互不阻塞 │ ⚡ 2-5 分钟 │ │ ▼ │ ✅ 快速反馈门禁 │ (A/B 任一失败则阻断) │ └──► Job C: E2E 测试 (Playwright) ──► 🔴 核心流程门禁 🐢 5-15 分钟 (C 失败则阻断合并)
3、智能触发:不是每次都要全量跑
paths-filter 或 Monorepo 工具(如 Nx, Turborepo)实现。🎯 一句话总结
单元测试保“逻辑对”,组件测试保“界面好”,E2E 保“流程通”。三者在 CI 中并行跑、独立报、按需用,才是兼顾速度与质量的正确姿势。

浙公网安备 33010602011771号