Dev Guide / 2026 ・ No.07

Playwright 入门与实战

诞生背景与原理、报销页面的测试,以及如何与 AI 配合

发布

写好测试,浏览器就会自己去验证

expense.spec.ts test('合計が出る', ... getByLabel('金額') .fill('14720') getByRole('button', {name:'申請する'}) .click() expect(total) .toHaveText(..) 人写的测试 Playwright 操作浏览器 Chromium Firefox WebKit 测试写一次,就能在三种浏览器上自动执行同样的操作和检查 把人工点击、肉眼确认的工作写成代码,随时重放

打开页面、输入、点击按钮、用眼睛确认显示。每次发布都要人工重复的这些检查,
写成代码、随时可以重放——这就是 Playwright。本文先用图梳理 Playwright 的诞生背景和工作原理,
再以报销页面为例,实际编写并运行测试。后半部分介绍为了用好 Playwright,从设计和开发阶段就该准备的事情,
以及让 AI 写测试时如何让它遵守规则、已有的实例和未来展望。

一

Playwright 是什么

Playwright 是微软开发的开源浏览器自动化与测试工具(许可证为 Apache 2.0)。它从程序里启动浏览器,打开页面、输入、点击、检查显示内容,把人工操作用代码精确地再现出来。

  • 支持三种浏览器引擎 ── Chromium(Chrome・Edge)、Firefox、WebKit(Safari),可在 Windows・macOS・Linux 上运行
  • 四种语言都能写 ── TypeScript/JavaScript、Python、Java、.NET(C#)。本文用 TypeScript
  • 测试工具一应俱全 ── 测试运行器(Playwright Test)、根据录制操作生成代码的 codegen、重放失败过程的追踪查看器、HTML 报告

它的主战场是 E2E(端到端)测试:从页面到数据库,把整个系统串起来,验证「按用户的操作,系统是否如预期运行」。测试分好几层,常被比作「金字塔」。

测试金字塔与 Playwright 的位置

E2E 集成・API 单元测试 在页面上申请→审批 Playwright 的主战场 申请 API 的输入检查 直接调用 API 来验证 计算合计的函数 数量多、瞬间完成 越往上越接近真实、越慢 E2E 用「和用户一样的操作」做最后把关,数量要精简,放在关键处 细碎的计算组合交给下层,上层看各部分的衔接和重要流程

E2E 越接近真实就越耗时。细碎规则的组合交给单元测试,E2E 只看「重要的流程能否走通」

CORE

Playwright 是把「人在页面上做的检查」变得快速、可重复、每次都一样的工具。但验证什么要由人来决定。好的测试来自好的需求和好的界面设计,这正是本文后半部分的主题。

二

诞生背景:Selenium、Puppeteer 与 Playwright

让浏览器自动运行的尝试已经有二十多年。第一个大潮流是 Selenium。据 Selenium 官网介绍,它始于 2004 年芝加哥的 ThoughtWorks 公司,杰森・哈金斯(Jason Huggins)为了测试公司内部的「考勤与报销」应用(Time and Expenses)而做了这个工具。本文以报销为题材,也算一段小小的缘分。后来 Selenium 与 WebDriver 合并,从外部操作浏览器的方法成为了 W3C 标准(WebDriver)。

2017 年,Google 的 Chrome DevTools 团队发布了 Puppeteer。它用 Chrome 开发者工具所用的通信方式(CDP)直接操作 Chrome,速度快、控制精细。但最初只支持 Chrome(Chromium),而真实用户也在用 Safari 和 Firefox。

于是,打造 Puppeteer 的核心成员转到微软,在 2020 年 1 月发布了 Playwright。当时 InfoQ 的报道引用了团队的话:「我们就是当初在 Google 打造 Puppeteer 的那个团队。」目标有两个:用一套 API 驱动 Chromium、Firefox、WebKit,以及提供更适合测试的 API。曾任 Puppeteer 技术负责人的安德烈・卢什尼科夫(Andrey Lushnikov)也以 Playwright 核心团队成员的身份出现在公开介绍中。

通往 Playwright 之路,以及之后

2004 Selenium 诞生(源自内部报销应用的测试) 2017 Puppeteer(Google・最初只支持 Chrome) 2020.01 Playwright 发布(微软・原 Puppeteer 团队) 2020.05 1.0 版 2021.06 Playwright Test 与追踪查看器(1.12) 2022.10 getByRole 等定位器(1.27) 2023.03 UI 模式(1.32) 2025.03 Playwright MCP(让 AI 操作浏览器) 2025.10 测试智能体:规划・生成・修复(1.56) 2026.07 MCP 服务器与 CLI 内置于本体(1.62) 2026.10 1.64(支持 WebMCP)── 撰写时最新 空心圆为前史。括号内为 Playwright 版本号(日期为 npm 发布日)

2021 年加入测试运行器,成为完整的「测试工具」;2025 年起,与 AI 连接的功能迅速增多

此后普及得很快。在技术咨询公司 Thoughtworks 的「技术雷达」中,它在 2020 年 10 月处于 Assess(评估),2021 年 4 月升到 Trial(试用),2023 年 9 月进入 Adopt(推荐采用)。npm 上的 @playwright/test 截至 2026 年 10 月每周下载约 7,600 万次(2026 年 10 月 6~12 日;其中包括 CI 和 AI 智能体的自动安装,并不等于用户数)。撰写本文时的最新版本是 1.64.1(2026 年 10 月 14 日),几乎每月都有新版本。

三

原理①:测试与浏览器如何连接

Playwright 由三层组成:写测试所用语言的库、把命令变成实际浏览器操作的 Playwright 本体(服务器),以及浏览器。

命令是怎样送达的

① 写测试的语言 ② Playwright ③ 与浏览器对话 TypeScript / JS Python Java .NET Playwright 查找元素(定位器) 等到准备就绪 记录操作(追踪) 同一进程/WebSocket Chromium CDP Firefox Juggler WebKit 专用 通过管道直连 WebDriver(Selenium) 每条命令都用 HTTP 询问并等待回复 Playwright 保持连接发送命令,浏览器的事件也会推送过来 Firefox、WebKit 使用打过补丁的版本(npx playwright install)

用 TypeScript 时,本体与测试运行在同一进程;Python・Java・.NET 则通过 WebSocket 连接 Node.js 的本体

  • 为每种浏览器配备「翻译」 ── 与 Chromium 用 CDP,与 Firefox 用名为「Juggler」的专用通信,与 WebKit 也用专用通信。Playwright 的源码里就包含 Firefox 用的 Juggler 和 WebKit 用的补丁
  • 通过「管道」与浏览器直连 ── 与启动的浏览器之间用管道(进程间通道)连接,不仅能发送命令,页面加载、画面变化等事件也会从浏览器那边推送过来
  • 使用打过补丁的浏览器 ── Firefox 和 WebKit 用 npx playwright install 安装补充了自动化所需功能的 Playwright 专用版本。Chromium 也可以直接使用 Chrome 或 Edge

传统的 WebDriver 每条命令都通过 HTTP 询问并等待回复。Playwright 则在一条保持连接的通道上双向通信,能立刻得知浏览器状态的变化。这正是下面要讲的「自动等待」的基础。

四

原理②:浏览器上下文

Playwright 的一项重要发明是浏览器上下文。在一个浏览器里,可以创建任意多个 Cookie 和存储都彼此独立的「无痕窗口」。用官方文档的话说,它们「创建起来又快又省」。

让不同的「人」共处于一个浏览器

浏览器(只启动一个) 如 Chromium 上下文 A 以田中(申请人)身份登录 申请页 列表 Cookie・存储互不相通 上下文 B 以佐藤(审批人)身份登录 待审批 Cookie・存储互不相通 上下文就像「全新的无痕窗口」,创建只需一瞬间 每个测试都用新的上下文,不会被上一个测试的状态影响

在报销场景里,可以在一个测试中再现申请人和审批人同时登录的情形(第十章)

Playwright Test 会为每个测试准备新的上下文。不会沿用上一个测试的登录状态和输入内容,所以测试并行运行、调换顺序,结果都不会变,也能避免「前一个测试失败,后面全部跟着失败」的连锁反应。

五

原理③:自动等待

页面自动化测试最常见的麻烦是「等待」:按钮还没出现就去点击,于是失败。为了避免而写上「等 2 秒」,页面快时白等,慢时照样失败。Playwright 用自动等待解决了这个问题。

不写「等几秒」

0.0秒 0.5秒 1.0秒 1.5秒 2.0秒 页面准备就绪 固定等待传统写法 waitForTimeout(2000) 白等(页面慢时失败) 自动等待Playwright 立即点击 点击前 Playwright 会检查 1 唯一确定 2 可见 3 静止 4 未被遮挡 5 可用 不写「等几秒」。准备好了就马上动 expect(…).toHaveText() 也一样,会反复检查直到符合预期(最多 5 秒)

检查项目因操作而异(输入时还会检查「是否可编辑」)。条件在时限内仍未满足,就以 TimeoutError 失败

验证(断言)也是同样的思路。写 expect(合计).toHaveText('3,000円'),Playwright 会反复检查,直到显示变成「3,000円」。这叫 Web 优先断言。默认时限是每个断言 5 秒、每个测试 30 秒(本文的测试在失败时的输出里也确认了这两个值)。

六

原理④:查找元素的方法与工具

指定要操作页面上哪个部件的,就是定位器。Playwright 官方指南建议用用户看得到的线索来查找。

定位器的推荐顺序

推荐顺序 对变更的耐受度 1 getByRole('button', { name: '申請する' }) 按用户看得到的「角色」和「名称」查找(最推荐) 2 getByLabel('金額') 输入框按标签查找,与读屏软件读出的一致 3 getByText('申請しました') 按显示的文字查找,改文案时要同步修改 4 getByTestId('total-amount') 开发与测试约定的标记(data-testid) 5 locator('#form > div:nth-child(3) button') 依赖页面结构(嵌套、顺序),稍有变动就会失效 用人眼看页面时的线索来查找,测试就不容易坏 用 1~2 找不到的页面,应先改页面本身(标签、按钮名)

getByRole 的「角色」指按钮、输入框、表格、标题等,也会传达给读屏软件的部件类型

定位器还有严格模式:要操作的目标匹配到两个以上元素时,会以「不知道是哪个」报错,防止随手点了第一个、碰巧通过。本文的测试也两次得益于它(第十一章)。

编写和修改测试的工具也很齐全。

工具命令・位置用途
codegennpx playwright codegen在浏览器里操作,就生成相应的测试代码,定位器也按上面的推荐顺序选择
UI 模式npx playwright test --ui测试列表、运行、回溯时间查看页面、保存时自动重跑
追踪查看器npx playwright show-trace逐个操作重放前后的页面、网络和控制台,用于排查 CI 上失败的测试
HTML 报告npx playwright show-report结果列表,以及失败时的截图和追踪
VS Code 扩展Playwright Test for VS Code在编辑器里运行、调试,挑选定位器
七

实战:以报销页面为例

下面实际编写并运行测试。题材是很多公司都有的报销。为了讲解,笔者做了一个小型演示应用(Node.js,无外部库,界面为日文):申请人登录后填写明细,审批人批准。

为本文制作的报销演示页面(界面为日文)。有标题、日期・类别・内容・金额・币种组成的 4 行明细(交通费 14,720 日元、住宿费 12,800 日元、会议费 3,300 日元、云服务费 20 美元=3,000 日元),合计 33,820 日元,收据上传栏,「合计 3 万日元以上时请附上收据」的错误提示,申请按钮,下方是申请状态列表
作为测试素材的报销页面。明细合计、美元折算日元、3 万日元以上需附收据等待验证的规则都在这里。这是笔者在写测试之前做的演示应用(笔者在测试环境中截图。Playwright 1.64.1,2026 年 10 月 16 日)

这个页面有以下规则。写测试之前,先从把规则列成表开始。

#规则(验收条件)验证方法
R1输入明细金额后,自动计算合计输入 2 行,检查合计显示
R2美元明细按汇率折算成日元把汇率 API 换成固定值(1 美元=150 日元)后检查
R3合计 3 万日元以上时,没有收据就不能提交无收据时报错,附上后可以通过
R4不能提交未来日期用明天的日期,确认报错
R5审批人批准后,申请人的列表显示「已批准」同时操作申请人和审批人两个人

R1~R4 是单个页面内的规则,R5 是跨两位用户的流程。另外,像 R3 的边界(29,999 日元和 30,000 日元)这类细致组合,本应由单元测试或 API 测试覆盖,E2E 只看代表性的用例更高效。

八

搭建测试套件

安装 Playwright 只需一行。运行 npm init playwright@latest 就会生成配置文件和示例(这次用 npm i -D @playwright/test 安装,文件是手写的)。测试套件的结构如下。

文件的分工

playwright.config.ts 整体配置 报销应用 以测试模式启动 webServer auth.setup.ts 初始化+登录 最先执行一次 .auth/*.json 保存登录状态 以已登录状态开始 测试主体 expense.spec.ts approval.spec.ts fixtures.ts 模拟与准备 pages/expense-page.ts 汇总页面操作 「验证什么」写在测试主体,「怎么操作」写在 Page Object 登录只在最开始做一次,各测试从保存的状态开始,所以很快

首先是配置。测试前启动应用(webServer)、先运行登录准备(setup)等都写在这里。

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,                       // 测试并行运行
  retries: process.env.CI ? 2 : 0,           // 在 CI 上失败时最多重跑 2 次
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:3000',
    locale: 'ja-JP',                         // 日期和数字按日本格式显示
    timezoneId: 'Asia/Tokyo',
    trace: 'on-first-retry',                 // 只在重跑时保留追踪
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    { name: 'chromium', use: { ...devices['Desktop Chrome'] }, dependencies: ['setup'] },
    // Firefox、Safari(WebKit)也能照样一行一行加上
  ],
  webServer: {                               // 测试前先启动应用
    command: 'node app/server.js',
    env: { APP_ENV: 'test' },
    url: 'http://localhost:3000/login',
    reuseExistingServer: !process.env.CI,
  },
});

接着是最先运行一次的准备工作:初始化数据,分别以申请人和审批人登录,把登录状态(Cookie 等)作为 storageState 保存到文件。各测试只要读取这个文件,就能从已登录状态开始,比每次都操作登录页快得多,也稳定得多。

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

// 测试账号(只存在于测试环境)
const accounts = [
  { id: 'tanaka', password: 'pass-tanaka', file: '.auth/applicant.json', home: '/' },        // 申请人
  { id: 'sato',   password: 'pass-sato',   file: '.auth/approver.json',  home: '/approve' }, // 审批人
];

setup('テストデータを初期化する', async ({ request }) => {
  const res = await request.post('/api/test/reset');   // 测试专用 API
  expect(res.ok()).toBeTruthy();
});

for (const a of accounts) {
  setup(`${a.id} でログインしておく`, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('社員ID').fill(a.id);
    await page.getByLabel('パスワード').fill(a.password);
    await page.getByRole('button', { name: 'ログイン' }).click();
    await expect(page).toHaveURL(a.home);
    await page.context().storageState({ path: a.file });   // 把登录状态保存到文件
  });
}
九

Page Object 与测试主体

页面的操作方法集中写在一个叫 Page Object 的类里。「金额栏按标签『金額』查找」「申请按钮按名称『申請する』查找」这类知识都放在这里,页面变化时只需改一处。

// tests/pages/expense-page.ts
import { type Page, type Locator } from '@playwright/test';

type Line = { date: string; kind: string; memo: string; amount: number; currency?: 'JPY' | 'USD' };

// 把申请页的「操作方法」集中在这里(Page Object)
export class ExpensePage {
  readonly lines: Locator;
  readonly total: Locator;
  readonly errors: Locator;
  readonly done: Locator;

  constructor(readonly page: Page) {
    this.lines  = page.getByTestId('expense-line');
    this.total  = page.getByTestId('total-amount');
    this.errors = page.getByRole('alert');
    this.done   = page.getByRole('status', { name: '申請結果' });
  }

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

  async fillLine(i: number, l: Line) {
    if (await this.lines.count() <= i)
      await this.page.getByRole('button', { name: '明細を追加' }).click();
    const row = this.lines.nth(i);
    await row.getByLabel('日付').fill(l.date);
    await row.getByLabel('区分').selectOption(l.kind);
    await row.getByLabel('内容').fill(l.memo);
    await row.getByLabel('金額').fill(String(l.amount));
    if (l.currency) await row.getByLabel('通貨').selectOption(l.currency);
  }

  async attachReceipt(name = 'receipt.pdf') {
    await this.page.getByLabel(/領収書/).setInputFiles({
      name, mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4 dummy'),
    });
  }

  async submit() {
    await this.page.getByRole('button', { name: '申請する' }).click();
  }
}

每一行明细都加了 data-testid="expense-line" 标记,因为行本身没有用户看得到的名称。只有在无法用可见线索查找的地方,才使用与开发者约定的标记——这就是定位器推荐顺序的用法。

然后是夹具(测试的准备工作)。在这里把外部汇率 API 换成模拟。真实汇率每天都在变,否则就没法写「应该是 3,000 日元」。用 page.route() 可以在中途截住浏览器发出的请求,返回固定的答案。

// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { ExpensePage } from './pages/expense-page';

export const test = base.extend<{ expensePage: ExpensePage }>({
  expensePage: async ({ page }, use) => {
    // 把外部汇率 API 换成总是返回同一个值的模拟
    await page.route('https://api.rates.example/**', route =>
      route.fulfill({ json: { base: 'USD', rates: { JPY: 150 } } }));
    const expensePage = new ExpensePage(page);
    await expensePage.goto();
    await use(expensePage);
  },
});
export { expect };

接下来是测试主体。规则表的 R1~R4 直接变成了 4 个测试。

// tests/expense.spec.ts
import { test, expect } from './fixtures';

test.use({ storageState: '.auth/applicant.json' });   // 以申请人的登录状态开始

test('明細の合計が自動で計算される', async ({ expensePage }) => {
  await expensePage.fillLine(0, { date: '2026-10-14', kind: '交通費', memo: '新幹線 東京→新大阪', amount: 14720 });
  await expensePage.fillLine(1, { date: '2026-10-14', kind: '会議費', memo: '打ち合わせ(3名)', amount: 3300 });
  await expect(expensePage.total).toHaveText('18,020円');
});

test('ドル建ての明細は為替レートで円に換算される', async ({ expensePage }) => {
  await expensePage.fillLine(0, { date: '2026-10-14', kind: 'その他', memo: 'クラウド利用料', amount: 20, currency: 'USD' });
  await expect(expensePage.total).toHaveText('3,000円');            // 20 美元 × 150 日元
});

test('3万円以上は領収書がないと申請できない', async ({ expensePage }) => {
  await expensePage.fillLine(0, { date: '2026-10-14', kind: '宿泊費', memo: 'ホテル 2泊', amount: 32000 });
  await expensePage.submit();
  await expect(expensePage.errors).toContainText('領収書を添付してください');

  await expensePage.attachReceipt();
  await expensePage.submit();
  await expect(expensePage.done).toContainText('申請しました');
});

test('未来の日付は申請できない', async ({ expensePage }) => {
  const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
  await expensePage.fillLine(0, { date: tomorrow, kind: '交通費', memo: 'タクシー', amount: 2400 });
  await expensePage.submit();
  await expect(expensePage.errors).toContainText('未来の日付は申請できません');
});

请看第 2 个测试。把币种切换成 USD 后,页面会先查询汇率 API,再改写合计。但测试里并没有「等待 API 响应」这一行,因为 toHaveText('3,000円') 会一直自动检查,直到合计变成 3,000 日元。

十

从申请到审批:两位角色

R5 中有申请人(田中)和审批人(佐藤)两个人。这时浏览器上下文就派上用场了:在一个浏览器里创建两人的上下文,分别以各自的登录状态打开页面。

一个测试里,两个人轮流操作

田中 上下文 A 报销应用 测试模式 佐藤 上下文 B 1 提交申请 2 已提交(No.0003) 3 批准 No.0003 4 已批准 5 打开申请列表 6 No.0003:已批准 编号(No.0003)从页面读取,再用这个编号查找对应行 若按标题或「第 1 行」查找,会和并行运行的其他测试数据冲突
// tests/approval.spec.ts
import { test, expect } from '@playwright/test';
import { ExpensePage } from './pages/expense-page';

test('申請して承認されると「承認済み」になる', async ({ browser }) => {
  // 在一个浏览器里,为申请人和审批人各建一个「上下文」
  const applicant = await browser.newContext({ storageState: '.auth/applicant.json' });
  const approver  = await browser.newContext({ storageState: '.auth/approver.json' });
  let no = '';                                              // 例如 "No.0003"

  await test.step('田中さんが申請する', async () => {
    const p = new ExpensePage(await applicant.newPage());
    await p.goto();
    await p.page.getByLabel('件名').fill('大阪出張');
    await p.fillLine(0, { date: '2026-10-14', kind: '交通費', memo: '新幹線 往復', amount: 29440 });
    await p.submit();
    await expect(p.done).toContainText('申請しました');
    no = (await p.done.textContent())!.match(/No\.\d{4}/)![0];   // 之后用分配到的编号查找
  });

  await test.step('佐藤さんが承認する', async () => {
    const page = await approver.newPage();
    await page.goto('/approve');
    const row = page.getByRole('row').filter({ hasText: no });
    await row.getByRole('button', { name: '承認' }).click();
    await expect(row.getByText('承認済み')).toBeVisible();
  });

  await test.step('田中さんの画面でも「承認済み」になっている', async () => {
    const page = await applicant.newPage();
    await page.goto('/');
    const row = page.getByRole('table', { name: '申請一覧' }).getByRole('row').filter({ hasText: no });
    await expect(row).toContainText('承認済み');
  });

  await applicant.close();
  await approver.close();
});

用 test.step() 分段后,报告和追踪里会出现「田中さんが申請する」这样的小标题,在哪一步失败一目了然。

十一

运行结果与踩过的坑

运行只需 npx playwright test。从启动应用、登录准备到 8 个测试,全部自动完成。

$ npx playwright test
Running 8 tests using 1 worker

  ✓  1 [setup] › tests/auth.setup.ts:9:6 › テストデータを初期化する (44ms)
  ✓  2 [setup] › tests/auth.setup.ts:15:8 › tanaka でログインしておく (606ms)
  ✓  3 [setup] › tests/auth.setup.ts:15:8 › sato でログインしておく (472ms)
  ✓  4 [chromium] › tests/approval.spec.ts:4:5 › 申請して承認されると「承認済み」になる (1.2s)
  ✓  5 [chromium] › tests/expense.spec.ts:5:5 › 明細の合計が自動で計算される (600ms)
  ✓  6 [chromium] › tests/expense.spec.ts:11:5 › ドル建ての明細は為替レートで円に換算される (435ms)
  ✓  7 [chromium] › tests/expense.spec.ts:16:5 › 3万円以上は領収書がないと申請できない (536ms)
  ✓  8 [chromium] › tests/expense.spec.ts:26:5 › 未来の日付は申請できない (461ms)

  8 passed (6.3s)
Playwright 的 HTML 报告。auth.setup.ts 的 3 项(初始化测试数据、tanaka 和 sato 登录)、approval.spec.ts 的 1 项、expense.spec.ts 的 4 项全部打着绿色对勾,显示 Passed 8、Failed 0、总计 8.1 秒
运行测试后的 HTML 报告,8 项全部通过。点击每项的「View Trace」即可跳到下面的追踪查看器(笔者在测试环境中截图。Playwright 1.64.1,2026 年 10 月 16 日)
Playwright 的追踪查看器。上方是时间轴和页面缩略图,左侧是「田中提交申请」「佐藤审批」等步骤,中间是待审批列表页面的快照,点击批准按钮的位置有一个红点。下方显示定位器 getByRole row filter No.0001 getByRole button 承認
从申请到审批这条测试的追踪。在左侧选中「Click」,就能看到那一刻的页面、点击的位置(红点)和所用的定位器。失败的测试也能这样事后重放,看清在哪里发生了什么(笔者在测试环境中截图。Playwright 1.64.1,2026 年 10 月 16 日)

不过,并不是一开始就全部顺利。我们两次被严格模式拦了下来。

  • 第 1 次:找到两个「已提交」的显示 ── 用 getByRole('status') 查找完成提示时,用于显示合计金额的 <output> 元素按 HTML 规范也具有同样的「status」角色。于是给提示加上 aria-label="申請結果" 这个名称,用 getByRole('status', { name: '申請結果' }) 区分开
  • 第 2 次:并行运行时出现两行同名记录 ── 起初在标题里加上唯一字符串来查找行,但在把同一测试并行重复 5 次的压力测试(--workers 4 --repeat-each 5)下,这个字符串在重复之间是相同的,结果找到两行。改为从页面读取应用分配的编号(如 No.0003),再用这个编号查找后,28 项全部通过

如果没有严格模式,两者都会成为「碰巧第一个就对了所以通过」的测试。靠运气的测试,总有一天会莫名其妙地失败(也就是所谓的「不稳定测试」)。

我们还故意让测试失败了一次:把按钮名称从「申請する」(申请)改为「提出する」(提交),测试会这样失败。

  1) [chromium] › tests/expense.spec.ts:26:5 › 未来の日付は申請できない ────────────────────────────────────────
    Test timeout of 30000ms exceeded.
    Error: locator.click: Test timeout of 30000ms exceeded.
    Call log:
      - waiting for getByRole('button', { name: '申請する' })
       at pages/expense-page.ts:41
      39 |
      40 |   async submit() {
    > 41 |     await this.page.getByRole('button', { name: '申請する' }).click();
         |                                                           ^
      42 |   }
      43 | }
      44 |

关键在于:这次失败究竟是「测试过时了」还是「页面出了缺陷」,Playwright 无从判断。如果改按钮名符合需求就改测试,如果是意外改动就把页面改回来。这个判断,到了后面要讲的 AI 时代,依然是人的工作。

十二

设计阶段就该准备的事

前面的测试之所以能写得简短又稳定,其实是因为应用一开始就具备了便于测试的结构。能否发挥 Playwright 的力量,在写测试之前,也就是设计和开发阶段,基本就决定了。

可测试性要「提前」做进去

1 需求・设计 ・用「可验证的句子」写验收条件 ・列出测试要点 ・确定测试环境和测试账号 2 界面设计 ・输入框加标签,按钮名称明确 ・统一错误显示方式(role=alert) ・确定 test ID 的命名规则 3 开发 ・准备初始化・写入数据的接口 ・让外部服务可以替换 ・日期时间・编号可由测试控制 4 CI・运维 ・每个合并请求自动运行 ・失败时保留追踪和报告 ・不稳定的测试及时修好 可测试性与其事后补,不如一开始就设计进去,成本更低 1 和 2 需要产品与设计人员参与。测试不是开发的最后一道工序
要准备的事为什么报销中的例子
用可验证的句子写验收条件「好用」没法测试,「3 万日元以上必须附收据」可以第七章规则表 R1~R5
输入框加标签,按钮起名称能用 getByRole・getByLabel 查找,也有助于读屏(无障碍)「金額」「申請する」
统一错误和完成提示的显示方式任何页面都能用同样的定位器验证错误用 role="alert",完成用 role="status"
test ID 的命名规则给没有可见线索的部件做标记。命名杂乱就找不到expense-line、total-amount(kebab-case)
初始化・写入测试数据的接口每次都能从相同状态开始,比从页面造数据快POST /api/test/reset(仅在测试模式下有效)
外部服务可替换的结构能固定汇率、支付、邮件等每次结果都会变的东西用 page.route() 模拟汇率 API
测试账号与权限用于验证各角色的操作,不使用生产账号申请人 tanaka、审批人 sato
日期时间・编号可控让依赖「今天」或流水号的测试稳定把分配的 No. 显示在页面上。也可以用 Playwright 的 Clock API 固定时间

例如,数据初始化的接口就这么几行。只在以测试模式启动时有效,生产环境中根本不存在。

// app/server.js(节选)
// 测试专用:把数据恢复到初始状态(生产环境中无效)
if (TEST_MODE && req.method === 'POST' && url.pathname === '/api/test/reset') {
  db = { seq: 0, expenses: [] };
  return send(res, 200, { ok: true });
}
POINT

可测试性是产品、设计、UI 设计人员都参与其中的质量。在界面设计文档里加一栏「各输入框的标签」「错误的显示方式」「test ID」,后续测试的工作量就会大不相同。而这些准备,在让 AI 写测试时也同样有效(第十四章)。

十三

开发阶段与 CI 的准备

测试只写出来,价值只有一半。每次变更都自动运行,才能防止「不知什么时候坏掉了」。

每个合并请求,都做同样的检查

推送 创建 PR 准备 启动应用 运行测试 shard 1/2 shard 2/2 汇总 报告 可合并 全部通过 设为必需检查 失败 → 用追踪查原因再修 无需人工介入,每次变更都执行同样的检查,这才是自动测试真正的价值 只安装需要的浏览器(如 chromium),用 --shard 拆分测试以缩短时间

用 GitHub Actions 的话,可以这样写(根据官方文档的示例,按本文的结构做了精简)。

# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - run: npx playwright install chromium --with-deps   # 只安装要用的浏览器
      - run: npx playwright test
        env:
          CI: true                                  # 启用重跑 2 次和追踪
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}                     # 失败也保留结果
        with:
          name: playwright-report
          path: playwright-report/

另一项准备是用机器检查写法规则。借助 ESLint 插件 eslint-plugin-playwright 和 TypeScript 的规则,可以在运行之前发现「用 CSS 选择器查找」「等待 2 秒」「忘写 await」等容易出问题的写法。

// eslint.config.mjs
import tseslint from 'typescript-eslint';
import playwright from 'eslint-plugin-playwright';

export default tseslint.config({
  files: ['tests/**/*.ts'],
  extends: [playwright.configs['flat/recommended']],
  languageOptions: { parser: tseslint.parser, parserOptions: { projectService: true } },
  plugins: { '@typescript-eslint': tseslint.plugin },
  rules: {
    '@typescript-eslint/no-floating-promises': 'error',   // 忘写 await
    'playwright/no-raw-locators': 'error',                // 不用 CSS・XPath 查找
    'playwright/no-wait-for-timeout': 'error',            // 禁止「先等 2 秒再说」
    'playwright/prefer-web-first-assertions': 'error',
  },
});

