从零到一:给 Vue3 项目接入 Playwright UI 自动化测试的完整实战

6 阅读7分钟

从零到一:给 Vue3 项目接入 Playwright UI 自动化测试的完整实战

前言

你是不是也经历过这样的场景:某个表单页面的提交按钮改了个样式,结果把 disabled 逻辑弄丢了;某个列表页加了筛选功能,结果分页组件悄悄坏了——而且都是测试同学甚至线上用户先发现的。

单元测试能覆盖组件逻辑,但用户是拿着浏览器操作的,不是拿着 Vitest 操作的。UI 自动化测试(E2E)的价值就在这里:它像真实用户一样打开页面、点击、输入、等待、断言,守住的是"整条链路"的正确性。

这篇文章记录我给一个 Vue3 + Vite 项目接入 Playwright 的完整过程,包含环境搭建、登录态处理、选择器策略、CI 集成以及踩过的坑,可以直接照抄落地。

一、为什么选 Playwright

在选型时对比过 Cypress 和 Playwright,最终选择 Playwright 的理由:

维度PlaywrightCypress
多浏览器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,
  },
});

两个点值得展开:

  1. webServer 配置:测试启动前自动拉起 pnpm devreuseExistingServer 让本地调试时直接复用已开的 dev server,CI 上则强制新起一个。
  2. 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();

我的团队实践是三层策略:

  1. 优先 getByTestId:在业务组件上统一加 data-testid,命名规则 模块-元素(如 asset-list-search-btn);
  2. 语义化场景用 getByRole / getByText:比如断言错误提示文案,用文本反而更贴近验收标准;
  3. 禁止裸 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 里直接下载回放,定位问题效率极高。

九、踩坑记录

  1. Element Plus 弹窗挂在 body 下el-dialog 默认 teleport 到 body,在组件容器里找不到弹窗内容。解决:断言时直接从 page 级别查询,不要从父容器往下找。
  2. 输入过快触发不了校验fill() 是瞬时赋值,某些依赖 input 事件的组件(如搜索联想)要改用 pressSequentially('关键词', { delay: 100 }) 模拟逐字输入。
  3. CI 上比本地慢导致超时:CI 机器性能差,timeout 建议本地 15s、CI 30s 起步,并且永远不要用 waitForTimeout 硬等,用条件等待替代。
  4. 并发用例互踩数据:多个用例同时改同一条记录导致断言互相干扰。解决:每个用例构造独立数据(名称里拼随机后缀),或者按文件加锁串行。
  5. toHaveURL 匹配跳转:Vue Router 的跳转有时先到中间路由。waitForURL('**/dashboard') 比连续多个断言更稳。

十、写在最后

UI 自动化测试不是银弹,它慢、脆、维护成本高——所以只测钱和口碑走的链路:登录、支付、核心 CRUD、权限边界。组件逻辑交给单元测试,接口契约交给接口测试,各司其职。

我们的落地节奏供参考:

  • 第一周:搭框架 + 登录态复用 + 1 条冒烟用例;
  • 第一个月:覆盖核心链路 15 条用例,接进 PR 流水线;
  • 长期:每个线上事故反推一条用例,让测试集随事故成长。

测试代码也是代码,值得用写业务代码的认真程度去对待。


如果这篇文章对你有帮助,欢迎点赞收藏。评论区和掘友们交流一下你们的 E2E 实践~