Vue 基础体系 · 第 51/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 端到端测试:Playwright、登录态、网络、并行和失败证据

端到端测试(End-to-End Test,简称 E2E)从真实浏览器的入口开始,经过 Vue 应用、HTTP 请求、后端服务和浏览器状态,验证用户最终能否完成一个业务目标。例如:

用户打开订单页,登录后创建订单,页面显示订单号。

这类测试与组件测试不同。组件测试通常隔离 Vue 组件和依赖;E2E 测试则验证多个系统边界之间的连接是否成立。因此,E2E 测试不仅要断言 DOM,还要处理:

  • 浏览器如何启动以及如何访问 Vite 应用;
  • 登录凭证如何建立和复用;
  • 网络请求何时发生、是否需要等待或模拟;
  • 多个测试并行执行时如何避免相互污染;
  • 失败后如何保存能定位问题的证据。

本文示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。Playwright 的配置字段、浏览器版本和部分报告行为会随 Playwright 版本变化;示例使用当前主流配置方式,实际项目应以所安装版本的类型检查和官方文档为准。


一、先明确 E2E 测试验证的对象

一个浏览器端业务流程可以抽象为:

用户操作Vue 状态变化HTTP 请求服务端状态变化响应页面渲染\text{用户操作} \rightarrow \text{Vue 状态变化} \rightarrow \text{HTTP 请求} \rightarrow \text{服务端状态变化} \rightarrow \text{响应} \rightarrow \text{页面渲染}

例如点击“提交订单”:

  1. 用户点击按钮;
  2. Vue 事件处理函数读取表单状态;
  3. 前端发出 POST /api/orders
  4. 后端校验库存并创建订单;
  5. 后端返回订单对象;
  6. Vue 更新响应式状态;
  7. 页面显示“创建成功”和订单号。

E2E 测试的价值在于,它可以验证这条链路的多个条件同时成立:

  • 路由能够访问;
  • 登录状态被后端接受;
  • 表单绑定没有错误;
  • 请求方法、路径和数据正确;
  • 后端返回结果符合前端预期;
  • 成功或错误状态正确渲染。

但这也意味着 E2E 测试的失败来源更多。一个断言失败可能来自 Vue 代码,也可能来自:

  • 开发服务器没有启动;
  • API 服务不可用;
  • 测试账号失效;
  • 测试数据被另一个并行 worker 修改;
  • 页面一直等待一个永远不会返回的请求;
  • 测试没有保存截图、网络日志或 Trace,导致无法还原现场。

Playwright 是浏览器自动化与测试框架。它提供浏览器启动、页面操作、断言、网络控制、上下文隔离、并行执行和失败录制等能力。它不是 Vue 专用工具,因此测试代码应尽量从用户可见行为出发,而不是依赖 Vue 内部组件实例。


二、在 Vue + Vite 项目中接入 Playwright

2.1 安装和初始化

在已有 Vue + TypeScript + Vite 项目中安装:

npm install -D @playwright/test
npx playwright install

@playwright/test 包含测试运行器、断言 API 和浏览器控制能力。npx playwright install 会安装 Playwright 需要的浏览器二进制文件。CI 环境也必须执行浏览器安装,否则可能出现:

Executable doesn't exist at ...

如果项目使用锁文件,CI 中通常应使用:

npm ci
npx playwright install --with-deps

--with-deps 会尝试安装 Linux 系统依赖,适合由项目控制系统软件包的 CI 镜像;在不允许 apt 或没有 root 权限的环境中,应该改用预装浏览器依赖的镜像。

可以先创建目录:

tests/
  e2e/
    home.spec.ts
playwright.config.ts

2.2 配置 Vite 开发服务器

playwright.config.ts

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',

  // 每个测试文件失败时保留,避免无限增长
  outputDir: 'test-results',

  // CI 中通常希望测试文件可以并行执行
  fullyParallel: true,

  // 本地失败时立即停止;CI 可改成更高值以收集更多失败
  forbidOnly: !!process.env.CI,

  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,

  reporter: process.env.CI
    ? [['dot'], ['html', { open: 'never' }]]
    : [['list'], ['html', { open: 'never' }]],

  use: {
    baseURL: 'http://127.0.0.1:4173',

    // 只有失败时保存截图
    screenshot: 'only-on-failure',

    // 第一次重试时收集 Trace
    trace: 'on-first-retry',

    // 失败测试保留视频
    video: 'retain-on-failure',
  },

  webServer: {
    command: 'npm run build && npm run preview -- --host 127.0.0.1',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
})

对应的 package.json 脚本可以是:

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:report": "playwright show-report"
  }
}

这里使用 vite preview 而不是 vite dev,有一个重要含义:测试的是构建后的产物,而不是开发服务器的热更新环境。这样更接近部署后的静态资源行为,但仍然需要单独启动后端 API。