我们写了下面这样的「反面例子」,实际跑了一遍检查。

// AI 写出来的「反面例子」
import { test, expect } from '@playwright/test';

test('合計が出る(悪い例)', async ({ page }) => {
  await page.goto('/');
  await page.locator('#lines tr:nth-child(1) td:nth-child(4) input').fill('14720');
  page.locator('button.primary').click();
  await page.waitForTimeout(2000);
  expect(await page.locator('#total').isVisible()).toBe(true);
});
$ npx eslint tests
tests/bad/bad.spec.ts
  5:9   error  Usage of raw locator detected. Use methods like .getByRole() or .getByText() instead of raw locators  playwright/no-raw-locators
  6:3   error  Promises must be awaited, end with a call to .catch, ... or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises
  6:3   error  Usage of raw locator detected. ...  playwright/no-raw-locators
  7:9   error  Unexpected use of page.waitForTimeout()  playwright/no-wait-for-timeout
  8:3   error  Replace isVisible() with toBeVisible()  playwright/prefer-web-first-assertions
  8:16  error  Usage of raw locator detected. ...  playwright/no-raw-locators

✖ 6 problems (6 errors, 0 warnings)

6 处全部被拦下。这套机制对人写的测试和 AI 写的测试同样有效。

  • 不放任不稳定的测试 ── 在 CI 上「重跑后通过」的测试会在报告中标为 Flaky。查明原因修好,或在修好前先隔离
  • 只装需要的浏览器 ── 先只用 Chromium,Safari 用户多的话再加 WebKit,循序渐进
  • 耗时变长就拆分 ── 用 --shard=1/2 之类的方式拆开,在多台机器上并行运行
