从零到一:给 Vue3 项目接入 Playwright UI 自动化测试的完整实战
前言
你是不是也经历过这样的场景:某个表单页面的提交按钮改了个样式,结果把 disabled 逻辑弄丢了;某个列表页加了筛选功能,结果分页组件悄悄坏了——而且都是测试同学甚至线上用户先发现的。
单元测试能覆盖组件逻辑,但用户是拿着浏览器操作的,不是拿着 Vitest 操作的。UI 自动化测试(E2E)的价值就在这里:它像真实用户一样打开页面、点击、输入、等待、断言,守住的是"整条链路"的正确性。
这篇文章记录我给一个 Vue3 + Vite 项目接入 Playwright 的完整过程,包含环境搭建、登录态处理、选择器策略、CI 集成以及踩过的坑,可以直接照抄落地。
一、为什么选 Playwright
在选型时对比过 Cypress 和 Playwright,最终选择 Playwright 的理由:
| 维度 | Playwright | Cypress |
|---|---|---|
| 多浏览器 | Chromium / Firefox / WebKit 全支持 | 主要基于 Chromium |
| 多标签页 / iframe | 原生支持 | 受架构限制,支持不完整 |
| 语言 | JS/TS、Python、Java、.NET | 仅 JS/TS |
| 并行执行 | 内置 workers,免配置 | 需要付费 Dashboard 才好用 |
| 自动等待 | 内置 auto-waiting | 内置 |
对于团队里同时存在前端(JS/TS)和工具链(Python)的场景,Playwright 的跨语言能力也是加分项。
二、环境搭建
项目背景:Vue 3 + TypeScript + Vite + Element Plus,包管理用 pnpm。
pnpm add -D @playwright/test
pnpm exec playwright install chromium
根目录新建 playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
retries: process.env.CI ? 2 : 0, // CI 上失败重试,本地不重试
workers: process.env.CI ? 1 : undefined, // CI 上串行更稳定
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry', // 首次重试失败时录 trace,排查神器
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
两个点值得展开:
webServer配置:测试启动前自动拉起pnpm dev,reuseExistingServer让本地调试时直接复用已开的 dev server,CI 上则强制新起一个。trace: 'on-first-retry':trace 是 Playwright 自带的"黑匣子",失败时可以回放每一步的 DOM 快照、网络请求。建议只在重试时开启,全量开启会让测试变慢。
三、第一个测试:登录页
登录页是最好的切入点,因为它几乎是所有后续测试的前置。
被测页面是一个典型的 Element Plus 登录表单:
<!-- src/views/login/Login.vue 简化版 -->
<template>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" data-testid="login-username" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" data-testid="login-password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" data-testid="login-submit">
登 录
</el-button>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" data-testid="login-error" />
</el-form>
</template>
对应的第一条用例:
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('登录页', () => {
test('正确的账号密码可以登录成功', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-username').fill('admin');
await page.getByTestId('login-password').fill('admin123');
await page.getByTestId('login-submit').click();
// 断言跳转到首页
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByTestId('user-dropdown')).toContainText('admin');
});
test('错误的密码展示错误提示', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-username').fill('admin');
await page.getByTestId('login-password').fill('wrong-password');
await page.getByTestId('login-submit').click();
await expect(page.getByTestId('login-error')).toBeVisible();
await expect(page.getByTestId('login-error')).toContainText('用户名或密码错误');
});
});
注意 await expect(page).toHaveURL(...)——所有 Playwright 的断言都是自动等待的,不需要手写 sleep,这是它和早期 Puppeteer 脚本最大的区别。
四、登录态复用:storageState
每个用例都跑一遍登录又慢又浪费。Playwright 支持把登录后的 localStorage / cookie 存成文件,后续用例直接复用:
// e2e/auth.setup.ts — 专门的登录 setup 项目
import { test as setup } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('登录并保存状态', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-username').fill('admin');
await page.getByTestId('login-password').fill('admin123');
await page.getByTestId('login-submit').click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: authFile });
});
在 config 里挂上:
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup'],
},
],
效果:setup 项目只跑一次登录,chromium 项目里的所有用例都带着已登录状态启动,登录只发生在开头一次。
五、选择器策略:为什么用 data-testid
这是 UI 自动化测试最容易腐烂的地方。三种常见写法对比:
// ❌ CSS 选择器:样式一改就挂
await page.click('.el-form .el-button--primary');
// ⚠️ 文本选择器:文案一改就挂,但读起来直观
await page.getByRole('button', { name: '登 录' });
// ✅ data-testid:与实现解耦,最稳定
await page.getByTestId('login-submit').click();
我的团队实践是三层策略:
- 优先
getByTestId:在业务组件上统一加data-testid,命名规则模块-元素(如asset-list-search-btn); - 语义化场景用
getByRole/getByText:比如断言错误提示文案,用文本反而更贴近验收标准; - 禁止裸 CSS 选择器进 CI:
.el-input__inner这种 Element Plus 内部类名,框架升级就批量炸。
对于 Element Plus 这类组件库,还可以通过配置全局注册 testid 属性,减少样板代码。
六、一个真实业务用例:资产台账列表
以我们项目里的资产列表页为例,测一个完整的交互链路:搜索 → 断言结果 → 翻页 → 断言数据更新。
// e2e/asset-list.spec.ts
import { test, expect } from '@playwright/test';
test.describe('资产台账列表', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/asset/list');
await expect(page.getByTestId('asset-table')).toBeVisible();
});
test('按资产名称搜索可以过滤列表', async ({ page }) => {
await page.getByTestId('asset-search-input').fill('摄像头');
await page.getByTestId('asset-search-btn').click();
// 断言表格每一行的名称列都包含关键字
const rows = page.getByTestId('asset-table').locator('tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(rows.nth(i).locator('td').first()).toContainText('摄像头');
}
});
test('翻页后列表数据更新', async ({ page }) => {
const firstPageFirstRow = await page
.getByTestId('asset-table').locator('tbody tr').first()
.locator('td').first().innerText();
await page.getByTestId('pagination-next').click();
const secondPageFirstRow = await page
.getByTestId('asset-table').locator('tbody tr').first()
.locator('td').first().innerText();
expect(firstPageFirstRow).not.toBe(secondPageFirstRow);
});
});
这里有个隐性知识点:getByTestId('asset-table') 的可见性断言在 beforeEach 里充当了"页面就绪"信号,不需要额外等待接口返回——因为表格可见本身就意味着数据渲染完了。
七、Mock 接口:测试不依赖后端环境
E2E 测试最大的痛点是环境依赖:后端服务挂了、测试数据被别人改了,测试就红了。Playwright 可以在网络层拦截:
test('列表加载失败时展示空状态', async ({ page }) => {
await page.route('**/api/v1/assets**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { list: [], total: 0 } }),
}),
);
await page.goto('/asset/list');
await expect(page.getByTestId('empty-state')).toBeVisible();
});
我们的策略是核心链路用真实后端(staging 环境),异常分支用 route mock。异常分支(网络错误、空数据、超长文本)恰恰是手工测试最容易漏、自动化测试最擅长的。
八、CI 集成
GitHub Actions 示例(Jenkins/GitLab CI 同理):
name: E2E
on:
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
失败时上传 HTML 报告和 trace 文件,PR 里直接下载回放,定位问题效率极高。
九、踩坑记录
- Element Plus 弹窗挂在 body 下:
el-dialog默认 teleport 到 body,在组件容器里找不到弹窗内容。解决:断言时直接从page级别查询,不要从父容器往下找。 - 输入过快触发不了校验:
fill()是瞬时赋值,某些依赖 input 事件的组件(如搜索联想)要改用pressSequentially('关键词', { delay: 100 })模拟逐字输入。 - CI 上比本地慢导致超时:CI 机器性能差,
timeout建议本地 15s、CI 30s 起步,并且永远不要用waitForTimeout硬等,用条件等待替代。 - 并发用例互踩数据:多个用例同时改同一条记录导致断言互相干扰。解决:每个用例构造独立数据(名称里拼随机后缀),或者按文件加锁串行。
toHaveURL匹配跳转:Vue Router 的跳转有时先到中间路由。waitForURL('**/dashboard')比连续多个断言更稳。
十、写在最后
UI 自动化测试不是银弹,它慢、脆、维护成本高——所以只测钱和口碑走的链路:登录、支付、核心 CRUD、权限边界。组件逻辑交给单元测试,接口契约交给接口测试,各司其职。
我们的落地节奏供参考:
- 第一周:搭框架 + 登录态复用 + 1 条冒烟用例;
- 第一个月:覆盖核心链路 15 条用例,接进 PR 流水线;
- 长期:每个线上事故反推一条用例,让测试集随事故成长。
测试代码也是代码,值得用写业务代码的认真程度去对待。
如果这篇文章对你有帮助,欢迎点赞收藏。评论区和掘友们交流一下你们的 E2E 实践~