你有没有遇到过这种情况:费了半天劲写了个page.locator('.product-card > div.list > span.price--final'),昨天还跑得好好的,今天产品把 class 从price--final改成final--price,你的用例一瞬间全军覆没。这种时候去改 CSS 路径只是治标不治本,真正该做的是给 Playwright 定制一套符合自己项目语义的选择器引擎,再用定位器(Locator)把元素的访问方式从 UI 细节里彻底解耦出去。
这篇不谈基础用法,只说两件事:怎么手写自定义选择器引擎(包括 midscene 这类工具为什么能实现"自然语言描述元素",本质也离不开这个机制),以及在实际项目中怎么把定位器用到极致,让脚本不再三天两头因为前端改动而崩。适合已经被 Playwright 的npx playwright install折磨过、在 iframe 和动态页面里迷失过、想沉淀一套稳定定位方案的测试开发人员。所有代码都能直接复制到自己的项目里改造,工具链以 TypeScript + Playwright 为准,Python 版思路完全一致。
1. 为什么默认选择器在真实项目里"不够用"
1.1 动态属性与语义缺失的尴尬
先给新手补个背景:Playwright 内置的css=、text=、xpath=已经覆盖了九成场景,但真实项目的 DOM 往往是这样的:
<div class="sc-6f2b1d3a-2 kYpFqW"> <span class="price--final">¥199</span> </div>class 要么是编译后的哈希字符串,要么带上了sc-前缀这种脚手架产物。更别提项目里常见的动态 id——每刷新一次页面id="item_284729"就变一次。默认选择器在这种环境里就是脆皮,对应关系全靠"当前恰好成立"。
这时候很多人的第一反应是去卷更长的nth-child路径,或者写复杂的 XPath。我的建议是:别卷,卷完还是脆。真正的解法只有两个方向——一是推动前端团队加>page.locator('data-hook=submit-btn')
这里>// selector-engines.ts import { selectors } from '@playwright/test'; await selectors.register('data-hook', { query(root: Element, selector: string): Element | null { return root.querySelector(`[data-hook="${selector}"]`); }, queryAll(root: Element, selector: string): Element[] { return Array.from(root.querySelectorAll(`[data-hook="${selector}"]`)); } });
注册之后,所有page实例都能用:
await page.locator('data-hook=cart/checkout-btn').click();这里>import { selectors } from '@playwright/test'; const normalize = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '-'); await selectors.register('semantic', { query(root: Element, selector: string): Element | null { const parts = selector.split(',').map(normalize).filter(Boolean); const all = root.querySelectorAll('[data-testid], [data-hook], [data-cy]'); for (let i = 0; i < all.length; i++) { const el = all[i]; const tags = [ el.getAttribute('data-testid'), el.getAttribute('data-hook'), el.getAttribute('data-cy'), ].filter(Boolean).map(normalize); if (parts.every(p => tags.some(t => t === p))) { return el as Element; } } return null; }, queryAll(root: Element, selector: string): Element[] { const parts = selector.split(',').map(normalize).filter(Boolean); const all = root.querySelectorAll('[data-testid], [data-hook], [data-cy]'); const result: Element[] = []; for (const el of Array.from(all)) { const tags = [ el.getAttribute('data-testid'), el.getAttribute('data-hook'), el.getAttribute('data-cy'), ].filter(Boolean).map(normalize); if (parts.every(p => tags.some(t => t === p))) { result.push(el); } } return result; } });
这个引擎的业务价值在于:当你面对一个既有 这里先用 定位器的组合能力还包括: 很多人写断言时喜欢用 第二个断言尤其重要,它判定"评论数大于 5 条"这个条件成立,Playwright 会一直重试直到超时,而不是只检查当前某一瞬间的状态。 上面热词里有人提到"爬取评论区"这类需求,我拿它作为动态页面的通用案例来讲思路,但不针对任何特定平台——合规采集和自动化测试的逻辑是相通的。 这类页面的核心问题:元素一开始不在 DOM 里,必须滚动触发加载。写法分四步: 几个要点: 这种方案不只适合评论区,凡是瀑布流、动态分页、tab 懒加载的模块都能套用。 Playwright 处理 iframe 的方式和 Selenium 那种"切换 driver 上下文"完全不同。它推出了 要注意的是 如果是 scrapy + playwright 这种后端渲染场景,注意 iframe 内容的加载时序:建议在配置里把 Shadow DOM 让很多自动化开发者头大,但 Playwright 有一个重要特性:内置选择器默认就能穿透 open shadow root。也就是说 只有两种情况需要额外关注: 强调一下:能穿透 open shadow root 是 Playwright 亲儿子的待遇,自定义引擎里没有这个能力。所以如果你既要自定义引擎又要处理 shadow DOM,就把上面这个函数作为 热词里有一个"playwright 过瑞数",先表个态:不要在没有授权的情况下绕过任何网站的安全防护。自动化测试和合规数据采集,都应该只在你有权限的站点上做。我下面讲的,是面对这类防护机制时测试工程师应该建立的通用认知。 这类防护的核心套路是:服务器返回一段动态脚本,脚本在浏览器里重新生成 cookie 标记,并且会校验浏览器环境指纹。如果你的自动化脚本环境特征和真实浏览器差距太大,页面就会反复停留在验证页或返回 403。常见的成功项是: 这些思路的本质是"让测试环境更接近真实用户环境",而不是"对抗防御机制"。如果你的项目真有被 WAF 误伤的情况,正确做法是联系站点负责人把你测试机的 IP 加入白名单,而不是研究绕过。 当一个页面里又是 iframe 又是 shadow DOM 时,不要试图写一长串选择器解决所有问题,而是分层处理: 这个分层写法也符合后续维护的心理模型。一旦出问题,你能准确判断是哪一层崩了,排查成本降一半。 Playwright 官方对 TypeScript 的支持几乎是第一公民。类型系统带来的收益不是"多写几个接口"那么表面,而是写自定义引擎的时候,你直接拿到官方的引擎接口定义,根本不用去猜 另一个实际好处是:定位器的 如果用了自定义引擎名,Playwright 的 TS 类型还不会自动认识 或者在 这样配置之后,团队成员不用关心"什么时候注册的",只要会用 输出类似: 这种形式一眼就能看出当前界面在无障碍层面是什么结构,很多定位问题其实是 DOM 语义和视觉结构错位导致的,快照能直接暴露出来。 最后分享我在团队里推的一套约定,实测下来能让脚本维护成本至少降一半:>const card = page.locator('.product-card').filter({ hasText: '无线耳机' }); await card.getByRole('button', { name: '加入购物车' }).click();.product-card框定商品卡片范围,再filter({ hasText: '无线耳机' })过滤出"名称包含无线耳机"的那张卡片,最后在这个范围内用getByRole定位按钮。注意 Playwright 的filter是在内部继续沿用自动等待的,不会因为过滤条件而丢失可操作状态检查。.first()/.last()/.nth(n):处理列表时快速取一个。.all():返回所有匹配元素数组,配合Promise.all做并发校验。.count():统计匹配数量,判断元素是否存在或是否如预期渲染。3.2 用 count() 判断元素是否存在的正确姿势
page.locator(...).isVisible(),但这是有局限的——isVisible()只对"页面里确实存在且非隐藏"的元素返回 true,如果元素还没渲染出来,它会直接返回 false,不会等。更稳的做法是配合自动等待的断言:await expect(page.locator('data-hook=empty-tips')).toBeVisible(); await expect(page.locator('data-hook=comment-item').count()).toBeGreaterThan(5);3.3 动态页面/无限滚动列表的完整处理方案
async function scrollThroughList(page: Page, itemLocator: Locator, maxScrolls = 20) { let prevCount = 0; let scrolls = 0; while (scrolls < maxScrolls) { prevCount = await itemLocator.count(); // 滚动到底部 await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等待新元素渲染:最多等 2 秒 await page.waitForFunction( (prev) => document.querySelectorAll('[data-hook="comment-item"]').length > prev, prevCount, { timeout: 2000 } ).catch(() => { // 超时说明已经没有新内容了 return; }); scrolls += 1; } }waitForFunction是动态列表场景的杀手锏。它能够反复执行一段浏览器环境里的 JS 表达式,直到条件成立或超时。catch里就退出循环。itemLocator.count()拿到总数,再用.all()逐条处理。千万避免边滚动边处理,防止页面结构变化导致定位器指向的元素"漂移"。3.4 定位器使用中最容易忽略的 3 件事
.click()里,所以永远只写await locator.click(),这一步就包含了等待出现、等待可见、等待稳定。.getByRole(...),父容器如果处于 pending 状态,子级操作会一直等待直到超时。page对象切换导航后,旧 locator 仍会尝试在新页面上查询,结果可能是空的或指向错误元素。4. iframe、Shadow DOM 与动态脚本防护的实战拆解
4.1 iframe 穿透:用 frameLocator 统一处理
frameLocator,可以把它理解成"位于 iframe 内部的定位器起点"。这种设计的最大好处是:可以同时操作主页和多个 iframe 里的元素,不用反复切换上下文。const frame = page.frameLocator('#payment-frame'); await frame.getByLabel('银行卡号').fill('6222****'); await frame.getByRole('button', { name: '确认支付' }).click();frameLocator的自动等待只在 frame 出现后才开始。如果页面里的 iframe 是动态注入的(比如点击某个按钮后才生成),需要先等待:await page.locator('#dynamic-wrapper iframe').waitFor();wait_for_selector设到 iframe 内部的目标元素上,而不是外层 iframe 标签本身。4.2 Shadow DOM:穿透其实没那么可怕
page.locator('text=用户名')可以摸到 shadow root 里的元素,不需要手动做任何处理。closed模式的 shadow root:默认无法穿透,需要前端配合暴露测试属性,或者用 JS 绕(但 closed 的意义就是不让外部访问,不应强行破解)。querySelector,它天然无法穿透 shadow root。解决办法是引擎内部遍历element.shadowRoot层级,比如:function deepQuery(root: Element | Document, selector: string): Element | null { const direct = root.querySelector(selector); if (direct) return direct; const all = Array.from(root.querySelectorAll('*')); for (const el of all) { if (el.shadowRoot) { const found = deepQuery(el.shadowRoot, selector); if (found) return found; } } return null; }query的内部实现。4.3 面对动态脚本防护(如瑞数类 WAF)的正确姿势
--headless或关闭 headless 后尽量加载真实 GPU 驱动。4.4 多层框架嵌套时的定位策略建议
// 第一层:进入 iframe const frame = page.frameLocator('#outer-frame').frameLocator('#inner-frame'); // 第二层:在 shadow 容器里找元素 const shadowHost = frame.locator('my-checkout-widget'); // 第三层:通过宿主元素进入 shadow 内容(Playwright 的 locator 会自动穿透) await shadowHost.getByText('确认订单').click();5. TypeScript + Playwright 的工程化实践与常见问题速查
5.1 为什么 TypeScript 和 Playwright 是黄金组合
queryAll的返回值格式。比如注册自定义引擎时的参数类型就是SelectorEngine接口,字段名和返回值一目了然。getByRole、filter、locator这些方法的参数都有精确的类型提示,传错字符串会直接编译报错,而不是等你运行到那一行才抛出。对于团队协作项目来说,类型就是最便宜的文档。5.2 自定义引擎的类型声明实战
>declare module '@playwright/test' { interface Locator { dataHook(selector: string): Locator; } }playwright.config.ts里把引擎注册封装到一个独立模块,在globalSetup引用,确保每个测试文件运行时引擎一定可用:// global-setup.ts import { selectors } from '@playwright/test'; export default async function globalSetup() { await import('./selector-engines/register'); // 引擎注册必须在全局初始化阶段完成 }>console.log(await page.getByRole('dialog').ariaSnapshot());- dialog "登录" - button "关闭" - textbox "用户名" - textbox "密码" - button "提交"5.5 一套可落地的选择器规范
>