1. 写在前面:第57天为什么要聊测试
学习Vue走到第57天,说实话是个挺微妙的节点。语法、组件通信、路由、状态管理这些“硬技能”基本都过了一遍,项目也能吭哧吭哧写出来了。但你要是停下来问自己一句:“我写的这玩意儿,真的没问题吗?”——大概率会愣住。页面能跑、交互能用,可一旦加了新功能,老功能莫名其妙挂了,你压根不知道是哪次改动惹的祸。这时候就该碰测试了。
这个阶段进入单元测试和端到端测试,不是让学习曲线变得更陡,而是让前面的积累真正变得“可交付”。单元测试负责盯住每一个组件、每一个函数的小逻辑,端到端测试则模拟真实用户在浏览器里的完整操作路径,从登录到点击、从跳转到提交,全链路跑一遍。两者搭配起来,你就有了两道安全网:改代码的时候,跑一遍测试就知道有没有把东西弄坏。
这篇内容面向两种人:一种是和我一样按计划推进Vue学习的朋友,另一种是已经在写Vue项目但一直没敢碰测试的前端开发。我会把单元测试和端到端测试从环境搭建、框架选型到踩坑实录完整过一遍,最后附上我实际使用中总结的排错手册。文章里所有操作命令和代码我都实测过,你可以直接照着敲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试该怎么设计:先搞懂单元测试和端到端测试的分工
2.1 为什么“先测什么”比“怎么测”更重要
很多初学者上手测试,第一反应是“我要把代码覆盖率干到100%”。这个想法不能说错,但会让你陷得很深。覆盖率是结果,不是目的。测试的真正的价值是保护你已有的功能不被未来的改动破坏。所以第一步要做的不是写测试,而是想清楚:我的项目里,哪些东西坏了代价最高?
拿一个典型的中后台Vue项目举例。登录流程、权限路由、列表页的搜索筛选、表单提交,这些是核心链路。用户天天走,任何一个环节崩了,都直接影响使用。这类功能必须优先覆盖端到端测试。而独立业务组件,比如状态标签、金额格式化、日期范围选择器、按钮组的显隐逻辑,这类适合用单元测试锁定行为。至于一些纯工具函数,像防抖、深拷贝、字符串脱敏,单元测试就是给它们量身定做的。
这就是测试金字塔在真实项目里的落法:底层大量单元测试跑得快、成本低,顶层少量端到端测试保住核心链路。两者不是二选一,而是配合使用。我见过不少团队只写端到端测试,结果跑一次全量回归要半个多小时,开发烦、运维累;也有团队只写单元测试,页面级联调一改就炸。都不健康。
2.2 工具选型的背后逻辑
Vue 3生态下,单元测试基本就是Vitest配@vue/test-utils。Vitest基于Vite,启动速度比Jest快一个量级,配置也简单,原生支持ESM和TypeScript,和Vue SFC的配合几乎零成本。Jest当然也能用,但那个配置折腾劲儿,尤其是处理别名、样式、图片资源的时候,实在不值得。
端到端测试我在Cypress和Playwright之间犹豫过一段时间,最后选了Playwright。原因有几个:第一,Playwright跑测试的速度快,并行能力好;第二,它的自动等待机制写得很好,不像Cypress那样需要写一堆cy.wait();第三,Playwright可以直接写一个Python脚本来管理浏览器,也可以完全用Node那一套,跟Vue项目融在一起不别扭。当然,Cypress的交互式调试体验依然是非常优秀的,如果你团队里有人习惯用Cypress的调试器,用Cypress也是合理选择。对于个人学习和中小项目,我建议走Vitest + Playwright这套组合,省心。
3. 单元测试从零到一:环境搭建与第一个组件测试
3.1 五步搭好单元测试环境
这一步没什么魔法,纯操作,跟着做就行。前提是你已经用Vite初始化好了一个Vue 3项目。
bash复制npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8
这里补一句,为什么需要jsdom。Vitest默认跑在Node环境里,没有DOM。而组件测试要挂载、渲染、触发事件,没有DOM寸步难行。jsdom就是给你模拟一套DOM环境。当然还有happy-dom这种轻量替代,但实测下来jsdom的兼容性更好一些,尤其是遇到老组件库的时候。
接着在package.json里加脚本:
json复制{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage"
}
}
然后配置vite.config.js,加上测试相关的配置:
javascript复制import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js']
}
})
上面有个setupFiles字段,需要建一个src/test/setup.js。这个文件可以放全局的mock,比如matchMedia、ResizeObserver这些浏览器API在jsdom里没有,需要手动补。不然等你写一个有El-select或者抽屉组件的测试时,会报一堆奇怪的错。
这时候你运行npm test,会提示没有测试文件。没关系,下面直接写第一个测试。
3.2 写第一个组件测试:一个计数器的完整验证
我挑一个最简单的Counter组件。它有一个按钮,点击一次数字加一。
vue复制<template>
<div>
<p class="count">{{ count }}</p>
<button @click="increment">加一</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value++
}
</script>
对应测试文件可以这么写:
javascript复制import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../src/components/Counter.vue'
describe('Counter.vue', () => {
it('初始渲染时显示0', () => {
const wrapper = mount(Counter)
expect(wrapper.find('.count').text()).toBe('0')
})
it('点击按钮后数字加一', async () => {
const wrapper = mount(Counter)
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})
})
两个细节值得注意。第一,找元素优先用类名或者data-testid,不要用标签选择器,越具体越不容易误伤。第二,trigger后面要加await,因为Vue的DOM更新是异步的,不等一下拿到的还是旧值。这个小坑,几乎每个刚开始写Vue测试的人都会踩一次。
3.3 组件测试的常见模式
从上面的例子往后扩展,组件测试基本就围绕几个固定套路转。
props传参测试。组件接收外部数据,你要验证不同参数下的渲染结果。比如一个显示用户等级的标签,传level=1显示“初级”,传level=2显示“中级”。
javascript复制const wrapper = mount(UserLevel, {
props: { level: 1 }
})
expect(wrapper.text()).toContain('初级')
事件派发测试。子组件通过emit通知父组件,你要验证emit是否带上了正确的载荷。比如一个搜索框,回车时派发search事件:
javascript复制await wrapper.find('input').trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')[0]).toEqual(['关键字'])
插槽测试。验证默认插槽和具名插槽的内容是否正确渲染:
javascript复制const wrapper = mount(MyCard, {
slots: {
default: '<span>卡片内容</span>',
footer: '<button>操作</button>'
}
})
expect(wrapper.find('.card-body').text()).toContain('卡片内容')
这些模式看起来简单,真正难的是处理异步和依赖。接下来我展开讲。
3.4 把异步、状态管理和路由mock干净
真实项目里,组件不可能都这么纯。最常见的情况是组件里调了接口、用了Pinia、跳了路由。不处理的话,测试一碰这些地方就崩。
先说接口请求。如果你的组件在onMounted里调了axios,测试环境没有后端,请求必然是404。处理方法是在setup文件里把网络请求整体mock掉。拿axios举例:
javascript复制// src/test/setup.js
import { vi } from 'vitest'
vi.mock('axios', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: [] })),
post: vi.fn(() => Promise.resolve({ data: {} }))
}
}))
更细一点,你可以在每个测试里调整mock的返回值。比如模拟拉取用户列表成功返回两条数据,断言页面渲染出两个列表项:
javascript复制import axios from 'axios'
axios.get.mockResolvedValue({
data: [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ]
})
再往下走,使用Pinia的状态。我一般用createTestingPinia来装。这个包由pinia官方提供,直接安装:
bash复制npm install -D @pinia/testing
然后在一个用store的组件测试里,创建一个带指定初始状态的store:
javascript复制import { createTestingPinia } from '@pinia/testing'
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
initialState: {
user: {
name: '测试用户',
role: 'admin'
}
}
})
]
}
})
路由的mock跟上面两个比起来就简单多了。最常见的方式是给组件传一个mock的router对象:
javascript复制const $router = {
push: vi.fn(),
replace: vi.fn()
}
mount(NavBar, {
global: {
mocks: {
$router
}
}
})
如果组件里用了useRoute,就直接用一个对象去占位:
javascript复制vi.mock('vue-router', () => ({
useRoute: () => ({
query: { page: '1' }
}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
currentRoute: { value: { path: '/' } }
})
}))
3.5 覆盖率报告怎么看
配置好之后,跑一下npm run test:coverage,Vitest会生成一份覆盖率报告,里面会明确告诉我:哪些文件测了,哪些没测,每个文件的行覆盖率、函数覆盖率、分支覆盖率分别是多少。
很多人看到第一次跑出的覆盖率很兴奋,觉得数字高就是好。实际看的时候,我更关注三个点:第一,核心业务组件是否每行都被执行到;第二,分支覆盖率低的地方是不是真的存在条件逻辑,如果有if-else,有没有两条分支都覆盖;第三,有没有打了nocovert注释跳过的内容,如果太多,就要警惕这是在逃避测不动的代码。
4. 端到端测试:从环境准备到完整用户流程
4.1 安装并初始化Playwright
端到端测试的核心思路就是:让浏览器像真实用户一样去操作你的应用。我们这里用Playwright,理由前面提过,不再重复。安装很简单:
bash复制npm init playwright@latest
安装向导会让你选择测试目录,我一般用e2e,和单元测试的test目录分开,避免混在一起。接下来安装浏览器内核:
bash复制npx playwright install
这一步会下载Chromium等浏览器内核,体积比较大,需要耐心等一下。如果你的开发机网络受限,可以只装chromium这一个内核:
bash复制npx playwright install chromium
安装好后,先写一个最简单的冒烟测试,确认整套链路是通的:
javascript复制import { test, expect } from '@playwright/test'
test('项目首页能正常打开', async ({ page }) => {
await page.goto('http://localhost:5173')
await expect(page.locator('h1')).toContainText('Vue App')
})
注意,运行前要先把开发服务器跑起来。Playwright本身不会帮你启动Vite服务,需要在playwright.config.js里把webServer配置好,让它在跑测试前自动启动、测试后自动停掉:
javascript复制// playwright.config.js
export default {
testDir: './e2e',
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000
},
use: {
baseURL: 'http://localhost:5173',
headless: true,
screenshot: 'only-on-failure'
}
}
这里reuseExistingServer这个配置很有用。本地开发时如果你自己已经启动了dev server,Playwright会直接复用,不会重复起一个端口。CI环境则强制每次都新建。
4.2 端到端测试的典型流程:登录、搜索、操作、断言
接下来写一个更贴近真实业务场景的端到端测试。假设项目是一个带登录的后台管理系统。一个完整的流程测试是:打开登录页、输入账号密码、点击登录、打开某个列表页、搜索关键字、点击详情、断言关键内容出现。
javascript复制import { test, expect } from '@playwright/test'
test('用户登录后能完成搜索和详情浏览', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('用户名').fill('admin')
await page.getByLabel('密码').fill('123456')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL(/\/dashboard/)
await page.goto('/orders')
await page.getByPlaceholder('搜索订单号').fill('2025001')
await page.getByRole('button', { name: '查询' }).click()
await expect(page.locator('.order-row')).toHaveCount(1)
await page.locator('.order-row').first().click()
await expect(page.locator('.order-detail')).toContainText('2025001')
})
这里用到的getByLabel、getByRole、getByPlaceholder,是Playwright推荐的选择器写法,语义化程度高,比写CSS选择器稳定得多。还有一个好习惯是:给测试里需要定位的元素加data-testid属性,这样即使样式结构调整,测试也不会挂。
4.3 网络请求的拦截与模拟
端到端测试里最让人头疼的就是外部依赖。联调环境不稳定、测试数据被改、第三方接口超时,都会让测试忽绿忽红。我常用的策略是:用page.route接口拦截关键的网络请求,返回固定的mock数据。
javascript复制test('列表页加载后展示mock数据', async ({ page }) => {
await page.route('**/api/orders', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
list: [{ id: 1, orderNo: '测试单号', status: '已完成' }]
})
})
})
await page.goto('/orders')
await expect(page.locator('.order-row')).toContainText('测试单号')
})
这么做的理由是让测试专注验证前端的交互逻辑,而不是依赖后端返回具体数据。当然,端到端测试也不应该全是mock,偶尔也要跑几条真实链路的用例,当作对后端接口的冒烟验证。我的比例大约是七成mock、三成真实。
4.4 稳定性优化:等待策略和登录态复用
端到端测试跑久了你就会发现,稳定性比覆盖率更稀缺。一摇就挂的测试,最终会被所有人无视。我总结了三个提升稳定性的习惯。
第一,坚持用Playwright的自动等待,不要手动写固定sleep。page.click、expect会自动等待元素出现、可点击,你只需要在断言里写清楚期望就行。比如上面的断言await expect(page).toHaveURL(//dashboard/),Playwright会一直轮询直到URL匹配,超时才失败。这比写await page.waitForTimeout(3000)可靠得多。
第二,每次测试独立登录代价太高,用storageState保存登录态。做法是先用脚本登一次,把cookies和localStorage存下来:
javascript复制// 保存登录状态
await page.context().storageState({ path: 'e2e/.auth/user.json' })
然后在playwright.config.js里给需要登录的测试项目统一注入这个状态:
javascript复制use: {
storageState: 'e2e/.auth/user.json'
}
这样每个测试跑起来的时候,浏览器已经带着登录态了,不需要每次都走一遍登录流程。
第三,遇到弹窗、iframe、多标签页这类场景,先暴露一个问题:iframe里的内容没法直接通过普通选择器访问,需要切换frame。Playwright对iframe的支持比较友好,直接用frameLocator就行:
javascript复制const frame = page.frameLocator('#iframe-container')
await frame.getByRole('button', { name: '确认' }).click()
5. 两个高频踩坑场景:控制台报错和异步更新的玄学
5.1 控制台明明报错了,测试却还是绿的
这个问题非常迷惑人。页面加载的时候,浏览器控制台打了一堆红色报错,比如“Unhandled Rejection”“Failed to fetch”,但测试居然一路绿灯。原因在于:测试断言的是DOM内容,控制台console.error不会让测试失败。Playwright默认不会把页面错误当成测试失败处理。
解决方式很简单,在测试里加一段监听逻辑:
javascript复制test('页面没有严重报错', async ({ page }) => {
const errors = []
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
page.on('pageerror', error => {
errors.push(error.message)
})
await page.goto('/dashboard')
expect(errors).toEqual([])
})
这段代码我几乎每个项目都会留一份,作为兜底冒烟用例,能提前发现很多隐藏问题。
5.2 DOM异步更新:猜不透的“旧值新值”
测试里最经典的一个怪现象:点击按钮后,明明界面已经变了,用find取到的文本却还是旧值。这个我一开始也中招过好多次。原因就像前面说的,Vue的DOM更新是异步的,等待一次微任务循环是必须的。你可以用nextTick,也可以直接用await trigger后得到的Promise。
实操建议是:触发交互后,只要下一步要依赖新的DOM状态,就无条件await。这不是一个“技巧”,而是一个习惯。养成这个习惯之后,可以少写几百行无意义的调试代码。
另外组件内部如果用了setTimeout、requestAnimationFrame这类定时器,测试要注意用vi.useFakeTimers()来模拟时间推进:
javascript复制vi.useFakeTimers()
// 组件里 setTimeout(() => {...}, 1000)
await vi.advanceTimersByTime(1000)
6. 实用速查表:常见问题定位与修复
我整理了一张我平时排查测试问题时用的速查表,基本覆盖了新手到中级开发最容易遇到的场景。
| 场景 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 组件测试找不到元素 | find返回的wrapper为空 | 组件内部显示被v-if控制,还没渲染 | 检查初始props或store状态,或用v-show |
| 组件测试取文本多一个空格 | text()返回的是“1 ”,断言却是“1” | 模板中有换行和缩进 | 用.trim()去掉首尾空格 |
| mounted里调接口报404 | 测试报错,Network 404 | 没有mock网络请求 | 在setup文件里vi.mock掉请求库 |
| trigger点击后DOM没变 | DOM内容还是旧值 | 忘记await,或点击后还有异步逻辑 | 添加await;用flushPromises处理 |
| 组件用useRouter报错 | 单测报Cannot read properties of undefined | 没有mock vue-router | 用vi.mock或global.mocks注入 |
| E2E选择器不稳定 | 测试挂在某一步元素定位失败 | 样式类名变化,或元素在滚动区域外 | 改用语义化选择器getByRole,必要时滚动到元素 |
| E2E偶发超时 | 同一套用例今天绿明天红 | 依赖真实后端数据或网络延迟 | 用page.route拦截mock;不要依赖sleep |
| E2E在CI环境跑不起来 | 提示浏览器启动失败或依赖缺失 | 服务器上没有安装浏览器内核或系统库 | 执行npx playwright install --with-deps |
| 组件用了El-Select等组件报错 | 找不到ResizeObserver等API | jsdom缺少浏览器原生API | 在setup文件里手动实现并挂到window上 |
这张表的存在不是让你背下来,而是当你测试飘红的时候,能快速对号入座,节省排查时间。第9行那个ResizeObserver问题尤其普遍,用Element Plus这类组件库做测试,几乎都会撞上。
javascript复制// src/test/setup.js
global.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
7. 从单测到E2E的串联:一次真实的回归复盘
最后用我实际经历过的一次回归排查来收尾,这比任何理论都更能说明测试这套组合拳的威力。
当时我给一个Vue项目加了一个“导出订单”功能。页面改动不大,就是在列表页头部加了一个按钮,点击后调用接口生成文件。改动完我下意识跑了一遍单测,列表页组件的用例全绿。接着跑E2E,结果第二秒就挂在了一个很久没动过的用例上:登录后跳转Dashboard,页面一直等不到目标元素。
打开截图像素级排查,发现是侧边栏菜单的“订单管理”入口因为按钮新增导致布局高度变化,菜单项被折叠到了次级菜单里。原来的E2E用例是直接点一级菜单的文案,现在文案已经躲到二级去了,自然定位失败。
这个问题的可怕之处在于:单元测试覆盖的组件层面完全感知不到布局变化,而普通的E2E用例又只关心点击后结果。如果没有E2E把“登录→跳转→点击左侧菜单”这一整条链路锁住,这个回归可能要等真实用户反馈才会暴露,那代价就大了。
所以我现在带项目、带团队,始终强调一件事:单元测试是单兵作战,E2E是联合作战。两者都有存在价值,但真正要优先保住的,是E2E锁定的那几条核心用户路径。单测可以慢慢补覆盖率,E2E的核心用例必须上线前跑一遍。
这套“Vitest + @vue/test-utils + Playwright”的组合,我从第57天开始用,到现在已经成了我接手任何Vue项目的默认标配。如果你也正卡在“测试到底该不该写、该从哪写起”这个念头里,不要犹豫,先搭环境,跑通一个用例,再谈其他。测试最基础的回报,就是让你在深夜临时改完需求后,还能睡个踏实觉。
