返回自动化工程模块
Automation / Tutorial 14

Playwright 自动化测试教程

把“浏览器能自动点击”升级为“核心业务可以稳定、独立、可诊断地持续回归”。

10 个章节商城下单 E2ETypeScript + CI
01

先确定哪些下单流程值得自动化

风险优先

你要守住的核心流程

用户登录商城,选择商品,填写地址,使用优惠券并提交订单。自动化测试需要确认页面操作成功、订单金额正确、订单只创建一次,而且失败时能留下足够证据。

从业务风险到可持续回归
选择场景高风险且重复
准备数据独立且可恢复
执行浏览器模拟真实用户
检查结果页面、接口和数据
保留证据报告与 trace

适合优先自动化

  • 每天或每次发布都要执行的主流程。
  • 金额、库存、订单状态等高风险规则。
  • 步骤稳定、结果可以明确判断的场景。
  • 人工执行耗时且容易遗漏的重复回归。

暂时不要急着自动化

  • 需求仍在频繁变化的临时页面。
  • 只执行一次且没有长期回归价值的场景。
  • 结果依赖主观视觉判断、尚无明确标准的功能。
  • 无法准备独立数据、执行后会污染生产的操作。
自动化不是把所有人工用例翻译成脚本。先覆盖高风险、重复执行且结果稳定的场景,脚本才会持续产生价值。
02

搭建一套能长期维护的项目结构

职责分开
每一层只承担一种职责
测试用例表达业务场景
页面对象封装页面操作
Fixture提供身份与上下文
数据层准备和清理数据
项目目录
tests/
  checkout.spec.ts       # 用例只表达业务场景
pages/
  checkout.page.ts       # 页面元素和操作
fixtures/
  authenticated.ts       # 登录状态与公共上下文
data/
  checkout.ts            # 商品、地址和优惠券数据
playwright.config.ts     # 浏览器、报告与失败证据

先写最小可运行用例

tests/checkout.spec.ts
import { test, expect } from '@playwright/test';

test('当库存充足且地址有效时,订单创建成功', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('购买数量').fill('1');
  await page.getByRole('button', { name: '提交订单' }).click();

  await expect(page.getByText('下单成功')).toBeVisible();
  await expect(page.getByTestId('order-id')).not.toBeEmpty();
});
03

用用户看得懂的方式定位元素

稳定优先
从上到下选择定位方式
01角色与标签

最接近用户语义

02稳定业务文案

确认唯一且不易变化

03测试标识

复杂组件的团队约定

04CSS / XPath

旧系统最后选择

定位方式的选择顺序

级别推荐方式适用位置判断依据
优先getByRole / getByLabel按钮、输入框和对话框接近用户和无障碍语义,页面改版后更稳定
其次getByText / getByPlaceholder稳定且唯一的业务文案可读性好,但需要确认多语言和文案变化
约定getByTestId没有稳定语义的复杂组件需要团队维护测试标识
谨慎CSS / XPath无法增加语义或测试标识的旧页面容易依赖 DOM 层级和样式类
稳定定位示例
await page.getByRole('button', { name: '提交订单' }).click();
await page.getByLabel('手机号').fill('13800138000');
await page.getByTestId('coupon-card').filter({ hasText: '满100减20' }).click();
不要用 .form > div:nth-child(3) button 记录页面结构。优先描述“用户要操作什么”,页面小幅改版后脚本才不容易失效。
04

用页面对象封装操作,不隐藏业务判断

页面与用例分层
业务用例调用页面能力,页面对象不决定业务是否通过
用例条件与预期
页面对象定位与操作
浏览器页面真实交互
断言业务结果
pages/checkout.page.ts
import { expect, type Page } from '@playwright/test';

export class CheckoutPage {
  constructor(private readonly page: Page) {}

  async open() {
    await this.page.goto('/checkout');
  }

  async fillAddress(phone: string) {
    await this.page.getByLabel('手机号').fill(phone);
  }

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

  async expectSuccess() {
    await expect(this.page.getByText('下单成功')).toBeVisible();
  }
}
页面对象负责“在哪里操作、怎样操作”;测试用例负责“为什么这样操作、应该得到什么业务结果”。不要把所有断言都塞进一个万能方法。
05

让每条用例拥有独立测试数据

可重复执行
每条用例拥有完整的数据生命周期
生成标识避免名称冲突
准备数据接口快速创建
执行用例独立完成场景
记录结果保存业务 ID
清理隔离恢复测试环境

执行前准备

  • 通过测试接口创建商品、库存和优惠券。
  • 使用唯一业务标识,避免并行用例相互覆盖。
  • 只准备当前用例真正需要的数据。
  • 不要依赖上一条用例执行成功。

执行后处理

  • 记录订单 ID,便于失败时定位。
  • 测试环境允许时清理临时数据。
  • 无法删除的业务数据使用专用租户或日期前缀隔离。
  • 禁止在生产环境运行创建、支付或删除类自动化。
使用唯一数据
const runId = Date.now() + '-' + testInfo.workerIndex;
const addressName = 'E2E-' + runId;