十四

AI 与 Playwright:已有的实例

从 2025 年起,Playwright 也开始被用作「让 AI 使用浏览器的工具」。关键在于把页面转成文字的无障碍快照。

AI 是怎样看页面的

/login ログイン 社員ID e8 パスワード e10 ログイン e11 交给 AI 的「页面文字版」 - heading "ログイン" - textbox "社員ID" [ref=e8] - textbox "パスワード" [ref=e10] - button "ログイン" [ref=e11] AI 发出的命令 $ playwright-cli fill e8 tanaka 返回实际执行的代码 await page.getByRole('textbox', { name: '社員ID' }).fill('tanaka'); AI 读页面靠的不是图片,而是「角色・名称・编号」的列表 标签和按钮名称越规范,AI 越不会迷路,写出的定位器也越好 (示例界面为日文。以上为笔者在演示应用上实际运行 playwright-cli 的输出,经过简化)

不是把截图当图片来读,而是读「角色・名称・编号」的列表,所以快,也不容易迷路

下面列出撰写时(2026 年 10 月)已有的实例。

实例时间能做什么
Playwright MCP2025 年 3 月起MCP(连接 AI 与工具的通用机制)服务器。可以让 VS Code、Cursor、Claude 等 AI 打开浏览器、输入、点击、获取快照。README 强调「不需要读截图或图片的模型」
GitHub Copilot coding agent2025 年 7 月起GitHub 的 AI 智能体一开始就能使用基于 Playwright MCP 的浏览器(默认启用),工作时可以打开网页确认
Copy prompt 与错误上下文2025 年 3 月起(1.51)HTML 报告、追踪查看器和 UI 模式里的错误旁新增「Copy prompt」按钮,可复制交给 AI 的提示词。现在每个失败测试还会生成汇总了错误、页面快照和相关代码的 error-context.md(在 1.64.1 中确认)
测试智能体2025 年 10 月起(1.56)用 npx playwright init-agents 把规划(planner)、生成(generator)、修复(healer)三个 AI 智能体的定义放进项目。撰写时支持 Claude Code、Codex、GitHub Copilot、OpenCode、VS Code
playwright-cli 与技能2026 年起(1.59 增强,1.62 内置)面向 AI 编程智能体的命令行。README 说明它比 MCP 传递的信息少,能节省 token(AI 的处理量)
支持 WebMCP2026 年 10 月(1.64)页面自己登记「面向 AI 的操作」的实验性浏览器机制(WebMCP),可以在测试里列出并调用