如果项目只需要测试开发服务器,也可以写成:

webServer: {
  command: 'npm run dev -- --host 127.0.0.1',
  url: 'http://127.0.0.1:5173',
  reuseExistingServer: !process.env.CI,
}

两者的验证重点不同:

  • vite dev:更适合本地开发和快速反馈;
  • vite preview:验证生产构建生成的前端资源;
  • 无论哪一种,都不能自动替代后端服务。

baseURL 使测试可以使用相对路径:

await page.goto('/')

它等价于访问:

http://127.0.0.1:4173/

如果没有配置 baseURL,则必须在每个测试中写完整 URL,容易造成环境切换困难。


三、页面对象、定位器和自动等待

3.1 Locator 是延迟定位,而不是一次性查询

Playwright 的 Locator 表示“之后如何找到元素”。例如:

const submitButton = page.getByRole('button', { name: '提交订单' })

这行代码通常不会立即查询 DOM。真正执行点击时,Playwright 才会寻找元素,并等待它满足可操作条件,例如:

  • 元素存在;
  • 元素可见;
  • 元素未被其他元素遮挡;
  • 元素可操作;
  • 元素在等待期间没有变成另一个不匹配节点。

因此,下面的写法比固定延时可靠:

await page.getByRole('button', { name: '提交订单' }).click()

而下面的写法通常是不可靠的:

await page.waitForTimeout(1000)
await page.locator('.submit-button').click()

固定等待只表达“我猜一秒后应该完成”,并没有表达业务条件。一秒可能不够,也可能让测试在每次运行中无意义地变慢。

3.2 定位优先选择用户可感知语义

推荐顺序通常是:

page.getByRole('button', { name: '提交' })
page.getByLabel('邮箱')
page.getByPlaceholder('请输入邮箱')
page.getByText('创建成功')
page.getByTestId('order-number')

Vue 模板可以这样写:

<template>
  <form @submit.prevent="submit">
    <label for="email">邮箱</label>
    <input id="email" v-model="email" type="email" />

    <label for="password">密码</label>
    <input id="password" v-model="password" type="password" />

    <button type="submit">登录</button>

    <p v-if="errorMessage" role="alert">
      {{ errorMessage }}
    </p>
  </form>
</template>

测试可以直接按可访问语义定位:

await page.getByLabel('邮箱').fill('e2e@example.com')
await page.getByLabel('密码').fill('correct-password')
await page.getByRole('button', { name: '登录' }).click()

await expect(page).toHaveURL(/\/dashboard/)

如果页面存在稳定但不适合展示给用户的技术节点,可以显式设置测试标识:

<span data-testid="order-number">{{ order.id }}</span>
await expect(page.getByTestId('order-number')).toHaveText(/ORD-/)

不应把 Vue 的 CSS 实现细节作为主要契约:

// 容易因样式重构而失效
await page.locator('.mt-4.text-blue-600 button').click()

测试仍然可以使用 CSS 选择器,但它更适合没有语义属性的基础设施元素,而不是业务主流程的全部定位依据。


四、登录态:从登录动作到可复用浏览器状态

4.1 登录态到底是什么

浏览器登录后的状态通常由以下一种或多种信息组成:

  • Cookie;
  • localStorage 中的令牌;
  • 服务器端 Session 对应的 Cookie;
  • IndexedDB 等浏览器存储;
  • 页面内存中的临时状态。

Playwright 的 storageState 主要保存可复用的浏览器存储状态,常见内容包括:

  • Cookies;
  • localStorage

它不等同于“把整个浏览器复制下来”,通常不会包含:

  • sessionStorage
  • 页面 JavaScript 内存;
  • 已打开的 DOM;
  • 任意后端数据库状态。

因此,如果应用把登录令牌放在 sessionStorage,直接使用 storageState 不能自动复用登录态,需要在新页面创建后显式注入,或者调整测试环境的认证方案。

4.2 方案一:每个测试独立登录

最直接的写法:

import { test, expect } from '@playwright/test'

test('登录后显示首页', async ({ page }) => {
  await page.goto('/login')

  await page.getByLabel('邮箱').fill('e2e@example.com')
  await page.getByLabel('密码').fill('correct-password')
  await page.getByRole('button', { name: '登录' }).click()

  await expect(page).toHaveURL(/\/dashboard/)
  await expect(page.getByRole('heading', { name: '控制台' })).toBeVisible()
})

这个方案的状态变化是:

未认证页面
  -> 填写凭证
  -> POST /api/login
  -> 服务端返回 Cookie 或 Token
  -> 浏览器保存状态
  -> 跳转到 /dashboard

优点是每个测试都验证了登录流程,失败时上下文完整。缺点是大量测试会重复登录,运行时间更长,而且认证服务容易成为瓶颈。

4.3 方案二:一次登录,多次复用 storageState