await request.post('/test-support/addresses', {
  data: { name: addressName, phone: '13800138000' }
});
06

等待业务结果,而不是等待固定秒数

自动重试断言
等待真正的业务条件成立
触发操作提交订单
等待响应创建接口完成
等待页面成功状态出现
轮询业务订单状态落稳
等待与断言
// 不推荐:页面快慢变化后容易偶发失败
await page.waitForTimeout(3000);

// 推荐:等待真正的业务结果出现
await expect(page.getByText('下单成功')).toBeVisible();
await expect(page.getByTestId('order-status')).toHaveText('待支付');
await expect.poll(async () => getOrder(orderId)).toMatchObject({ status: 'PENDING_PAYMENT' });

一条下单用例需要检查的结果

层级检查内容回答的问题
页面成功提示、按钮状态、订单号用户是否看到正确结果
接口响应状态、业务码、关键字段请求是否按契约完成
业务金额、优惠、库存和订单状态核心规则是否正确
数据订单只创建一次,库存只扣减一次跨层结果是否保持一致
07

让页面操作和接口结果互相印证

UI 不等于全部
一次 E2E 验证连接三个观察面
页面操作用户提交订单
接口响应获得订单 ID
页面结果展示相同订单
业务数据金额与库存正确
监听创建订单响应
const createOrderResponse = page.waitForResponse((response) =>
  response.url().includes('/api/orders') && response.request().method() === 'POST'
);

await checkout.submit();
const response = await createOrderResponse;
const body = await response.json();

expect(response.status()).toBe(200);
expect(body.orderId).toBeTruthy();
await expect(page.getByTestId('order-id')).toHaveText(body.orderId);

什么时候拦截接口

  • 第三方服务不稳定且不是本次验证目标。
  • 需要稳定复现超时、500 或特殊响应。
  • 需要验证前端在异常响应下的提示与恢复。
  • 不能用 Mock 代替所有真实集成回归。
08

复用登录状态,同时保持用例隔离

Fixture
共享稳定上下文,隔离可变业务数据
登录状态可复用
浏览器上下文每条用例独立
订单数据每条用例新建
执行结束保存或清理
复用已登录上下文
import { test as base } from '@playwright/test';

export const test = base.extend({
  storageState: 'playwright/.auth/user.json',
});

test.beforeEach(async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('link', { name: '我的订单' })).toBeVisible();
});
可以复用登录状态,但不要让不同用例共享购物车、订单或可变库存。身份可以复用,业务数据应尽量独立。
09

让失败结果能够快速定位

证据链
失败报告至少留下五类证据
Trace

操作、DOM 与网络

截图

失败瞬间页面

视频

完整操作过程

请求日志

接口输入与响应

业务 ID

追踪后端数据

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL,
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  reporter: [['html', { open: 'never' }]],
  retries: process.env.CI ? 2 : 0,
});

先判断失败属于哪一类

  • 产品缺陷:实际业务结果与预期不一致。
  • 脚本缺陷:定位、等待或数据准备不正确。
  • 环境问题:服务、网络或依赖不可用。
  • 偶发问题:需要用 trace 和日志找到真实触发条件。

重试不能掩盖问题

  • 重试通过仍要记录首次失败原因。
  • 持续偶发的用例需要修复,而不是无限增加重试。
  • 报告中保留 trace、截图、视频、请求和业务 ID。
  • 只有确认环境抖动时才把用例标记为不稳定。
10

把稳定用例放进持续集成

分层回归

按反馈速度组织自动化任务

触发时机执行范围目标时长通过标准
提交前冒烟用例1~3 分钟核心页面可以打开,主流程没有立即阻塞
合并请求核心回归5~15 分钟P0/P1 场景、接口依赖和主要浏览器通过
定时任务完整回归按项目规模更广的浏览器、数据组合和低频场景
CI 命令
npx playwright test --project=chromium --grep @smoke
npx playwright test --project=chromium
npx playwright show-report

练习:完成下单 E2E 项目

  1. 选择 3 条 P0 下单用例,说明为什么值得自动化。
  2. 创建 CheckoutPage,只封装页面元素和重复操作。
  3. 通过接口准备独立商品、库存、地址和优惠券。
  4. 用语义定位完成下单,不使用依赖层级的 XPath。
  5. 同时断言页面结果、创建订单响应和关键业务数据。
  6. 复现接口超时,验证用户可以安全重试且不会重复下单。
  7. 开启 trace、截图和 HTML 报告,故意制造失败并完成一次定位。
  8. 把冒烟用例接入 CI,并设置明确的运行环境和失败退出条件。

脚本稳定

  • 定位语义清楚
  • 没有固定等待
  • 数据彼此隔离
  • 重复执行结果一致

结果可信

  • 关键业务有断言
  • 接口与页面一致
  • 失败保留证据
  • 重试不掩盖缺陷

可以持续运行

  • 用例按标签分层
  • 环境参数外置
  • CI 失败会阻断
  • 报告可以追溯

完成页面自动化后,继续把冒烟、回归、报告和失败治理接入持续测试流水线。

继续学习持续测试与 CI/CD