React 基础体系 · 第 51/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 端到端测试:Playwright、登录态、网络、并行和失败证据
端到端测试(End-to-End Test,E2E)验证的是一条接近真实用户的完整路径:浏览器加载 React 应用,执行 JavaScript,请求服务端接口,接收响应并更新 DOM,用户再通过可见界面完成操作。
它与单元测试、组件测试的边界不同:
- 单元测试通常验证一个函数或模块的输入输出。
- 组件测试通常验证一个 React 组件在给定 props、状态和依赖下的渲染与交互。
- 端到端测试验证浏览器、React、路由、认证、网络和服务端之间的协作结果。
因此,端到端测试更容易发现“单个模块都正确,但组合起来失败”的问题,例如:
- 登录成功后重定向地址错误;
- Cookie 已写入,但应用没有恢复用户信息;
- 页面展示了加载状态,却没有等待 API 完成;
- API 返回 401 后,前端没有跳转到登录页;
- 两个并行测试修改了同一条数据库记录;
- 测试失败时只有“expected true, received false”,无法判断是请求失败、元素未出现还是环境异常。
Playwright 的价值不只是“模拟点击”。它同时提供浏览器控制、自动等待、网络观察与拦截、认证状态保存、测试隔离、并行调度以及截图、视频和 Trace 等失败证据。
一、先建立端到端测试的系统边界
一个典型的 React 端到端测试链路如下:
sequenceDiagram
participant T as Playwright 测试
participant B as 浏览器
participant R as React 应用
participant S as 服务端 API
participant D as 数据库
T->>B: 启动 Chromium / Firefox / WebKit
B->>R: 加载页面
R->>S: 请求登录态或业务数据
S->>D: 查询或修改数据
D-->>S: 返回数据
S-->>R: HTTP 响应
R-->>B: 更新 DOM
B-->>T: locator 观察到可见结果
这条链路中至少有三类状态:
- 浏览器状态:Cookie、Local Storage、页面 URL、打开的页面和上下文。
- React 状态:加载中、成功、错误、用户信息、表单值等。
- 服务端状态:会话、数据库记录、权限以及异步任务。
测试断言的对象通常是浏览器可见行为,例如:
await expect(page.getByRole('heading', { name: '订单详情' })).toBeVisible();
这个断言成立,意味着页面已经完成了足以显示该标题的渲染流程;但它不一定证明数据库中所有后台任务都完成。反过来,直接断言某个内部 React state 或某个未公开的函数调用,通常已经偏离了端到端测试的边界。
React 端与服务端边界
React 19 可以使用服务端组件、Server Actions 或框架提供的服务端能力,但 Playwright 仍然从浏览器边界观察结果。测试需要明确:
- 页面中的客户端组件运行在浏览器;
- 服务端组件或服务端 Action 运行在服务端;
- 浏览器可能只能看到最终 HTML、网络响应和 DOM;
- 某些服务端异常不会以浏览器异常形式出现,而会表现为错误页、状态码或错误提示。
因此,一个“按钮点击后无变化”的失败,可能来自:
- React 事件处理函数没有执行;
- 请求 URL 错误;
- Cookie 没有发送;
- 服务端返回 401 或 500;
- 响应结构变化导致 React 渲染分支报错;
- 页面虽然成功更新,但断言使用了错误的定位方式。
端到端测试的核心工作,就是通过稳定的用户行为和可观测证据,把这些可能性区分开。
二、Playwright 的基本模型:Browser、Context、Page 和 Locator
Playwright 常用对象有四层:
- Browser:浏览器进程,例如 Chromium。
- BrowserContext:隔离的浏览器会话,类似一个新的无痕浏览器配置文件。
- Page:一个标签页。
- Locator:对页面元素的延迟定位描述。
测试中通常不直接管理 Browser,而使用 Playwright Test 提供的 page fixture:
import { test, expect } from '@playwright/test';
test('用户可以打开仪表盘', async ({ page }) => {
await page.goto('/dashboard');
await expect(
page.getByRole('heading', { name: '仪表盘' }),
).toBeVisible();
});
page.goto('/dashboard') 能否工作,取决于 playwright.config.ts 中是否配置了 baseURL。
一个最小配置如下:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI
? [['html', { outputFolder: 'playwright-report', open: 'never' }], ['line']]
: [['list']],
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
安装和运行:
npm install -D @playwright/test
npx playwright install
npx playwright test
npx playwright install 会安装浏览器二进制文件。CI 环境不能假设开发机已经存在这些浏览器。
Locator 为什么比 CSS 查询更适合作为测试接口
推荐优先使用表达用户语义的定位方式:
page.getByRole('button', { name: '保存' });
page.getByLabel('邮箱');
page.getByPlaceholder('请输入邮箱');
page.getByText('保存成功');
page.getByTestId('order-status');
例如:
await page.getByLabel('邮箱').fill('alice@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();
Locator 是延迟求值的。getByRole 创建的是一个定位描述,不会在这一行立即要求元素已经存在。真正执行点击或断言时,Playwright 会等待元素满足可操作条件,例如出现在 DOM 中、可见、未被遮挡并且可交互。
这也是下面写法更可靠的原因:
await page.getByRole('button', { name: '提交' }).click();
await expect(page.getByText('提交成功')).toBeVisible();
而不是:
await page.waitForTimeout(1000);
await page.locator('.success-message').isVisible();
固定等待只说明时间过去了,不说明请求完成、React 已提交渲染或元素已经可见。它可能在本地“碰巧通过”,在 CI 慢机器上失败。
三、登录态:从一次登录到可复用的认证状态
登录态是端到端测试最容易设计失真的部分。
假设应用使用基于 Cookie 的会话认证:
- 浏览器提交用户名和密码;
- 服务端返回
Set-Cookie; - 浏览器保存 Cookie;
- 后续请求自动携带 Cookie;
- 应用通过
/api/me或页面数据恢复当前用户。
如果每个测试都重复执行完整 UI 登录,测试会变慢,而且登录页面本身的缺陷会导致所有业务测试同时失败。常见做法是使用一个独立的 setup project 登录一次,保存认证状态,其他项目复用该状态。
使用 Playwright setup project
目录结构:
tests/
auth.setup.ts
dashboard.spec.ts
playwright.config.ts
playwright/.auth/
认证 setup:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
fs.mkdirSync('playwright/.auth', { recursive: true });
await page.goto('/login');
await page.getByLabel('邮箱').fill(
process.env.E2E_EMAIL ?? 'e2e@example.com',
);
await page.getByLabel('密码').fill(
process.env.E2E_PASSWORD ?? 'e2e-password',
);
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(
page.getByRole('heading', { name: '仪表盘' }),
).toBeVisible();
await page.context().storageState({ path: authFile });
});
配置项目依赖:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: authFile,
},
dependencies: ['setup'],
},
],
});
业务测试可以直接使用已认证的 page:
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
test('已登录用户可以查看仪表盘', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
await expect(
page.getByRole('heading', { name: '仪表盘' }),
).toBeVisible();
});
这里的因果关系是:
setup项目先执行登录;storageState保存浏览器上下文中的认证数据;chromium项目依赖setup;- 业务测试启动的新 context 加载该文件;
- 页面请求因此携带相同的认证状态。
storageState 保存了什么,没保存什么
storageState 主要用于保存可复用的浏览器存储状态,常见内容包括 Cookies 和 Local Storage。它不是完整的浏览器快照,不能把所有浏览器运行时状态都当作已经保存。
尤其要注意:
- Session Storage 通常不会因为保存
storageState自动复用; - IndexedDB 是否保存以及如何保存取决于使用的 Playwright 版本和配置能力,不能笼统假设所有客户端状态都已包含;
- 页面内存中的 React state、打开的 WebSocket、定时器和已渲染 DOM 都不会被保存;
- 服务端会话可能过期,即使文件仍然存在,测试也可能收到 401。
认证文件包含 Cookie 或令牌,必须加入 .gitignore:
playwright/.auth/
playwright-report/
test-results/
如果认证文件被提交到仓库,任何能读取仓库的人都可能获得测试账号的访问能力。CI 中应使用专用低权限账号,并通过密钥管理注入密码。
UI 登录与 API 登录的取舍
UI 登录能验证真实登录页面,但耗时较长,也会把登录页面的缺陷传播到所有测试。若目标只是建立会话,可以使用 API 请求登录:
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
const authFile = 'playwright/.auth/api-user.json';
setup('authenticate by API', async ({ request }) => {
fs.mkdirSync('playwright/.auth', { recursive: true });
const response = await request.post('/api/login', {
data: {
email: process.env.E2E_EMAIL,
password: process.env.E2E_PASSWORD,
},
});
expect(response.ok()).toBeTruthy();
// 将 API 上下文中的认证状态导出给浏览器测试使用
await request.storageState({ path: authFile });
});
但这段代码成立有前提:登录接口产生的 Cookie 必须属于同一个测试域,并且该认证方式能被浏览器 context 使用。如果应用把令牌放在响应 JSON 中,测试还需要按照应用协议把令牌写入 Local Storage,不能仅凭 response.ok() 就认为浏览器已登录。
API 登录验证的是认证协议,不验证登录表单、按钮、错误提示和重定向。因此实践中通常采用:
- 少量测试覆盖完整 UI 登录;
- 大多数业务测试复用 API 或 setup 产生的登录态。
四、认证状态与测试隔离:共享登录态不等于共享测试状态
storageState 可以复用身份,但不应该让测试共享互相修改的数据。
例如两个测试都登录为 alice:
测试 A:创建订单 order-1
测试 B:删除 order-1
如果两者并行执行,结果取决于时序:
- B 先执行,可能删除失败;
- A 先执行,B 可能在页面尚未刷新时观察到旧数据;
- 测试重试时,A 可能发现订单已存在;
- 失败未必能稳定复现。
因此要区分:
- 身份隔离:使用哪个用户登录;
- 数据隔离:测试操作哪些数据库实体;
- 浏览器隔离:是否使用独立 BrowserContext;
- 服务端资源隔离:订单、项目、文件等是否属于当前测试专属数据。
Playwright Test 默认会为测试提供独立的 context 和 page,但它不会自动为你创建独立数据库记录。可以使用唯一标识构造资源:
import { randomUUID } from 'node:crypto';
test('用户可以创建项目', async ({ page }) => {
const projectName = `e2e-project-${randomUUID()}`;
await page.goto('/projects/new');
await page.getByLabel('项目名称').fill(projectName);
await page.getByRole('button', { name: '创建' }).click();
await expect(page.getByText(projectName)).toBeVisible();
});
如果系统支持按测试账号隔离数据,更稳妥的策略是为每个 worker 分配一个独立账号。worker 是 Playwright 中运行一组测试的独立进程。需要注意:
test级 fixture 通常每个测试创建一次;worker级 fixture 在一个 worker 内复用;- worker 数量增加会提高并行度,也会增加数据库、浏览器和服务端压力;
- 测试数据必须支持并行创建、查询和清理。
不应为了绕开数据冲突而把所有测试写成串行链:
test.describe.configure({ mode: 'serial' });
串行模式会隐藏测试之间的共享状态问题,并且一个前置测试失败后,后续测试可能全部被跳过。只有当业务本身必须保持顺序、且无法改造为独立场景时,才使用串行模式。
五、网络:观察、等待、拦截和模拟必须区分
端到端测试中的“网络”有四种不同用途:
- 观察请求:确认页面发起了预期请求;
- 等待请求完成:让断言与真实响应建立因果关系;
- 拦截并修改请求或响应:测试特定边界条件;
- 完全模拟后端:让测试只验证前端行为。
这四种用途不能混为一谈。模拟网络后,测试就不再覆盖真实服务端实现。
等待网络与用户操作同时开始
如果点击按钮才触发请求,应使用 Promise.all 同时注册等待条件和执行操作:
const responsePromise = page.waitForResponse((response) => {
return (
response.url().endsWith('/api/orders') &&
response.request().method() === 'POST' &&
response.status() === 201
);
});
await page.getByRole('button', { name: '提交订单' }).click();
const response = await responsePromise;
const body = await response.json();
expect(body.id).toBeTruthy();
await expect(page.getByText('订单已提交')).toBeVisible();
顺序不能写成:
await page.getByRole('button', { name: '提交订单' }).click();
await page.waitForResponse('/api/orders');
因为请求可能在点击后很快完成,后注册的等待条件可能错过目标响应。Promise.all 先建立监听,再触发操作,避免这个竞态。
不过,等待响应不应取代用户可见断言。服务端返回 201 只说明 HTTP 层成功,用户仍可能看不到成功状态。完整测试应同时验证:
- 请求方法、URL 和状态码;
- 页面上的最终可见结果;
- 必要时验证响应数据中的关键字段。
路由拦截:模拟成功、失败和延迟
Playwright 可以拦截页面网络请求:
test('API 返回错误时显示重试提示', async ({ page }) => {
await page.route('**/api/profile', async (route) => {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({
code: 'SERVICE_UNAVAILABLE',
message: '服务暂时不可用',
}),
});
});
await page.goto('/profile');
await expect(
page.getByRole('alert'),
).toContainText('服务暂时不可用');
await expect(
page.getByRole('button', { name: '重试' }),
).toBeVisible();
});
这里测试的是 React 对 503 响应的处理逻辑,而不是服务端是否真的会返回 503。它适合验证难以稳定制造的边界条件,例如:
- 401 未登录;
- 403 无权限;
- 404 资源不存在;
- 429 限流;
- 500 或 503 服务异常;
- 响应字段缺失;
- 网络请求被中止。
模拟响应时必须与真实接口契约一致。若生产接口返回:
{
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "服务暂时不可用"
}
}
而测试返回了完全不同的结构,测试可能验证了一个不存在的协议。
也可以模拟延迟:
await page.route('**/api/dashboard', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 800));
await route.continue();
});
它可以验证加载状态是否显示,但延迟本身是测试控制条件,不是生产性能测量。不要依据这种人为延迟得出真实网络性能结论。
观察页面发出的请求
如果页面打开后应请求 /api/me:
const requestPromise = page.waitForRequest(
(request) =>
request.url().endsWith('/api/me') &&
request.method() === 'GET',
);
await page.goto('/dashboard');
const request = await requestPromise;
expect(request.headers()['accept']).toContain('application/json');
更接近业务结果的断言通常仍然是:
await expect(
page.getByRole('heading', { name: '仪表盘' }),
).toBeVisible();
请求存在不等于请求被正确处理。一个页面可能成功发送了 /api/me,但因为响应解析错误仍然显示空白页。
不要把所有请求都拦截掉
全局拦截所有 API 请求会带来两个风险:
- 测试失去对真实后端集成问题的检测能力;
- 模拟数据与真实接口逐渐分叉,测试长期通过但生产失败。
更合理的分层是:
- 少量关键路径使用真实后端,验证完整链路;
- 对稳定性差、难以制造或成本高的故障使用精确拦截;
- 在 CI 中保留接口契约检查或集成测试,避免 mock 长期失真。
六、等待机制:自动等待解决时序,不解决错误因果
Playwright 的自动等待主要解决“元素尚未满足操作条件”的问题。例如:
await page.getByRole('button', { name: '保存' }).click();
Playwright 会等待按钮可操作,但不会自动知道:
- 你想等待哪个 API;
- 哪个加载动画代表业务已完成;
- 某个后台任务是否已持久化;
- 页面上的旧数据是否需要刷新。
因此,等待必须围绕业务状态设计。
不稳定的写法:
await page.waitForTimeout(2000);
await expect(page.getByText('处理完成')).toBeVisible();
更可靠的写法是等待可观测状态:
await expect(page.getByRole('status')).toHaveText('处理中');
await expect(
page.getByRole('status'),
).toHaveText('处理完成', { timeout: 15_000 });
如果状态来自轮询,页面应提供稳定的可见状态或测试可读的语义标记。测试可以等待该标记,但不应依赖 React 内部变量。
对 URL 的断言也应使用 Playwright 的等待断言:
await expect(page).toHaveURL(/\/orders\/[a-z0-9-]+$/);
而不是立即读取:
expect(page.url()).toMatch(/\/orders\//);
前者会等待导航完成或 URL 满足条件,后者只检查调用瞬间的值。
七、并行执行:速度来自隔离,失败来自共享资源
Playwright 可以在多个 worker 中并行运行测试。并行的本质是把测试集合分成多个执行单元:
测试集合 T = {t1, t2, t3, t4}
worker-1: t1, t3
worker-2: t2, t4
若测试之间完全独立,总耗时近似由最慢 worker 决定,而不是所有测试耗时相加。但“完全独立”必须包括外部资源。
一个测试可以并行运行的条件是:
其中:
- 是测试 读取的共享资源;
- 是测试 写入的共享资源。
更严格地说,两个并行测试只要存在同一资源上的写写冲突,或者一个测试读取另一个测试正在写入的资源,就可能产生时序依赖。
例如:
A: 写入用户 alice 的 displayName = A
B: 写入用户 alice 的 displayName = B
A: 读取 displayName,期望为 A
即使两个 BrowserContext 完全隔离,服务端数据库仍然共享,A 和 B 依旧冲突。
并行配置与资源成本
可以配置 worker 数量:
export default defineConfig({
workers: process.env.CI ? 2 : undefined,
fullyParallel: true,
});
workers: 2 表示最多使用两个 worker;undefined 表示由 Playwright 默认决定。worker 越多不一定越快,瓶颈可能转移到:
- 数据库连接数;
- API 限流;
- CPU 和内存;
- 测试服务器;
- 外部支付、邮件等不可并行服务。
可以用 --workers=1 验证是否存在并行竞态:
npx playwright test --workers=1
如果单 worker 稳定、并行时失败,应优先检查共享数据库记录、固定账号、全局 mock、端口和外部服务,而不是立刻增加重试次数。
重试不是隔离方案
retries: 2
重试适合处理偶发环境故障,但不能修复:
- 测试依赖随机时序;
- 测试数据没有清理;
- 共享账号被其他测试修改;
- 断言本身缺少等待;
- 服务端接口本来就不稳定。
如果重试后通过,仍应检查第一次失败留下的 Trace 和日志,因为重试可能只是改变了竞态时序。
八、失败证据:让一次失败能够被重放和定位
失败证据不是“失败后多截一张图”这么简单。一个可诊断的失败至少需要回答:
- 浏览器当时在哪个 URL?
- 页面上看到了什么?
- 之前执行了哪些操作?
- 发出了哪些请求,响应是什么?
- 元素是不存在、不可见、被遮挡,还是文本不匹配?
- 是首次失败,还是重试后失败?
推荐配置:
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
三类证据的含义不同:
Screenshot
截图记录某个时刻的视觉结果,适合判断:
- 错误提示是否显示;
- 页面是否是登录页;
- 布局是否遮挡按钮;
- 页面是否加载成空白。
它不能证明之前发生了什么,也不能完整展示网络时序。
Video
视频记录页面交互过程,适合观察:
- 点击是否发生;
- 页面是否反复跳转;
- 加载状态是否卡住;
- 动画或遮罩是否阻止操作。
视频文件较大,而且对“哪个请求返回 401”这类问题不如 Trace 直接。
Trace
Trace 通常包含操作时间线、页面快照、网络请求、控制台信息和截图等数据,适合重建失败路径。CI 失败后可以使用:
npx playwright show-trace test-results/**/trace.zip
具体文件路径取决于测试名称和报告目录。打开后应沿时间线检查:
- 最后一次成功的用户操作;
- 失败断言前页面快照;
- 目标请求的 URL、方法和状态码;
- 控制台错误;
- 是否出现重定向或异常响应。
trace: 'on-first-retry' 的含义是首次通过时不保留 Trace,第一次重试时记录。这样能减少正常运行的存储开销,同时保留失败诊断材料。若正在调查不稳定问题,可以临时使用:
npx playwright test --trace=on
但这会增加运行时间和产物大小,不宜默认长期打开全部 Trace。
使用 HTML 报告查看失败
npx playwright show-report playwright-report
CI 应保存以下目录作为构建产物:
playwright-report/
test-results/
不能只保存 HTML 报告而丢弃 test-results,因为截图、视频和 Trace 通常位于后者。
九、从失败表现反推故障路径
端到端失败诊断应从现象反推因果链,而不是先修改超时时间。
现象一:找不到登录后的标题
可能路径:
登录点击
├─ 表单校验失败
├─ POST /api/login 返回 401
├─ 登录成功但重定向错误
├─ Cookie 未保存或域名错误
├─ /api/me 返回 401
└─ React 渲染错误
诊断顺序:
- 看 URL 是否仍为
/login; - 看登录响应状态码;
- 看是否发生
Set-Cookie或其他认证状态写入; - 看后续
/api/me是否携带认证信息; - 看 Trace 中是否有控制台错误;
- 再检查定位器和文本是否正确。
现象二:等待响应超时
可能是:
- 点击没有触发表单提交;
- URL 匹配条件写错;
- 请求被浏览器 CORS 或 CSP 拦截;
- 前端请求了相对路径,而测试监听了另一域名;
- 页面在请求之前已经导航;
- 网络被 route 规则提前 abort;
- 接口一直处于 pending。
不要立即把超时从 5 秒改成 60 秒。应先查看失败 Trace 或监听请求:
page.on('request', (request) => {
console.log('>>', request.method(), request.url());
});
page.on('response', (response) => {
console.log('<<', response.status(), response.url());
});
page.on('console', (message) => {
console.log('[console]', message.type(), message.text());
});
调试完成后应移除或限制日志,避免 CI 输出敏感 Cookie、令牌或用户数据。
现象三:单独运行通过,完整套件失败
优先怀疑顺序和共享状态:
- 前一个测试修改了固定记录;
- setup 生成的登录态已经过期;
- 某个
page.route()没有解除; - 测试依赖了前一个测试留下的 Local Storage;
- 数据库清理与异步任务存在竞态;
- 并行 worker 使用了同一个外部账号。
可以用以下命令缩小范围:
npx playwright test tests/dashboard.spec.ts
npx playwright test --workers=1
npx playwright test --repeat-each=10
--repeat-each=10 适合复现随机失败,但它不能证明测试具备隔离性;如果每次重复都使用相同数据,也可能只是重复触发同一个污染问题。
十、一个完整的业务测试示例
下面的测试验证“已登录用户创建项目”,同时观察真实 API 响应:
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
test('已登录用户可以创建项目', async ({ page }) => {
const projectName = `project-${randomUUID()}`;
await page.goto('/projects/new');
await expect(
page.getByRole('heading', { name: '创建项目' }),
).toBeVisible();
await page.getByLabel('项目名称').fill(projectName);
const responsePromise = page.waitForResponse((response) => {
return (
response.url().endsWith('/api/projects') &&
response.request().method() === 'POST'
);
});
await page.getByRole('button', { name: '创建' }).click();
const response = await responsePromise;
expect(response.status()).toBe(201);
await expect(page).toHaveURL(/\/projects\/[a-z0-9-]+$/);
await expect(
page.getByRole('heading', { name: projectName }),
).toBeVisible();
});
每一步的作用不同:
randomUUID()避免并行测试使用相同项目名称;goto打开真实用户路径;- 标题断言确认页面已进入正确状态;
fill使用标签定位输入框;waitForResponse先建立网络监听;click触发业务行为;response.status()验证服务端协议;- URL 断言验证路由变化;
- 标题断言验证 React 最终渲染结果。
如果只断言状态码,可能出现“后端创建成功,但前端跳转失败”的漏测;如果只断言标题,又可能通过一个错误的 mock 页面。两类断言结合,才能覆盖网络和用户可见行为。
十一、React 应用应提供稳定的可测试界面
测试稳定性不意味着给每个节点随意添加选择器,而是让产品界面具有明确语义。
例如表单:
<form onSubmit={handleSubmit}>
<label htmlFor="project-name">项目名称</label>
<input id="project-name" name="name" />
<button type="submit">创建</button>
{error ? <div role="alert">{error}</div> : null}
{status ? <div role="status">{status}</div> : null}
</form>
这样测试可以使用:
page.getByLabel('项目名称');
page.getByRole('button', { name: '创建' });
page.getByRole('alert');
page.getByRole('status');
如果视觉文本经常变化,可以使用 data-testid:
<span data-testid="project-status">{status}</span>
await expect(page.getByTestId('project-status')).toHaveText('完成');
data-testid 是显式测试接口,适合没有自然语义的复杂组件,但不应替代所有可访问性语义。若按钮没有可访问名称,测试用例和真实用户都更难判断它的用途。
十二、真实后端、Mock 和测试环境的取舍
端到端测试并不要求所有测试永远连接生产同构的真实后端,也不允许所有测试都只运行在 mock 上。可以按风险划分:
真实链路测试
覆盖:
浏览器 → React → API → 服务端 → 数据库
适合验证:
- 登录;
- 权限;
- 关键写入流程;
- 路由和服务端数据结构;
- 数据库迁移后的真实行为。
代价是速度较慢,对测试数据和环境要求高。
前端故障处理测试
使用 page.route() 模拟 401、500、延迟或异常 JSON,适合验证:
- 错误提示;
- 重试按钮;
- 空状态;
- 加载骨架屏;
- 断网或超时分支。
代价是无法发现真实 API 实现与前端之间的集成错误。
Mock 的边界
如果前端期待:
{ "items": [], "nextCursor": null }
而真实接口后来改成:
{ "data": [], "cursor": null }
所有使用旧 mock 的前端测试仍可能通过。解决方式不是取消 mock,而是:
- 关键路径保留真实接口测试;
- mock 数据尽量从共享 schema 或契约生成;
- 让 API 契约测试独立验证请求和响应结构;
- 不在每个测试中复制大量手写响应对象。
十三、客户端与服务端的错误处理边界
前端无法通过 Playwright 修复服务端错误,只能验证界面是否正确处理服务端结果。
例如:
const response = await page.request.get('/api/admin/report');
expect(response.status()).toBe(403);
这类断言验证 HTTP 层权限结果,但如果测试目标是浏览器行为,还应通过页面验证:
await page.goto('/admin/report');
await expect(page).toHaveURL(/\/forbidden$/);
await expect(
page.getByRole('heading', { name: '无权访问' }),
).toBeVisible();
需要区分三种错误:
- 网络错误:请求无法建立、被中止或超时;
- HTTP 错误:服务器返回 401、403、404、500;
- 应用错误:HTTP 200,但响应业务字段表示失败,或 React 解析数据时出错。
如果测试只等待 response.ok(),可能遗漏 HTTP 200 下的业务失败。相反,如果只观察页面文案,也可能遗漏接口返回错误但页面错误提示错误映射的问题。
十四、常见误区与对应修正
误区:把 waitForTimeout 当作同步机制
修正为等待 URL、Locator、响应或业务状态。固定等待只能用于非常特殊的调试场景,不应成为业务测试的同步手段。
误区:所有测试共用同一个账号并修改同一数据
浏览器 context 隔离不能隔离数据库。使用独立账号、唯一资源标识或测试前后清理,才能支持并行。
误区:登录态文件可以永久复用
Cookie 可能过期,服务端会话可能被撤销,测试账号密码也可能轮换。setup 失败时应首先检查认证接口和环境变量,而不是手动编辑状态文件。
误区:只看页面截图,不看网络
截图只能证明某一时刻的视觉结果。认证、重定向、接口状态和服务端错误通常需要结合 Trace 的网络记录判断。
误区:重试次数越多,稳定性越高
重试会降低偶发故障对流水线的影响,但也可能掩盖真实竞态。应统计首次失败与最终失败,而不是只看最终绿色结果。
误区:用实现细节定位 React 节点
依赖 .component-wrapper:nth-child(2)、内部 class 名或 React state 会使测试与重构强耦合。优先依赖角色、标签、可见文本和稳定测试标识。
十五、推荐的执行与验证流程
本地开发时可以从小范围开始:
# 运行单个测试文件
npx playwright test tests/dashboard.spec.ts
# 显示浏览器运行
npx playwright test --headed
# 调试单个测试
npx playwright test tests/dashboard.spec.ts --debug
# 使用单 worker 排查共享状态
npx playwright test --workers=1
# 失败后查看报告
npx playwright show-report playwright-report
CI 中通常需要:
- 安装依赖;
- 安装 Playwright 浏览器;
- 启动 React 应用及其服务端依赖;
- 注入测试账号和数据库配置;
- 执行测试;
- 即使测试失败,也上传
playwright-report和test-results; - 修复后重新运行失败用例,而不是只依赖全量重跑。
一个失败用例的恢复路径应包含:
- 确认应用版本和测试数据版本一致;
- 查看失败的 URL、截图、视频和 Trace;
- 确认请求是否发出、状态码是什么;
- 使用
--workers=1排除并行竞态; - 检查登录态是否过期;
- 确认失败是产品缺陷、测试缺陷还是环境故障;
- 修复根因后再决定是否调整超时或重试策略。
结语
Playwright 端到端测试的核心不是把测试步骤写得像用户操作,而是建立一条可解释的验证链:
用户动作
→ 浏览器状态变化
→ React 触发网络请求
→ 服务端返回结果
→ React 提交新的 UI
→ 页面可见状态满足断言
→ 失败时能通过 Trace、截图、视频和网络记录重建路径
登录态解决的是认证初始化,网络控制解决的是异步结果和故障分支,并行执行解决的是吞吐量,失败证据解决的是诊断成本。它们彼此相关但不能互相替代:
- 保存登录态不能解决数据库数据冲突;
- 等待响应不能证明用户看到了正确界面;
- 网络 mock 不能证明真实服务端契约正确;
- 增加重试不能修复共享状态;
- 截图不能替代网络和时间线证据。
当测试围绕用户可见行为组织,同时明确浏览器、React、服务端和数据库之间的边界,端到端测试才会从“偶尔通过的自动点击脚本”变成能够持续发现回归并支持定位的工程验证系统。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 组件集成测试:用户行为、异步 UI、Provider 和边界
- 下一篇:React 视觉回归:截图基线、字体、动画、阈值和审阅
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论