用了快三年 Playwright,我最常跟团队里新人说的一句话就是:先别急着手写选择器,把npx playwright codegen跑起来,让浏览器先替你"写"一遍代码。今天想把这套从录制到工程化的完整思路捋一遍,包括 codegen 生成代码的工作原理、选择器策略、以及从录制脚本到可维护测试用例之间的那段"隐性改造"。这篇内容适合刚接触 Playwright 自动化、或者已经会用 codegen 但总觉得生成代码不够稳的读者,我会把平时踩过的坑和验证过的做法都写出来。
1. 为什么我把 codegen 当作写测试的默认起点
大多数刚接触自动化测试的人,第一反应都是打开编辑器,对着页面 F12 查看元素,然后照着 class 或者 id 硬写一份 selector。但这个流程有个隐藏问题:你写 selector 的时候,脑子里其实在"猜"这个元素在当前页面的稳定特征。猜对了还好,猜错了就是来回试。而 codegen 本质上是另一套逻辑——它让浏览器帮你观察、帮你试、最终把一次真实的人工操作翻译成一段可运行的脚本。这个过程省掉的不只是时间,更是一大堆不确定性。
1.1 手写选择器的三个典型翻车现场
先说三个我见过太多遍的失败模式。
第一个是"复制 class 全家桶"。很多人习惯把开发工具里那一长串动态 class 原样抄进选择器,类似.sc-6h3f2a-login-btn__submit--primary。这种 class 多半是 CSS Modules 或某个组件库运行时生成的哈希值,前端一改配置、一升级依赖,哈希就变,测试第二天全红。第二个是过度依赖绝对路径,html > body > div#app > div > div > form > button这种写法。页面结构稍微调整一个层级,整个路径就塌了。第三个是不加限制地用文本匹配,比如text=登录。看起来挺稳,但页面如果有两个"登录"(导航栏一个、弹窗一个),或者文案从"登录"改成"立即登录",这条规则就出问题。
这三个翻车现场本质上是同一个问题:你站在页面外面去猜它的内部结构,猜得再准也是脆弱的。codegen 恰恰换了思路——它让你直接去操作页面,再根据 Playwright 自己的选择器优先级策略帮你挑出它认为最稳的定位方式。
1.2 codegen 的本质:把操作翻译成指令
很多人对 codegen 有误解,觉得它就是个"录屏生成脚本"的小工具。实际上它的核心逻辑比录屏严谨得多。录屏只是记录坐标和视觉变化,codegen 不一样,它在录制过程中会持续解析当前页面的 DOM 树、可访问性快照(Accessibility Snapshot)和元素之间的相对位置,然后根据一套内置的优先级规则选出最合适的选择器。也就是说,它不是记"刚才这个位置被点了一下",而是记"刚才这个元素被我点击了,它在当前时刻最稳定的标识是什么"。
这个差别在动态页面里特别有价值。比如你点了一个列表项,录制器会根据列表项内部的文本、附近的>npm init -y npm install -D @playwright/test npx playwright install chromium
安装完之后,启动代码生成器:
npx playwright codegen这个命令会做三件事:打开一个录制控制面板、启动一个浏览器窗口、在控制面板里实时展示当前操作对应的脚本。你在这个浏览器窗口里的每一次点击、输入、跳转、滚动,都会被翻译成脚本输出。
录制控制面板上方有个语言切换下拉框,支持 JavaScript、TypeScript、Python、Java、C# 和 .NET。切到对应语言,生成的语法就会实时联动。另外还有几个常用参数:
# 指定目标网址,打开后自动开始录制 npx playwright codegen https://example.com # 保存到指定文件(--output 或 -o) npx playwright codegen -o tests/login.spec.js https://example.com/login # 指定设备模拟,比如 iPhone 13 npx playwright codegen --device="iPhone 13" https://example.com我日常用得最多的就是-o加指定 URL。因为-o会直接生成一个完整可执行的测试文件,里面会自动带上test声明和必要的断言,不是只输出操作脚本片段。这个细节很多人没注意到,觉得 codegen 只能生成零散的代码。实际上在较新版本里,它已经能生成带完整测试骨架的文件了。
2.2 录制过程中会被捕捉的操作类型
先明确一个边界:codegen 不是捕捉浏览器里的一切。它重点捕捉的是与 UI 交互相关的操作,具体包括:
- 点击、双击、右键点击
- 输入框的 fill(包括文本输入和清空)
- 下拉选择(selectOption)
- 复选框和单选框的勾选
- 鼠标悬停(hover)
- 键盘事件(如 Tab、Enter 提交表单)
- 页面跳转和导航
- 文件上传(setInputFiles)
- 滚动,以及一些页面内加载的动态内容触发的等待
比较典型的例外是:鼠标在页面上随意划过但没有触发任何可交互元素,这种无意义移动不会被记录;浏览器地址栏直接输入 URL 跳转,会以goto的形式记录;纯 JS 驱动的页面状态变化(比如某个定时器修改了页面文案)通常不会生成操作代码,但如果你在录制过程中使用了 Playwright 提供的"断言"和"检查"工具,点击页面元素右键选择"断言可见性""断言文本",这些断言会一并生成。
这里有一个很实用的操作习惯:录制过程中不要一上来就让浏览器自动跑到某个 URL,而是先手动打开目标页面,把登录、跳转、筛选这些前置流程录完,再在关键节点上(比如某个结果列表渲染完成后)主动调用录制器上的"暂停"按钮,让代码生成先稳定一波。因为录制时如果页面还在异步加载,录制器有时会把一个元素既记录"隐式等待"又记录"可见性检查",导致生成的代码冗余。手动暂停可以避免一部分这类噪音。
2.3 代码格式的选择与切换
语言选择不是随便选的,它直接关系到你后面怎么跑。我的建议是:项目用什么测试栈,就生成对应语言。如果你用的是 Playwright 原生测试框架(@playwright/test),直接用 JavaScript 或 TypeScript。TS 的好处是生成的代码自带类型推断,对于选择器的补全和后期维护更友好。Python 项目就生成 Python 版本,配合 pytest 插件使用。
其实比语言更值得关注的是 codegen 生成的脚本结构。以 JS 为例,默认生成的代码大致长这样:
import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('https://example.com/login'); await page.getByLabel('用户名').fill('tester'); await page.getByLabel('密码').fill('passw0rd'); await page.getByRole('button', { name: '登 录' }).click(); await expect(page.getByText('欢迎回来')).toBeVisible(); });注意几个点:它用的是getByLabel、getByRole这类以语义化定位为主的 API,而不是page.click('.class-name'),这是新版本录制器的默认策略,优先级从高到低大致是:>const dialog = page.getByRole('dialog', { name: '确认删除' }); await dialog.getByRole('button', { name: '删除' }).click();
这种写法的核心思想是把不稳定的部分缩小到一个小范围内,即使外层结构有变化,只要弹窗的 role 和标题不变,内层按钮就能稳定命中。实测下来,这种组合定位比单层长选择器稳得多。
注意:很多失效问题的根源其实不是选择器,而是"时机"。在动手改选择器之前,先确认是不是缺少等待。盲改选择器只会让测试从一个坑跳进另一个坑。
4. 从录制代码到工程化测试脚本,差在哪几步
用 codegen 跑通流程只是第一步。真正要让它进入 CI、成为团队资产,必须做一轮"去录制味"的改造。这个改造不复杂,但很关键。
4.1 把魔法字符串抽成页面对象
codegen 生成的脚本是线性结构:打开页面、填、点、断言。如果整个测试就十几行,这么写没什么问题。但真实业务测试一般都有几十行甚至上百行,线性结构会让每个测试都变成一长串流水账,改一个选择器就得翻遍所有文件。
我习惯在脚本稳定后,把核心操作抽到 Page Object 里。比如登录这个动作,无论哪个测试用例都需要先登录,那就把它统一封装:
class LoginPage { constructor(page) { this.page = page; this.username = page.getByLabel('用户名'); this.password = page.getByLabel('密码'); this.submit = page.getByRole('button', { name: '登 录' }); } async login(user, pwd) { await this.username.fill(user); await this.password.fill(pwd); await this.submit.click(); await this.page.waitForURL('**/dashboard'); } }抽完之后,测试用例变得非常干净:
test('登录后进入工作台', async ({ page }) => { const login = new LoginPage(page); await login.goto(); await login.login('tester', 'passw0rd'); await expect(page.getByRole('heading', { name: '工作台' })).toBeVisible(); });这里有个容易被忽略的好处:Page Object 把"页面元素长什么样"和"业务逻辑做什么"分离了。以后前端改版,只需要改 Page Object 里的 locator,测试用例本身不动。这对快速迭代的 Web 项目来说,维护成本直接下降一个量级。
4.2 等待策略:分清自动等待和显式等待的边界
Playwright 有自动等待机制,它会在操作前自动等待元素可被发现、可见、稳定、可接收事件。这是它比 Selenium 好用的关键原因之一。但自动等待不等于"无限等待",它有时间上限(默认 30 秒),也不完全等同于"等待业务逻辑完成"。
录制生成的代码里,Playwright 有时会额外生成page.waitForTimeout(3000)这种硬性等待。这种代码在录制场景下可能是合理的,但放进正式测试里通常应该被清理掉。原因很简单:固定等待是脆弱的。机器快的时候白等,慢的时候不够用,而且它掩盖了真正的依赖关系。
正确的做法是把"等待"转化成"条件"。比如等待接口响应、等待某个元素出现、等待 URL 变化:
// 等待网络请求完成 await Promise.all([ page.waitForResponse('**/api/order/create'), page.getByRole('button', { name: '提交订单' }).click(), ]);这段代码的含义是"点击提交后,等服务端返回创建成功的响应"。这不是凭空等一个时间,而是在等待一个真实发生的事件,稳定性要高得多。codegen 不会帮你生成这类等待,所以这是从录制代码迈向工程化代码时,最值得花时间改造的一环。
4.3 断言与验证的自动化补全
codegen 生成的断言通常很简单,比如"某个文本可见"。真实业务里,你需要验证的东西远不止文本可见。我总结了几类高频补全场景:
- 数值型断言:金额、数量、统计数字,要断言内容等于具体值,而不是仅可见
- 状态型断言:按钮是否处于禁用态、复选框是否被勾选
- 列表型断言:表格的行数是否符合预期,列表项是否包含预期数据
- 跳转型断言:点击后地址栏 URL 是否符合预期,使用
waitForURL - 网络型断言:关键接口是否成功返回、响应码是否为 200
举一个实际例子,订单创建成功后,页面会跳转到订单详情,详情里有订单号和一个"待支付"状态标签。录制的代码大概只会生成"待支付可见"这一条断言。我会补上 URL 断言和订单号格式断言:
await expect(page).toHaveURL(/\/order\/detail\/\d+/); await expect(page.getByText('订单号')).toContainText(/ORD\d{10}/);这样测试的意义就上来了:它不只是"界面没崩",而是"业务链路符合契约"。这也是我判断录制代码是否达到交付标准的底线。
4.4 数据驱动与参数化的基本思路
codegen 的单脚本模式还有一个局限性:它把数据写死了。填的是tester和passw0rd,那么所有回放都用这一组数据。但要覆盖多种角色、多种输入组合,就得做参数化。
最简单的做法是写循环或者用测试框架自带的参数化能力。Playwright Test 自带for循环写法或describe配置,也可以直接用 Node 的数据文件:
const users = [ { name: 'admin', pwd: 'admin123', expect: '管理员后台' }, { name: 'viewer', pwd: 'view123', expect: '只读模式' }, ]; for (const user of users) { test(`用户 ${user.name} 登录`, async ({ page }) => { const login = new LoginPage(page); await login.goto(); await login.login(user.name, user.pwd); await expect(page.getByText(user.expect)).toBeVisible(); }); }这样一套数据驱动改造做完,原来录制出来的 3 行测试就扩展成了覆盖多角色权限的测试矩阵,而且数据本身是独立的,后续加角色只需要往数组里加一项。这个改造思路不复杂,但它把 codegen 工具的定位从"生成脚本"提升到了"生成测试资产"。
5. 实测记录:iframe、多标签页、下载和断言的典型坑
理论知识讲完,分享几个我在实际项目里用 codegen 跑下来的场景记录。这些场景看着简单,但如果不了解录制器的边界,很容易在录制阶段就觉得"工具不行"。
5.1 iframe 场景:录制器知道你在操作 iframe 吗
Playwright 对 iframe 的支持一直不错,codegen 也能识别 iframe 内的点击和输入。生成代码时会自动使用frameLocator,这是正确用法,不用手动改。比如:
const frame = page.frameLocator('#payment-widget'); await frame.getByLabel('卡号').fill('4242 4242 4242 4242'); await frame.getByRole('button', { name: '支付' }).click();但我碰到过两个坑。第一个是 iframe 的加载时序。iframe 里的内容本身是异步加载的,录制器识别元素时页面已经加载完毕,回放时 iframe 却可能还没渲染。这时候frameLocator会超时。解决办法是在操作 iframe 前,先等待 iframe 的某个稳定元素可见:
await frame.getByText('安全支付').waitFor();第二个坑是跨域 iframe 的 loading 状态。有些页面的 iframe 会有一个短暂的白屏期,Playwright 的自动等待在这个场景下会比较钝。实测下来,加上expect(frame.getByRole('button', { name: '支付' })).toBeEnabled()这类显式断言,能明显减少随机失败。
5.2 多标签页:录制器生成的是 context 级别的操作吗
codegen 对多标签页的支持比很多人预期的好。如果你在录制时点击了带有target="_blank"的链接,它会自动生成context.waitForEvent('page')这样的代码,并切到新页面继续操作:
const [newPage] = await Promise.all([ context.waitForEvent('page'), page.getByText('打开帮助中心').click(), ]); await newPage.getByRole('heading', { name: '帮助中心' }).click();这里要注意的问题是录制时弹出的新标签页如果被你手关掉了,生成的代码可能会在newPage后续操作里断掉。最稳妥的录制方式是新标签页相关操作一气呵成录制完,不要在中间切换页面做别的事。另外,在录制过程中如果打开了多个标签页,录制器会为每个页面分别维护一套操作记录,生成的代码会以page1、page2这样的变量区分,回放顺序完全按录制顺序来。这意味着如果你回放时遇到弹窗拦截或自动跳转页面,变量指向就可能错位。
5.3 文件下载:不要让下载操作卡住测试
下载场景在 codegen 里其实处理得不算好,因为录制器不会替你判断下载是否完成。它最多生成一个点击动作。如果点击后触发了下载,Playwright 会默认允许下载,但测试不会等待下载完成,直接进入下一步,这会导致断言失败或产生临时文件残留。
实际项目中,处理下载的正确姿势是在点击下载按钮之前,用page.waitForEvent('download')先挂起监听:
const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: '导出报表' }).click(); const download = await downloadPromise; await download.saveAs('./downloads/report.xlsx');保存路径建议放到测试数据目录里,并在测试结束时清理。如果项目用 CI,下载目录要规划好,否则每次构建都会积累一堆垃圾文件。codegen 在这个场景里的角色就是提供一个起点:把按钮点击录出来,至于下载完成事件和文件保存逻辑,必须手动补。
5.4 断言细节决定测试有效性
录制器生成的断言往往只有toBeVisible,这在快速冒烟阶段够用,但在核心流程里不够。比如支付成功页,录制器可能会生成"支付成功"文本可见的断言。但"支付成功"四个字可能是页面写死的静态文案,哪怕业务失败也会展示,那这条断言就没有意义。真正应该验证的是订单状态、金额、流水号这类动态数据。
一个我常用的改进思路是"三级断言":
- 第一级:核心文案可见
- 第二级:关键动态数据出现且有格式
- 第三级:关键接口响应成功
第二级和第三级通常需要手动补。别嫌麻烦,断言的有效性直接决定了测试的价值。一个测不出问题的测试,比不写测试更危险,因为它会给你虚假的安全感。
6. 关于验证码这类特殊交互的测试策略
热搜词和日常交流里,总有人问自动化怎么处理滑块验证码、行为验证码这类交互。这里我要先明确一点:如果你问的是"怎么绕过",那不是测试该做的事。测试验证码的目标应该是"如何让测试环境不依赖验证码",而不是"如何攻破验证码"。真实项目里,验证码是安全防线,测试团队在测试环境去攻破它,既低效又危险。
正确的做法是推动产品/开发在测试环境提供验证码豁免机制。常见方案包括:
- 测试环境关闭验证码校验
- 提供万能管理员 token 跳过验证
- 预留测试专用验证码入口,比如固定返回通过
- 用 MCP 或测试工具注入会话态,绕过验证流程进入目标页面
如果实在要在测试环境验证"验证码组件本身是否正常",那就只做组件级测试,不要试图在端到端流程里去破解它。这个边界很重要,它不是技术问题,是原则问题。我在多个团队里推动的都是这条路线,实测下来测试稳定性大幅提升,安全团队也不会找麻烦。
7. 迁回核心:codegen 在测试策略里的真实定位
回到最开始的问题:codegen 到底应该被怎么用?
我的答案始终是:它是一个效率工具,不是一个测试策略本身。它最大的价值是替你把"从 0 到 1"的时间压缩到最短,让你把精力花在"从 1 到 100"的健壮性、数据准确性、业务覆盖度上。拿我自己的项目举例,每次接手新业务模块,标准流程都是:
- 用 codegen 快速录制主流程,确认自动化可行
- 把录制产物当作需求文档,梳理出所有可断言的节点
- 抽 Page Object、补等待策略、补断言、做参数化
- 关键路径补上接口响应断言
- 接入 CI,把稳定用例逐步固化
这套流程下来,codegen 只是第一步的催化剂。真正让测试有价值的,永远是后面那四步里你对业务的理解和对稳定性的打磨。工具会升级、API 会变,但这个思路不会过时。希望这篇内容能帮你把 Playwright codegen 用得更透彻,也欢迎在实践中多试多验证——毕竟自动化测试这东西,只有跑在真实项目里的经验才算数。