更常见的做法是建立一个专门的 setup project。

目录:

tests/
  auth.setup.ts
  e2e/
    dashboard.spec.ts
playwright.config.ts
playwright/.auth/

tests/auth.setup.ts

import { test as setup, expect } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'

const authFile = path.resolve('playwright/.auth/user.json')

setup('authenticate', async ({ page }) => {
  await page.goto('/login')

  await page.getByLabel('邮箱').fill(
    process.env.E2E_EMAIL ?? 'e2e@example.com',
  )
  await page.getByLabel('密码').fill(
    process.env.E2E_PASSWORD ?? 'correct-password',
  )
  await page.getByRole('button', { name: '登录' }).click()

  await expect(page).toHaveURL(/\/dashboard/)

  fs.mkdirSync(path.dirname(authFile), { recursive: true })
  await page.context().storageState({ path: authFile })
})

配置项目依赖:

import path from 'node:path'
import { defineConfig, devices } from '@playwright/test'

const authFile = path.resolve('playwright/.auth/user.json')

export default defineConfig({
  testDir: './tests/e2e',

  webServer: {
    command: 'npm run dev -- --host 127.0.0.1',
    url: 'http://127.0.0.1:5173',
    reuseExistingServer: !process.env.CI,
  },

  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: authFile,
      },
      dependencies: ['setup'],
    },
  ],
})

业务测试:

import { test, expect } from '@playwright/test'

test('登录用户可以打开控制台', async ({ page }) => {
  await page.goto('/dashboard')

  await expect(page.getByRole('heading', { name: '控制台' })).toBeVisible()
})

执行流程是:

setup project
  -> 创建浏览器上下文
  -> 打开登录页
  -> 登录
  -> 保存 playwright/.auth/user.json

chromium project
  -> 读取 user.json
  -> 为测试创建新的 context
  -> 直接访问需要认证的页面

storageState 复用的是“认证凭证”,不是复用同一个页面。每个测试仍然拥有独立的 Browser Context。这样可以避免一个测试关闭页面、修改 Cookie 或清空存储后影响另一个测试。

4.4 登录态文件的安全边界

认证状态文件通常可以还原登录身份,应当视为敏感文件:

playwright/.auth/
test-results/
playwright-report/

不应:

  • 将真实生产账号的状态文件提交到 Git;
  • 在日志中打印 Cookie 或 Token;
  • 在公共构建产物中上传未保护的认证文件;
  • 让多个无关测试共享一个会修改服务端数据的账号。

如果测试过程中会修改用户资料、删除资源或创建订单,单纯复用一个账号不等于测试隔离。浏览器存储隔离只能解决客户端状态污染,不能自动隔离后端数据库记录。


五、网络:等待真实请求、模拟响应和验证请求

端到端测试中的“网络”至少有三种不同目的:

  1. 等待真实请求完成;
  2. 验证前端发出了正确请求;
  3. 模拟不稳定或难以构造的后端响应。

这三者不能混为一谈。

5.1 等待真实网络响应

假设页面点击“刷新订单”后发送:

GET /api/orders

不要先点击,再盲目等待:

await page.getByRole('button', { name: '刷新订单' }).click()
await page.waitForTimeout(1000)

可以在触发动作之前建立响应等待:

import { test, expect } from '@playwright/test'

test('刷新后显示订单', async ({ page }) => {
  await page.goto('/orders')

  const responsePromise = page.waitForResponse((response) => {
    return (
      response.request().method() === 'GET' &&
      response.url().endsWith('/api/orders') &&
      response.status() === 200
    )
  })

  await page.getByRole('button', { name: '刷新订单' }).click()

  const response = await responsePromise
  const body = await response.json()

  expect(Array.isArray(body.items)).toBe(true)
  await expect(page.getByRole('row')).toHaveCount(2)
})

顺序必须是:

先注册 waitForResponse
  -> 再执行会触发请求的动作
  -> 等待匹配响应
  -> 断言响应或页面结果

如果先点击,极快的请求可能在等待器建立之前已经完成,从而造成竞态。

更稳妥的页面断言仍然必要。响应状态为 200 只说明 HTTP 请求成功,不说明 Vue 正确渲染了数据。反过来,页面显示内容也不能证明请求使用了正确的方法和参数。因此,重要流程可以同时验证网络和用户可见结果。

5.2 验证请求内容

可以使用 page.waitForRequest

test('提交订单发送正确数据', async ({ page }) => {
  await page.goto('/checkout')

  await page.getByLabel('商品数量').fill('2')

  const requestPromise = page.waitForRequest((request) => {
    return (
      request.url().endsWith('/api/orders') &&
      request.method() === 'POST'
    )
  })

  await page.getByRole('button', { name: '提交订单' }).click()

  const request = await requestPromise
  const data = request.postDataJSON()

  expect(data).toEqual({
    productId: 'p-100',
    quantity: 2,
  })
})