我们试着用 playwright-cli 操作了演示应用的登录页。AI 下达「在编号 e8 输入 tanaka」的命令后,Playwright 会把它作为使用 getByRole 的代码来执行,并把这段代码返回。AI 可以直接把它用进测试。

$ playwright-cli open http://localhost:3000/login
$ playwright-cli fill e8 tanaka
### Ran Playwright code
await page.getByRole('textbox', { name: '社員ID' }).fill('tanaka');
$ playwright-cli fill e10 pass-tanaka
### Ran Playwright code
await page.getByRole('textbox', { name: 'パスワード' }).fill('pass-tanaka');
$ playwright-cli click e11
### Ran Playwright code
await page.getByRole('button', { name: 'ログイン' }).click();
### Page
- Page URL: http://localhost:3000/
- Page Title: 経費精算の申請

这里第十二章的准备就起作用了。正因为输入框有标签,返回的代码才是 getByRole('textbox', { name: '社員ID' }) 这种易读又不易坏的形式。没有标签的页面,AI 也只能用容易坏的方式查找。对人友好的页面,对 AI 同样友好。

我们也装上了测试智能体(没有运行 AI,只确认到生成的文件为止)。

$ npx playwright init-agents --loop=claude
 🎭 Using project "chromium" as a primary project
 📝 specs/README.md - directory for test plans
 🌱 tests/seed.spec.ts - default environment seed file
 🤖 .claude/agents/playwright-test-generator.md - agent definition
 🤖 .claude/agents/playwright-test-healer.md - agent definition
 🤖 .claude/agents/playwright-test-planner.md - agent definition
 🔧 .mcp.json - mcp configuration
 ✅ Done.

