Dev Guide / 2026 ・ No.07
Playwright 入门与实战
诞生背景与原理、报销页面的测试,以及如何与 AI 配合
发布
写好测试,浏览器就会自己去验证
打开页面、输入、点击按钮、用眼睛确认显示。每次发布都要人工重复的这些检查,
写成代码、随时可以重放——这就是 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 越接近真实就越耗时。细碎规则的组合交给单元测试,E2E 只看「重要的流程能否走通」
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 之路,以及之后
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 本体(服务器),以及浏览器。
命令是怎样送达的
用 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 和存储都彼此独立的「无痕窗口」。用官方文档的话说,它们「创建起来又快又省」。
让不同的「人」共处于一个浏览器
在报销场景里,可以在一个测试中再现申请人和审批人同时登录的情形(第十章)
Playwright Test 会为每个测试准备新的上下文。不会沿用上一个测试的登录状态和输入内容,所以测试并行运行、调换顺序,结果都不会变,也能避免「前一个测试失败,后面全部跟着失败」的连锁反应。
原理③:自动等待
页面自动化测试最常见的麻烦是「等待」:按钮还没出现就去点击,于是失败。为了避免而写上「等 2 秒」,页面快时白等,慢时照样失败。Playwright 用自动等待解决了这个问题。
不写「等几秒」
检查项目因操作而异(输入时还会检查「是否可编辑」)。条件在时限内仍未满足,就以 TimeoutError 失败
验证(断言)也是同样的思路。写 expect(合计).toHaveText('3,000円'),Playwright 会反复检查,直到显示变成「3,000円」。这叫 Web 优先断言。默认时限是每个断言 5 秒、每个测试 30 秒(本文的测试在失败时的输出里也确认了这两个值)。
原理④:查找元素的方法与工具
指定要操作页面上哪个部件的,就是定位器。Playwright 官方指南建议用用户看得到的线索来查找。
定位器的推荐顺序
getByRole 的「角色」指按钮、输入框、表格、标题等,也会传达给读屏软件的部件类型
定位器还有严格模式:要操作的目标匹配到两个以上元素时,会以「不知道是哪个」报错,防止随手点了第一个、碰巧通过。本文的测试也两次得益于它(第十一章)。
编写和修改测试的工具也很齐全。
| 工具 | 命令・位置 | 用途 |
|---|---|---|
| codegen | npx 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,无外部库,界面为日文):申请人登录后填写明细,审批人批准。
这个页面有以下规则。写测试之前,先从把规则列成表开始。
| # | 规则(验收条件) | 验证方法 |
|---|---|---|
| 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 安装,文件是手写的)。测试套件的结构如下。
文件的分工
首先是配置。测试前启动应用(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 中有申请人(田中)和审批人(佐藤)两个人。这时浏览器上下文就派上用场了:在一个浏览器里创建两人的上下文,分别以各自的登录状态打开页面。
一个测试里,两个人轮流操作
// 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)
不过,并不是一开始就全部顺利。我们两次被严格模式拦了下来。
- 第 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 的力量,在写测试之前,也就是设计和开发阶段,基本就决定了。
可测试性要「提前」做进去
| 要准备的事 | 为什么 | 报销中的例子 |
|---|---|---|
| 用可验证的句子写验收条件 | 「好用」没法测试,「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 });
}
可测试性是产品、设计、UI 设计人员都参与其中的质量。在界面设计文档里加一栏「各输入框的标签」「错误的显示方式」「test ID」,后续测试的工作量就会大不相同。而这些准备,在让 AI 写测试时也同样有效(第十四章)。
开发阶段与 CI 的准备
测试只写出来,价值只有一半。每次变更都自动运行,才能防止「不知什么时候坏掉了」。
每个合并请求,都做同样的检查
用 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 是怎样看页面的
不是把截图当图片来读,而是读「角色・名称・编号」的列表,所以快,也不容易迷路
下面列出撰写时(2026 年 10 月)已有的实例。
| 实例 | 时间 | 能做什么 |
|---|---|---|
| Playwright MCP | 2025 年 3 月起 | MCP(连接 AI 与工具的通用机制)服务器。可以让 VS Code、Cursor、Claude 等 AI 打开浏览器、输入、点击、获取快照。README 强调「不需要读截图或图片的模型」 |
| GitHub Copilot coding agent | 2025 年 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 的处理量) |
| 支持 WebMCP | 2026 年 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.
测试智能体的分工
修复者的定义里写着「如果判断功能本身坏了,可能会跳过测试」。修改的内容一定要由人来看
再看看第十一章故意让它失败的测试,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(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,同样的做法都管用。
让 AI 操作浏览器时,请只给它测试环境和测试账号,不要让它接触生产数据或个人账号。另外,网页上的文字混入成为对 AI 的「指令」的攻击(提示词注入)也已为人所知。让它打开外部网站时,要限制允许 AI 做的操作,并由人确认结果。
未来展望
最后谈谈今后的方向。以下是以现有功能为线索的笔者的看法。
- 从需求到测试的距离缩短 ── 测试智能体可以从 Markdown 计划生成测试。只要验收条件写得清楚,从那里到测试草稿由 AI 完成,可能会成为常态。相应地,验收条件的写法会更加重要
- 从「修测试」到「守测试」 ── 让测试跟上页面变化的修复,修复者已经在做。但什么都修的话,测试只会追着「当前的页面」跑,漏掉缺陷。期望值的修改必须经过人工批准的做法,今后会越来越重要
- 无障碍成为质量的核心 ── AI 和 Playwright 都靠标签和角色来读页面。照顾读屏的页面结构,同时就是「易于测试」和「易于 AI 使用」。三个目标一致,对开发现场是顺风
- 页面主动向 AI 提供「操作入口」 ── 像 1.64 加入的 WebMCP 支持那样,由页面自己声明面向 AI 的操作的尝试已经开始。虽然还在实验阶段,但沿着画面操作和调用约定入口,或许会并存下去
- 人的工作转向决定「什么是对的」 ── 写测试、跑测试、修测试的工作,正在被 AI 和工具不断接手。留下来的,是决定什么样的行为才正确,并审视 AI 提出的改动
结 — 一页总结
- Playwright 是微软在 2020 年发布的浏览器自动化与测试工具,由打造 Puppeteer 的成员为了用一套 API 驱动 Chromium、Firefox、WebKit 而开发。
- 原理的支柱是与浏览器保持连接的通信、每个测试都全新的浏览器上下文、自动等待与 Web 优先断言,以及用可见线索查找的定位器。
- 在报销页面上,按规则表 → Page Object → 测试主体的顺序编写,利用登录状态保存、汇率 API 模拟和两个上下文,8 个测试约 6 秒全部通过。严格模式两次阻止了靠运气的测试。
- 可测试性要在设计阶段做进去:可验证的验收条件、标签与名称、错误的显示方式、test ID、数据初始化接口、外部服务可替换。用 CI 和 lint 在每次变更时守住。
- 与 AI 配合时,用规则文件指明方向,用 lint 和测试叫停,期望值的判断由人掌握。MCP、CLI、测试智能体都已可用。对人友好的页面,对 AI 也友好。
参考
- Playwright Docs — Installation
- Playwright Docs — Release notes
- Playwright Docs — Best Practices
- Playwright Docs — Locators
- Playwright Docs — Auto-waiting (Actionability)
- Playwright Docs — Isolation (Browser contexts)
- Playwright Docs — Authentication
- Playwright Docs — Continuous Integration
- Playwright Docs — Playwright Test Agents
- GitHub — microsoft/playwright-mcp
- GitHub — microsoft/playwright-cli
- InfoQ — Microsoft Announces Playwright Alternative to Puppeteer (2020-01-30)
- Wikipedia — Playwright (software)
- Selenium — History
- Thoughtworks Technology Radar — Playwright
- GitHub Changelog — Copilot coding agent now has its own web browser (2025-07-02)
- GitHub — eslint-plugin-playwright
- npm — @playwright/test
测试于 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