这里的 postDataJSON() 适用于请求体是 JSON 的情况。如果请求使用 FormData、文本或文件上传,应根据实际 Content-Type 解析,不能假设所有请求都是 JSON。

5.3 使用 route 模拟响应

如果测试目标是前端在“库存不足”时的表现,而不是验证真实库存服务,那么可以拦截请求:

test('库存不足时显示错误信息', async ({ page }) => {
  await page.route('**/api/inventory/**', async (route) => {
    await route.fulfill({
      status: 409,
      contentType: 'application/json',
      body: JSON.stringify({
        code: 'OUT_OF_STOCK',
        message: '库存不足',
      }),
    })
  })

  await page.goto('/product/p-100')
  await page.getByRole('button', { name: '立即购买' }).click()

  await expect(page.getByRole('alert')).toHaveText('库存不足')
})

拦截的状态变化是:

页面发起请求
  -> Playwright route 匹配 URL
  -> route.fulfill 替换真实服务器响应
  -> Vue 收到 409 JSON
  -> Vue 进入错误状态
  -> 页面显示 alert

route.fulfill 适合构造确定的边界响应。也可以继续真实请求,只修改响应:

await page.route('**/api/profile', async (route) => {
  const response = await route.fetch()
  const json = await response.json()

  await route.fulfill({
    response,
    json: {
      ...json,
      displayName: '测试用户',
    },
  })
})

要放行请求则使用:

await page.route('**/api/orders', async (route) => {
  await route.continue()
})

要模拟网络失败可以使用:

await page.route('**/api/orders', async (route) => {
  await route.abort('failed')
})

5.4 Mock 网络的边界

Mock 的测试结果只能证明:

在这个指定响应下,前端处理逻辑符合预期。

它不能证明:

  • 后端接口真实存在;
  • 前后端字段命名一致;
  • 认证和 CORS 配置正确;
  • 真实数据量下页面仍然可用;
  • 服务端事务和权限逻辑正确。

因此,应该区分两类测试:

真实后端 E2E
  验证前后端连接与业务链路

受控网络 E2E
  验证前端对成功、错误、空数据和超时的处理

如果所有测试都 Mock 网络,测试可能非常稳定,却漏掉真正的接口集成错误;如果所有测试都连接共享后端,则容易受到环境、数据和网络波动影响。

5.5 等待页面状态,而不是等待内部实现

推荐:

await expect(page.getByRole('status')).toHaveText('保存成功')

不推荐:

await page.waitForResponse('**/api/save')
await page.waitForTimeout(200)

waitForResponse 验证网络层,expect(locator) 验证页面层。只等待网络响应可能仍然存在以下问题:

  • Vue 收到响应后还需要处理数据;
  • 页面因字段错误没有渲染成功;
  • 请求成功但接口业务码表示失败;
  • 页面显示了旧数据。

六、Playwright 的上下文隔离和并行执行

6.1 Browser、Context、Page 的关系

可以把 Playwright 的对象关系理解为:

Browser
  ├── BrowserContext A
  │     ├── Page A1
  │     └── Page A2
  └── BrowserContext B
        └── Page B1
  • Browser:浏览器进程;
  • BrowserContext:类似一个独立的无痕用户档案,包含 Cookie、Storage、权限和缓存等隔离状态;
  • Page:一个标签页。

Playwright Test 通常为每个测试提供新的 page fixture,而 page 属于新的测试上下文。于是:

test('测试 A', async ({ page }) => {
  // 修改 localStorage、Cookie 或页面状态
})

test('测试 B', async ({ page }) => {
  // 不应看到测试 A 的浏览器状态
})

这种隔离不是后端数据隔离。两个测试即使使用不同 Context,也可能同时修改数据库中的同一条订单。

6.2 worker 和并行

Playwright 使用 worker 进程执行测试。worker 数量越多,理论上的并发度越高,但实际执行速度受到以下因素限制:

  • CPU;
  • 内存;
  • 浏览器启动成本;
  • 后端连接数;
  • 数据库锁;
  • 测试环境限流。

配置:

workers: process.env.CI ? 2 : undefined,
fullyParallel: true,

fullyParallel: true 允许测试文件中的测试也按更细粒度并行调度。并行并不意味着测试顺序可靠,因此测试必须满足:

测试结果=f(自身输入,隔离环境)\text{测试结果} = f(\text{自身输入}, \text{隔离环境})

而不能依赖:

测试结果=f(前一个测试留下的状态)\text{测试结果} = f(\text{前一个测试留下的状态})

下面这种设计具有隐藏依赖:

test('创建项目', async () => {
  // 创建项目
})

test('编辑刚才创建的项目', async () => {
  // 假设上一个测试创建的项目一定存在
})

并行时,第二个测试可能先执行;重试时,创建测试可能重复创建;测试顺序改变后,结果也会改变。

