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 观察到可见结果

这条链路中至少有三类状态:

  1. 浏览器状态:Cookie、Local Storage、页面 URL、打开的页面和上下文。
  2. React 状态:加载中、成功、错误、用户信息、表单值等。
  3. 服务端状态:会话、数据库记录、权限以及异步任务。

测试断言的对象通常是浏览器可见行为,例如:

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 的会话认证:

  1. 浏览器提交用户名和密码;
  2. 服务端返回 Set-Cookie
  3. 浏览器保存 Cookie;
  4. 后续请求自动携带 Cookie;
  5. 应用通过 /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' });

串行模式会隐藏测试之间的共享状态问题,并且一个前置测试失败后,后续测试可能全部被跳过。只有当业务本身必须保持顺序、且无法改造为独立场景时,才使用串行模式。


五、网络:观察、等待、拦截和模拟必须区分

端到端测试中的“网络”有四种不同用途:

  1. 观察请求:确认页面发起了预期请求;
  2. 等待请求完成:让断言与真实响应建立因果关系;
  3. 拦截并修改请求或响应:测试特定边界条件;
  4. 完全模拟后端:让测试只验证前端行为。

这四种用途不能混为一谈。模拟网络后,测试就不再覆盖真实服务端实现。

等待网络与用户操作同时开始

如果点击按钮才触发请求,应使用 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 请求会带来两个风险:

  1. 测试失去对真实后端集成问题的检测能力;
  2. 模拟数据与真实接口逐渐分叉,测试长期通过但生产失败。

更合理的分层是:

  • 少量关键路径使用真实后端,验证完整链路;
  • 对稳定性差、难以制造或成本高的故障使用精确拦截;
  • 在 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 决定,而不是所有测试耗时相加。但“完全独立”必须包括外部资源。

一个测试可以并行运行的条件是:

RiWj=R_i \cap W_j = \varnothing

其中:

  • RiR_i 是测试 ii 读取的共享资源;
  • WjW_j 是测试 jj 写入的共享资源。

更严格地说,两个并行测试只要存在同一资源上的写写冲突,或者一个测试读取另一个测试正在写入的资源,就可能产生时序依赖。

例如:

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 和日志,因为重试可能只是改变了竞态时序。


八、失败证据:让一次失败能够被重放和定位

失败证据不是“失败后多截一张图”这么简单。一个可诊断的失败至少需要回答:

  1. 浏览器当时在哪个 URL?
  2. 页面上看到了什么?
  3. 之前执行了哪些操作?
  4. 发出了哪些请求,响应是什么?
  5. 元素是不存在、不可见、被遮挡,还是文本不匹配?
  6. 是首次失败,还是重试后失败?

推荐配置:

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

具体文件路径取决于测试名称和报告目录。打开后应沿时间线检查:

  1. 最后一次成功的用户操作;
  2. 失败断言前页面快照;
  3. 目标请求的 URL、方法和状态码;
  4. 控制台错误;
  5. 是否出现重定向或异常响应。

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 渲染错误

诊断顺序:

  1. 看 URL 是否仍为 /login
  2. 看登录响应状态码;
  3. 看是否发生 Set-Cookie 或其他认证状态写入;
  4. 看后续 /api/me 是否携带认证信息;
  5. 看 Trace 中是否有控制台错误;
  6. 再检查定位器和文本是否正确。

现象二:等待响应超时

可能是:

  • 点击没有触发表单提交;
  • 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();
});

每一步的作用不同:

  1. randomUUID() 避免并行测试使用相同项目名称;
  2. goto 打开真实用户路径;
  3. 标题断言确认页面已进入正确状态;
  4. fill 使用标签定位输入框;
  5. waitForResponse 先建立网络监听;
  6. click 触发业务行为;
  7. response.status() 验证服务端协议;
  8. URL 断言验证路由变化;
  9. 标题断言验证 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 中通常需要:

  1. 安装依赖;
  2. 安装 Playwright 浏览器;
  3. 启动 React 应用及其服务端依赖;
  4. 注入测试账号和数据库配置;
  5. 执行测试;
  6. 即使测试失败,也上传 playwright-reporttest-results
  7. 修复后重新运行失败用例,而不是只依赖全量重跑。

一个失败用例的恢复路径应包含:

  • 确认应用版本和测试数据版本一致;
  • 查看失败的 URL、截图、视频和 Trace;
  • 确认请求是否发出、状态码是什么;
  • 使用 --workers=1 排除并行竞态;
  • 检查登录态是否过期;
  • 确认失败是产品缺陷、测试缺陷还是环境故障;
  • 修复根因后再决定是否调整超时或重试策略。

结语

Playwright 端到端测试的核心不是把测试步骤写得像用户操作,而是建立一条可解释的验证链:

用户动作
  → 浏览器状态变化
  → React 触发网络请求
  → 服务端返回结果
  → React 提交新的 UI
  → 页面可见状态满足断言
  → 失败时能通过 Trace、截图、视频和网络记录重建路径

登录态解决的是认证初始化,网络控制解决的是异步结果和故障分支,并行执行解决的是吞吐量,失败证据解决的是诊断成本。它们彼此相关但不能互相替代:

  • 保存登录态不能解决数据库数据冲突;
  • 等待响应不能证明用户看到了正确界面;
  • 网络 mock 不能证明真实服务端契约正确;
  • 增加重试不能修复共享状态;
  • 截图不能替代网络和时间线证据。

当测试围绕用户可见行为组织,同时明确浏览器、React、服务端和数据库之间的边界,端到端测试才会从“偶尔通过的自动点击脚本”变成能够持续发现回归并支持定位的工程验证系统。


系列导航与关联阅读

官方资料

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