测试智能体的分工

seed.spec.ts 准备工作 规划者 探索应用 specs/*.md 测试计划 生成者 写成代码 tests/*.spec.ts 测试代码 修复者 修好 失败时 人来确认 关键在于计划(Markdown)人能读懂。别全交给 AI,在计划阶段就参与把关

修复者的定义里写着「如果判断功能本身坏了,可能会跳过测试」。修改的内容一定要由人来看

再看看第十一章故意让它失败的测试,Playwright 写出的 error-context.md 内容。里面有给 AI 的指示、错误,以及失败那一刻的页面快照,AI 也能读出「没有『申請する』按钮,却有『提出する』按钮」。

# error-context.md(节选,自动生成)
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Error details
Error: locator.click: Test timeout of 30000ms exceeded.
  - waiting for getByRole('button', { name: '申請する' })

# Page snapshot
      - status [ref=e39]: 2,400円
      ...
      - button "提出する" [ref=e42] [cursor=pointer]
十五

让 AI 遵守规则

让 AI 写测试,速度快,但写法容易五花八门:用 CSS 选择器查找、等 2 秒、测试之间共享数据……所以要双管齐下:把项目的规则写成文字交给 AI,再用机器检查强制遵守。

规则、测试、人,三道关

交给 AI 的规则 AGENTS.md 等 提示词模板 ESLint 规则 示范测试代码 Page Object 位置 编写实现和测试 AI 智能体 用 Playwright 运行 CLI・MCP・test 失败的线索 错误上下文・追踪 失败 修改 1 2 3 全部通过 4 机器检查 → 人工评审 lint 与全部测试通过设为必需。改了期望值的差异由人判断 用规则统一「写法」,用测试确认「能运行」,由人把关「是否正确」 AI 很快,但有时会为了让测试通过而改掉期望值,这一点要靠人拦住

先是规则文件。多数 AI 编程智能体会先读仓库里的 AGENTS.md(Claude Code 是 CLAUDE.md,GitHub Copilot 是 .github/copilot-instructions.md 等)再开始工作(AGENTS.md 详见AGENTS.md 札记)。结合本文的测试,可以加上这样一节。

## E2E 测试(Playwright)规则

- 测试放在 tests/。页面操作写在 tests/pages/ 的 Page Object 里,
  测试主体只写「验证什么」
- 按 getByRole → getByLabel → getByText → getByTestId 的顺序查找元素。
  不用 CSS 选择器、XPath、nth-child
- 需要等待时用 expect(...).toBeVisible() 等 Web 优先断言。
  不用 waitForTimeout()
- 测试彼此独立。不依赖其他测试的数据或执行顺序。
  查找行时使用应用分配的编号
- 登录使用 auth.setup.ts 保存的 storageState
- 外部服务(汇率 API 等)用 page.route() 模拟
- 制作页面时,给输入框加标签、给按钮起名称。
  需要标记时加 data-testid(kebab-case)
- 修复失败的测试时,不改 expect 的期望值。无法判断是需求变更
  还是缺陷时,停下来说明理由
- 完成后运行 npx eslint tests 和 npx playwright test,把结果写进报告

再准备好请 AI 写测试时用的提示词「模板」。要点是:点名示范文件,不让它直接写代码,先交计划,贴出运行结果。

# 给 AI 的请求(模板)
请为报销的「退回」功能添加 E2E 测试。

- 需求:审批人退回后,申请人列表中的状态变为「差戻し」(退回)
- 示范:与 tests/approval.spec.ts 写法相同,遵守 AGENTS.md 的
  「E2E 测试(Playwright)规则」
- 先在 specs/reject.md 写出步骤和预期结果,等我确认
- 确认后再实现,并贴出 npx eslint tests 和 npx playwright test 的结果
- 如果页面缺少标签或名称,不要在测试里绕过,请提出修改页面的方案

然后,用机器确认规则是否被遵守,就是第十三章的 ESLint。文字规则 AI 可能会漏看,但 CI 里的 lint 漏不掉。「用规则文件指明方向,用 lint 和测试在偏离时叫停」——无论是人的团队还是 AI,同样的做法都管用。

NOTE

让 AI 操作浏览器时,请只给它测试环境和测试账号,不要让它接触生产数据或个人账号。另外,网页上的文字混入成为对 AI 的「指令」的攻击(提示词注入)也已为人所知。让它打开外部网站时,要限制允许 AI 做的操作,并由人确认结果。

十六

未来展望

最后谈谈今后的方向。以下是以现有功能为线索的笔者的看法。

  • 从需求到测试的距离缩短 ── 测试智能体可以从 Markdown 计划生成测试。只要验收条件写得清楚,从那里到测试草稿由 AI 完成,可能会成为常态。相应地,验收条件的写法会更加重要
  • 从「修测试」到「守测试」 ── 让测试跟上页面变化的修复,修复者已经在做。但什么都修的话,测试只会追着「当前的页面」跑,漏掉缺陷。期望值的修改必须经过人工批准的做法,今后会越来越重要
  • 无障碍成为质量的核心 ── AI 和 Playwright 都靠标签和角色来读页面。照顾读屏的页面结构,同时就是「易于测试」和「易于 AI 使用」。三个目标一致,对开发现场是顺风
  • 页面主动向 AI 提供「操作入口」 ── 像 1.64 加入的 WebMCP 支持那样,由页面自己声明面向 AI 的操作的尝试已经开始。虽然还在实验阶段,但沿着画面操作和调用约定入口,或许会并存下去
  • 人的工作转向决定「什么是对的」 ── 写测试、跑测试、修测试的工作,正在被 AI 和工具不断接手。留下来的,是决定什么样的行为才正确,并审视 AI 提出的改动

结 — 一页总结

  1. Playwright 是微软在 2020 年发布的浏览器自动化与测试工具,由打造 Puppeteer 的成员为了用一套 API 驱动 Chromium、Firefox、WebKit 而开发。
  2. 原理的支柱是与浏览器保持连接的通信、每个测试都全新的浏览器上下文、自动等待与 Web 优先断言,以及用可见线索查找的定位器。
  3. 在报销页面上,按规则表 → Page Object → 测试主体的顺序编写,利用登录状态保存、汇率 API 模拟和两个上下文,8 个测试约 6 秒全部通过。严格模式两次阻止了靠运气的测试。
  4. 可测试性要在设计阶段做进去:可验证的验收条件、标签与名称、错误的显示方式、test ID、数据初始化接口、外部服务可替换。用 CI 和 lint 在每次变更时守住。
  5. 与 AI 配合时,用规则文件指明方向,用 lint 和测试叫停,期望值的判断由人掌握。MCP、CLI、测试智能体都已可用。对人友好的页面,对 AI 也友好。

参考

测试于 2026 年 10 月 16 日用 Playwright 1.64.1(@playwright/test)针对笔者制作的报销演示应用(Node.js 22)实际运行。验证只在云端沙箱的 Chromium 上进行,未在 Firefox 和 WebKit 上运行。CI 配置是根据官方文档写的示例,没有为本文实际运行。测试智能体只确认到生成定义文件为止。下载量来自 npm 的公开统计(2026 年 10 月 6~12 日),各功能的时间依据 Playwright 的发布说明和 npm 的发布日期。「未来展望」为笔者个人看法。

开发指南 No.7 — Playwright 入门与实战 / 2026.10

← 返回文章列表