更合理的写法是每个测试准备自己的数据:

import { test, expect } from '@playwright/test'

test('可以编辑自己创建的项目', async ({ page, request }) => {
  const projectName = `e2e-project-${crypto.randomUUID()}`

  const createResponse = await request.post('/api/projects', {
    data: { name: projectName },
  })
  expect(createResponse.ok()).toBeTruthy()

  await page.goto('/projects')
  await page.getByRole('link', { name: projectName }).click()
  await page.getByRole('button', { name: '编辑' }).click()
  await page.getByLabel('项目名称').fill(`${projectName}-updated`)
  await page.getByRole('button', { name: '保存' }).click()

  await expect(
    page.getByRole('heading', { name: `${projectName}-updated` }),
  ).toBeVisible()
})

这里的 request 是 Playwright 提供的 APIRequestContext fixture,可直接发送 HTTP 请求准备数据。它与页面浏览器上下文可以共享或不共享状态,具体取决于使用方式和配置;如果接口需要认证,应确认请求上下文是否带有相应 storageState,或者显式提供认证头。

crypto.randomUUID() 只是让测试数据名称具有较低碰撞概率。它不能替代数据库清理。测试环境如果长期运行,仍应设计删除、事务回滚、按运行 ID 清理或独立数据库等机制。

6.3 哪些状态必须隔离

并行测试需要考虑四层状态:

状态层 示例 常见隔离方法
浏览器上下文 Cookie、localStorage 每个测试使用独立 Context
页面状态 DOM、当前路由 每个测试新建 Page
服务端业务数据 订单、项目、用户资料 唯一数据、清理、独立数据库
外部资源 邮件、支付、第三方 API 测试替身、沙箱、唯一幂等键

如果测试创建固定名称的项目:

const name = 'test-project'

并行时很可能遇到唯一约束冲突。若测试依赖固定用户余额,则另一个测试的消费操作会改变结果。浏览器隔离无法解决这些问题。

6.4 何时使用 serial

serial 会让一组测试按顺序执行,并且前一个测试失败后后续测试通常会被跳过或按组处理。它适合表达真正不可分割的流程,但不适合掩盖测试设计问题:

import { test } from '@playwright/test'

test.describe.serial('必须按顺序完成的迁移流程', () => {
  test('步骤一', async () => {})
  test('步骤二', async () => {})
})

如果“步骤二”只是因为测试作者没有为它准备数据,应该重写测试,而不是默认改成串行。串行会降低并发能力,也会使失败定位变差,因为后续测试可能只是由于前置步骤失败而没有机会运行。


七、失败证据:让一次失败变成可诊断事件

测试失败本身不是完整信息。完整的失败事件至少包含:

失败测试名称
+ 断言错误
+ 调用步骤
+ 截图
+ 页面或浏览器 Trace
+ 必要时的视频和网络日志

7.1 截图、视频和 Trace 各自证明什么

Screenshot

截图证明某一时刻页面可见内容是什么,例如:

  • 错误提示遮挡按钮;
  • 登录后仍停留在登录页;
  • 加载骨架一直存在;
  • 页面出现空白。

它不能证明:

  • 请求是否发出;
  • 请求返回了什么;
  • 失败前页面经历了哪些状态。

Video

视频可以观察动作顺序和视觉变化,例如点击后是否发生跳转、弹窗是否瞬间出现。它通常比截图占用更多空间,因此适合失败保留,不适合所有测试永久录制。

Trace

Trace 是更接近“可回放诊断记录”的证据,通常包含:

  • 测试步骤;
  • 页面快照;
  • 网络请求;
  • 控制台消息;
  • 截图;
  • 时间线。

配置:

use: {
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
  trace: 'on-first-retry',
}

这里的 trace: 'on-first-retry' 表示第一次尝试失败并进入重试时收集 Trace。它平衡了成本和诊断能力:普通成功测试不产生 Trace,疑似不稳定的测试会留下重试现场。

如果希望本地每次都能查看:

npx playwright test --trace on

如果已经产生 Trace,可以使用:

npx playwright show-trace test-results/<某个目录>/trace.zip

具体路径由项目名称、测试标题和重复尝试编号共同决定,因此不应把目录名硬编码到脚本中。

7.2 自定义附加证据

对于业务错误,截图和 Trace 之外,还可以保存结构化信息:

import { test, expect } from '@playwright/test'

test('订单提交失败时保存响应信息', async ({ page }, testInfo) => {
  await page.goto('/checkout')

  const responsePromise = page.waitForResponse((response) =>
    response.url().endsWith('/api/orders') &&
    response.request().method() === 'POST',
  )

  await page.getByRole('button', { name: '提交订单' }).click()

  const response = await responsePromise
  const body = await response.text()

  await testInfo.attach('create-order-response', {
    body: Buffer.from(body),
    contentType: 'application/json',
  })

  expect(response.status()).toBe(201)
})

