Skip to content

03|端到端测试 ​

第九章 / 03。目标:用真实浏览器守住跨路由、网络、权限和持久化的最贵失败。

一个不依赖真实账号的运行夹具 ​

先完成 测试环境。在同一个实验项目安装 npm install -D --save-exact @playwright/test@1.63.0,运行 npx playwright install chromium 安装测试浏览器。

新建 playwright.config.ts:

ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://127.0.0.1:4173', trace: 'retain-on-failure' },
  webServer: {
    command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: false,
    timeout: 120_000
  }
})

测试工具先构建和启动独立预览,再创建浏览器 context 执行用例。端口被占用应换成本次实验专用端口,不复用未知服务器。Vitest 只找 src/**/*.spec.ts,Playwright 只找 e2e/,二者不会误收对方文件。

把 src/App.vue 临时替换成下面保存草稿页。开发预览在 /drafts/new 回退到同一个 SPA 入口;本实验只测一页,不隐含需要先搭一个登录后端。

vue
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'
const title = ref('')
const field = useTemplateRef<HTMLInputElement>('title')
const status = ref('')
const error = ref('')
const pending = ref(false)
async function save() {
  if (pending.value) return
  pending.value = true
  error.value = ''
  status.value = '保存中'
  try {
    const response = await fetch('/api/drafts', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: title.value })
    })
    if (!response.ok) throw new Error('save failed')
    status.value = '已保存'
  } catch {
    status.value = ''
    error.value = '保存失败'
    await nextTick()
    field.value?.focus()
  } finally { pending.value = false }
}
</script>
<template>
  <form @submit.prevent="save">
    <label>标题<input ref="title" v-model="title"></label>
    <button :disabled="pending">{{ error ? '重试' : '保存' }}</button>
    <p role="status">{{ status }}</p>
    <p v-if="error" role="alert">{{ error }}</p>
  </form>
</template>

接口响应由练习答案里的 page.route 提供;独自手点页面没有服务器时会进入失败态,符合夹具的边界。把答案测试保存为 e2e/draft.spec.ts,运行 npx playwright test e2e/draft.spec.ts。Playwright 的 test 不从 vitest 导入,两者是不同运行器。

E2E 的边界 ​

纯函数和组件分支留给快测试;登录跳转、Cookie、浏览器表单、真实构建资源和后端协议交给 E2E。数量不必最多,但每条都应对应业务风险。

以下登录例是已有登录应用的局部用例,页面、账号与API由该应用提供;首次运行使用上面的草稿夹具和本节答案。

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

test('登录后创建草稿', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('learner@example.test')
  await page.getByLabel('密码').fill('correct-password')

  const login = page.waitForResponse(r =>
    r.url().endsWith('/api/session') && r.request().method() === 'POST'
  )
  await page.getByRole('button', { name: '登录' }).click()
  expect((await login).status()).toBe(204)

  await expect(page).toHaveURL(/\/dashboard$/)
  await page.getByRole('link', { name: '新建草稿' }).click()
  await page.getByLabel('标题').fill('Hydration notes')
  await page.getByRole('button', { name: '保存' }).click()
  await expect(page.getByRole('status')).toHaveText('已保存')
})

Playwright locator 与 expect(locator) 会自动等待可操作/期望状态。expect(await locator.isVisible()).toBe(true) 是瞬时采样,不会获得 web-first 重试。不要用固定 timeout 等待接口。

失败路径与隔离 ​

ts
await page.route('**/api/drafts', route =>
  route.fulfill({ status: 503, contentType: 'application/json', body: '{"code":"unavailable"}' })
)

随后点击保存,断言 alert、草稿仍在输入框、URL 未误跳转、可重试。若要证明前后端真实集成,再保留少量不拦截网络的烟雾测试;路由 mock 不能替代协议集成。

每个用例使用独立浏览器 context/测试账号或 API 创建的唯一数据,并在测试边界清理。失败时保留 trace、截图、console 与网络证据;重试只能用于收集证据,不能把首轮失败算成功。

E2E 应对生产构建预览运行,开发服务器 HMR、未压缩代码和错误覆盖层不是用户环境。

练习:失败后草稿不丢 ​

编写 E2E:API 首次返回 503、第二次 201。断言第一次保存后错误可见、输入值和焦点保留;点击重试前先建立 response wait,第二次成功后状态变为已保存且只创建一个草稿。不得使用 waitForTimeout,并配置失败时 trace。

参考答案:第 9 章第 03 题。

用心记录每一步成长