03|端到端测试
第九章 / 03。目标:用真实浏览器守住跨路由、网络、权限和持久化的最贵失败。
一个不依赖真实账号的运行夹具
先完成 测试环境。在同一个实验项目安装 npm install -D --save-exact @playwright/test@1.63.0,运行 npx playwright install chromium 安装测试浏览器。
新建 playwright.config.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 入口;本实验只测一页,不隐含需要先搭一个登录后端。
<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由该应用提供;首次运行使用上面的草稿夹具和本节答案。
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 等待接口。
失败路径与隔离
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 题。