testInfo.attach 会把数据交给报告器。这样失败报告中不仅有“期望 201,实际 409”,还保留了接口响应正文。

但不要无条件保存完整响应,尤其要注意:

  • Cookie;
  • Authorization;
  • 用户邮箱、手机号;
  • 订单地址;
  • 付款信息;
  • 后端内部堆栈。

测试证据本身也可能成为敏感数据泄露渠道。可以在附加前做脱敏:

const safeBody = body.replaceAll(/"token"\s*:\s*"[^"]+"/g, '"token":"[redacted]"')

正则脱敏并不适用于任意嵌套 JSON;更可靠的做法是先解析 JSON,递归删除敏感字段,再序列化。

7.3 重试不是修复失败

CI 中设置:

retries: process.env.CI ? 2 : 0

重试的作用是帮助识别偶发失败,并保留重试证据。它不应被解释为“失败了也算通过”。

一个测试第一次失败、第二次通过,仍然说明系统或测试存在不稳定性。应关注报告中的 flaky 状态,并区分:

  • 页面加载竞态;
  • 共享数据冲突;
  • 后端临时不可用;
  • 真实业务错误;
  • 测试定位器过于脆弱。

如果测试依赖随机等待,重试往往只是改变了时序,使问题暂时消失。


八、一个完整的 Vue 登录与业务流程示例

假设应用包含:

/login
/dashboard
/orders

登录成功后后端通过 Cookie 设置会话。订单页提供“刷新订单”按钮。

测试可以写成:

import { test, expect } from '@playwright/test'

test('认证用户可以刷新订单列表', async ({ page }) => {
  await page.goto('/orders')

  await expect(page).toHaveURL(/\/orders/)
  await expect(
    page.getByRole('heading', { name: '订单列表' }),
  ).toBeVisible()

  const responsePromise = page.waitForResponse((response) => {
    const url = new URL(response.url())

    return (
      url.pathname === '/api/orders' &&
      response.request().method() === 'GET' &&
      response.status() === 200
    )
  })

  await page.getByRole('button', { name: '刷新订单' }).click()

  const response = await responsePromise
  const payload = await response.json()

  expect(payload).toEqual(
    expect.objectContaining({
      items: expect.any(Array),
    }),
  )

  await expect(page.getByRole('status')).toHaveText('加载完成')
})

这个测试有三层断言:

  1. 路由层:认证用户可以进入 /orders
  2. 网络层:点击后发出正确的 GET /api/orders,并返回 200;
  3. 视图层:Vue 页面显示“加载完成”。

如果只保留第三层,接口路径改错但测试环境恰好显示缓存数据时,测试可能漏报;如果只保留第二层,Vue 收到响应却没有渲染,测试也可能漏报。


九、错误路径、超时和请求竞态

9.1 明确区分测试超时和应用超时

测试失败时常见错误:

Test timeout of 30000ms exceeded

这只说明 Playwright 在规定时间内没有等到测试结束,不说明具体原因。可能是:

  • 定位器找不到元素;
  • 页面跳转未完成;
  • 请求没有返回;
  • 应用进入死循环;
  • 断言文本永远不成立。

可以在关键动作附近添加明确的断言,而不是简单提高全局超时:

await page.getByRole('button', { name: '保存' }).click()

await expect(page.getByRole('status')).toHaveText('保存成功', {
  timeout: 10_000,
})

全局测试超时可以配置:

timeout: 30_000,
expect: {
  timeout: 5_000,
},

两者含义不同:

  • timeout:一个测试从开始到结束的总时间预算;
  • expect.timeout:自动重试断言的时间预算。

提高超时可能掩盖性能回归。应先通过 Trace 或网络日志确认到底是在等待什么。

9.2 避免在动作之后才等待同一个事件

错误示例:

await page.getByRole('button', { name: '保存' }).click()
await page.waitForResponse('**/api/save')

正确示例:

const saveResponse = page.waitForResponse('**/api/save')

await page.getByRole('button', { name: '保存' }).click()

await expect((await saveResponse).status()).toBe(200)

更常见的写法是使用 Promise.all

const [response] = await Promise.all([
  page.waitForResponse((response) =>
    response.url().endsWith('/api/save') &&
    response.request().method() === 'POST',
  ),
  page.getByRole('button', { name: '保存' }).click(),
])

expect(response.status()).toBe(200)

Promise.all 的数组元素会并发注册和执行。等待器必须放在点击动作之前,才能覆盖从触发到完成的整个窗口。

9.3 请求重叠时不要只匹配 URL

页面初始化可能发出多个相同路径的请求:

GET /api/orders
GET /api/orders

如果只写:

page.waitForResponse('**/api/orders')

可能匹配到不想要的那个请求。应加入:

  • 请求方法;
  • 查询参数;
  • 响应状态;
  • 必要时请求体或响应内容。

例如:

await page.waitForResponse((response) => {
  const url = new URL(response.url())

  return (
    url.pathname === '/api/orders' &&
    url.searchParams.get('status') === 'paid' &&
    response.request().method() === 'GET' &&
    response.status() === 200
  )
})

十、组件状态、网络状态与可测试 Vue 页面

Vue Composition API 页面通常有多个异步状态:

import { ref } from 'vue'

const loading = ref(false)
const errorMessage = ref('')
const orders = ref<Order[]>([])

async function loadOrders() {
  loading.value = true
  errorMessage.value = ''

  try {
    const response = await fetch('/api/orders')

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    const data = await response.json()
    orders.value = data.items
  } catch {
    errorMessage.value = '订单加载失败'
  } finally {
    loading.value = false
  }
}

对应模板应让关键状态可观察:

<template>
  <section>
    <h1>订单列表</h1>

    <p v-if="loading" role="status">加载中</p>
    <p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>

    <ul v-else>
      <li v-for="order in orders" :key="order.id">
        {{ order.number }}
      </li>
    </ul>

    <button type="button" @click="loadOrders">
      刷新订单
    </button>
  </section>
</template>

这样 E2E 测试可以验证完整状态转换:

初始状态
  -> loading = true
  -> 显示“加载中”
  -> 请求成功
  -> orders 被赋值
  -> loading = false
  -> 显示订单列表

错误路径则是:

初始状态
  -> loading = true
  -> 请求失败或 response.ok = false
  -> errorMessage 被赋值
  -> loading = false
  -> 显示 role="alert"

如果模板只显示一个没有稳定语义的图标,测试就难以确认状态变化是否正确。可访问性属性在这里不仅服务于辅助技术,也成为可靠的测试契约。


十一、常见失败表现和诊断路径

11.1 页面一直显示“加载中”

可能原因:

  1. 前端请求路径错误;
  2. 后端没有启动;
  3. Playwright route 拦截后没有调用 continuefulfillabort
  4. 服务端响应格式与前端解析逻辑不一致;
  5. 页面在等待错误的请求;
  6. 请求被 Service Worker 处理,导致普通路由拦截行为与预期不同。

诊断顺序:

查看 Trace 网络时间线
  -> 确认请求是否发出
  -> 确认 URL、方法和状态码
  -> 查看响应正文
  -> 查看控制台错误
  -> 查看页面最终 DOM

如果使用了 page.route,每个匹配分支必须明确结束:

await page.route('**/api/data', async (route) => {
  if (process.env.MOCK_DATA === '1') {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ items: [] }),
    })
    return
  }

  await route.continue()
})

遗漏 return 可能让代码继续执行并重复操作同一个 route,造成错误或不可预期行为。

11.2 登录态在本地有效,在 CI 失效

常见原因:

  • CI 没有生成 playwright/.auth/user.json
  • storageState 路径相对当前工作目录解析错误;
  • 登录域名和业务域名不一致;
  • Cookie 的 domainsecure 属性不适用于当前 URL;
  • 测试账号密码依赖环境变量但变量未注入;
  • 登录接口依赖验证码、二次认证或外部服务;
  • 会话在 setup 完成后很快过期。

可以在 setup 中加入可见断言:

await expect(page).toHaveURL(/\/dashboard/)
await expect(page.getByRole('heading', { name: '控制台' })).toBeVisible()

不要只保存状态而不验证登录是否成功。否则 setup 可能成功结束,但生成的是未登录状态,所有后续测试都会在业务页面失败。

11.3 并行后出现“偶发唯一键冲突”

这通常不是 Playwright 的浏览器隔离失效,而是多个测试向同一个后端写入相同数据。例如:

worker 1: 创建 test-project
worker 2: 创建 test-project
数据库:唯一索引冲突

解决方向包括:

  • 使用唯一运行 ID;
  • 让 API 支持幂等键;
  • 每个 worker 使用独立租户;
  • 测试前后清理数据;
  • 使用独立数据库或临时 schema;
  • 对真正需要共享的数据只读访问。

workers 改为 1 只能降低并发冲突,不会修复数据清理和测试依赖问题。


十二、真实后端、网络 Mock 与测试分层

一套合理的 E2E 测试不应只有一种网络策略。

12.1 真实链路测试

适合验证:

  • 登录和权限;
  • 关键 CRUD 流程;
  • 前后端字段兼容性;
  • 数据库事务后的用户可见结果;
  • 部署环境路由和静态资源。

示例:

登录
  -> 创建订单
  -> 刷新页面
  -> 后端仍能返回该订单

这个流程可以发现 Mock 无法发现的错误,例如 Cookie 没有正确设置、API 网关路径错误或数据库写入失败。

12.2 Mock 边界测试

适合验证:

  • 500 错误;
  • 409 冲突;
  • 空列表;
  • 慢响应;
  • 网络断开;
  • 特殊字段和分页边界。

例如模拟慢响应:

await page.route('**/api/orders', async (route) => {
  await new Promise((resolve) => setTimeout(resolve, 2_000))

  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ items: [] }),
  })
})

这种测试可以验证加载状态是否正确,但它的耗时是人为设置的,不应被当作真实后端性能测试。

12.3 不要让 Mock 覆盖被测对象

如果测试目标是验证前端请求参数,不应在 route 中直接返回一个与请求无关的成功响应后就结束。应该同时检查请求:

await page.route('**/api/orders', async (route) => {
  const request = route.request()
  const data = request.postDataJSON()

  if (data.quantity !== 2) {
    await route.fulfill({
      status: 400,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'invalid quantity' }),
    })
    return
  }

  await route.fulfill({
    status: 201,
    contentType: 'application/json',
    body: JSON.stringify({ id: 'order-1', number: 'ORD-001' }),
  })
})

否则 Mock 可能把前端错误的请求也“修饰”为成功,降低测试的有效性。


十三、报告、命令结果与 CI 处理

运行全部 E2E 测试:

npm run test:e2e

成功时,进程通常以退出码 0 结束;存在失败测试时,以非零退出码结束。CI 应依赖退出码阻断流水线,而不是只看 HTML 报告是否生成。

查看 HTML 报告:

npm run test:e2e:report

在无图形界面的 CI 中,配置:

reporter: [['dot'], ['html', { open: 'never' }]]

这样测试结束后生成报告,但不会尝试启动本地浏览器打开它。

失败证据通常位于:

test-results/
playwright-report/

推荐的 CI 处理逻辑是:

  1. 安装 Node 依赖;
  2. 安装 Playwright 浏览器和系统依赖;
  3. 启动或由 webServer 启动前端;
  4. 确保 API、数据库和测试账号可用;
  5. 执行 playwright test
  6. 无论测试成功或失败,都上传报告和失败证据;
  7. 根据测试进程退出码决定流水线是否失败。

上传产物时要注意保密。HTML 报告、视频和 Trace 可能包含:

  • 登录账号;
  • 页面中的用户资料;
  • 接口响应;
  • 业务订单;
  • 认证 Cookie 的可用信息。

在共享 CI 中,产物访问权限应与测试数据敏感等级匹配。若测试使用真实用户信息,应改用脱敏数据或专用测试租户。


十四、从失败证据反推故障边界

可以用下面的证据组合缩小问题范围:

现象 可能边界
page.goto 失败 前端服务器、DNS、端口或 HTTPS 配置
页面打开但找不到登录按钮 Vue 路由、构建产物、页面渲染错误
登录请求返回 401 测试凭证、认证服务或请求数据
登录返回 200 但访问业务页被重定向 Cookie、Token 保存或域名配置
请求未发出 点击未成功、事件绑定错误、前端状态条件不满足
请求发出但一直未完成 后端、代理、路由拦截或网络
请求 200 但页面内容错误 响应解析、响应式状态或模板渲染
只有并行时失败 共享后端数据、资源限流或测试顺序依赖
失败后无截图和 Trace 配置未生效、产物未上传或测试进程被强制终止

这种诊断方式比直接增加等待时间更有效,因为它把“测试失败”拆成可观察的状态转换。


十五、一个可执行的最小验收闭环

对于 Vue 项目,最小但完整的 E2E 闭环可以是:

1. npm ci
2. npx playwright install --with-deps
3. 启动 Vite 应用
4. 启动测试 API 和数据库
5. setup 登录并保存 storageState
6. 每个测试使用独立 Browser Context
7. 测试通过语义定位器操作页面
8. 在触发动作前注册网络等待
9. 同时验证网络结果和用户可见页面状态
10. 允许测试文件并行,但隔离服务端数据
11. 失败时保留截图、视频或 Trace
12. 上传报告,依据退出码决定 CI 成败

其中最容易被忽略的是第 9 和第 10 步:

  • 只断言页面,可能漏掉错误请求;
  • 只断言网络,可能漏掉错误渲染;
  • 只隔离浏览器,不隔离后端数据,仍会产生并行污染;
  • 只重试失败,不分析失败证据,无法判断是否存在不稳定性。

Playwright 的核心不是“模拟点击”,而是建立一个可重复、可观察、可隔离的浏览器实验环境。Vue 页面负责响应式渲染,Vite 负责构建和提供前端应用,后端负责认证与业务状态,Playwright 则把这些边界串成用户可执行的验证流程。登录态决定测试从哪个状态开始,网络控制决定测试观察或替换哪条通信路径,并行模型决定状态是否互相污染,失败证据决定一次失败能否转化为可修复的问题。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。