10.高级页面交互技巧
页面交互是自动化测试的核心环节。前几章已经掌握了基础的选择器使用和点击、输入等操作,但在真实项目中,页面结构往往比想象中复杂:嵌套的 Shadow DOM、动态加载的组件、自定义框架封装元素……这些场景下,常规方法会显得力不从心。本章将深入探讨 Puppeteer 提供的高级交互技巧,帮助应对各种复杂定位需求。
复杂元素定位策略
Locator API 的核心理念
Puppeteer 的 Locator API 是处理复杂交互的首选方案。它不仅仅是一个元素查找工具,更是一个智能等待与动作执行的封装层。当我们调用 page.locator('button').click() 时,Locator 会自动完成一系列前置检查:确保元素在视口内、等待可见性、等待元素启用、等待动画稳定。这种设计大幅降低了测试的脆弱性。
// 最基础的点击操作,背后包含多个自动检查
await page.locator('button.submit').click();
这段代码看似简单,实际上 Locator 会在点击前自动执行四个关键检查。首先是视口检测,如果按钮在页面可视区域外,Puppeteer 会自动滚动使其可见。其次是可见性等待,确保元素不是 display: none 或 visibility: hidden 状态。第三是启用状态检查,防止点击被禁用的按钮。最后是稳定性验证,通过连续两个动画帧确认元素位置不再变化。这些检查避免了传统自动化中常见的"元素不可交互"错误。
多类型选择器混用
CSS 选择器虽然强大,但面对文本内容、可访问性属性等场景时显得捉襟见肘。Puppeteer 提供了多种内置选择器,通过伪元素语法与 CSS 无缝结合。
// XPath 选择器:定位所有 h2 标题
const element = await page.waitForSelector('::-p-xpath(//h2)');
// 文本选择器:点击包含"结账"文本的按钮
await page.locator('div ::-p-text(Checkout)').click();
// ARIA 选择器:通过可访问名称定位
await page.locator('::-p-aria(Submit)').click();
// 带属性的 ARIA 选择器
await page.locator('::-p-aria([name="Click me"][role="button"])').click();
XPath 选择器在处理复杂 DOM 结构时特别有用,比如查找父元素、兄弟节点等 CSS 难以表达的关系。文本选择器则完全摆脱了对 DOM 结构的依赖,直接根据可见文本定位,非常适合多语言网站测试。ARIA 选择器基于浏览器的无障碍树,能够识别 aria-label、aria-labelledby 等属性,对于注重可访问性的应用是绝佳选择。
需要注意的是,文本选择器会转义括号等特殊字符。如果搜索文本包含 Checkout (2 items),需要写成 ::-p-text(Checkout \\(2 items\\)),或者用引号包裹 ::-p-text("He said: \\"Hello\\"")。这种转义规则虽然繁琐,但保证了选择器语法的健壮性。
过滤器增强定位精度
当单个选择器无法精确定位时,过滤器提供了二次筛选的能力。通过 JavaScript 函数对候选元素进行额外验证,可以实现任意复杂的定位逻辑。
// 仅点击文本为"My button"的按钮
await page
.locator('button')
.filter(button => button.innerText === 'My button')
.click();
这个例子中,CSS 选择器 button 可能匹配到多个元素,过滤器会遍历每个候选按钮,只保留 innerText 完全匹配的元素。过滤函数在浏览器上下文中执行,可以访问完整的 DOM API,这意味着可以进行任意复杂的验证,比如检查计算样式、数据集属性,甚至调用页面内的自定义函数。
从 Protractor 迁移的经验
许多团队从 Protractor 迁移到 Puppeteer,选择器语法的变化是首要挑战。Puppeteer 提供了对应方案,让迁移过程更平滑。
| Protractor 语法 | Puppeteer 等价方案 |
|---|---|
$(by.css('.class')) |
page.$('.class') |
$$(by.css('.class')) |
page.$$('.class') |
by.deepCss('.nested') |
:scope >>> .nested |
by.cssContainingText('.item', 'text') |
.item ::-p-text(text) |
by.xpath('//div') |
::-p-xpath(//div) |
deepCss 是 Protractor 中用于穿透 Shadow DOM 的选择器,在 Puppeteer 中对应 >>> 组合器。cssContainingText 则可以直接用文本选择器替代。这种对应关系让迁移工作有了明确的映射表,减少了学习成本。
Shadow DOM 穿透查询
Shadow DOM 的挑战
现代 Web 组件广泛使用 Shadow DOM 实现样式和结构的封装。但 Shadow DOM 的隔离特性让传统 CSS 选择器无法穿透, document.querySelector 无法访问 Shadow Tree 内部的元素。Puppeteer 为此提供了专门的组合器。
深度后代组合器 >>>
>>> 是最常用的 Shadow DOM 穿透工具,它会在所有打开的 Shadow Root 中递归查找匹配元素,类似于 CSS 的空格后代选择器。
// 在自定义元素内部查找按钮
await page.locator('my-custom-element >>> button').click();
这个选择器会首先定位 my-custom-element 元素,然后检查它是否有 Shadow Root。如果有,就在 Shadow Tree 中查找 button 元素。如果按钮又嵌套在另一个自定义元素中,会继续深入查找。这种递归机制完美契合了 Web Components 的嵌套特性。
深度子组合器 >>>>
>>>> 只查找直接子级的 Shadow Root,不会递归深入,类似于 CSS 的 > 子选择器。
// 仅在直接 Shadow Root 中查找
await page.locator('my-custom-element >>>> button').click();
如果 my-custom-element 的 Shadow Tree 中又包含另一个自定义元素,而目标按钮在第二层 Shadow DOM 中,这个选择器将无法找到。>>>> 的精确性在需要限制查找范围时非常有用,可以避免意外匹配到深层嵌套的元素。
组合选择器的实战技巧
深度组合器可以与普通 CSS 选择器、文本选择器等混合使用,构建强大的定位表达式。
// 在侧边栏中定位 Vue 组件,再查找包含特定文本的元素
await page.locator('.side-bar ::-p-vue(MyComponent) >>> ::-p-text(确认)').click();
这个复杂选择器展示了 Puppeteer 选择器系统的灵活性:先用 CSS 选择器限定范围,再用自定义 Vue 选择器定位组件,接着穿透 Shadow DOM,最后用文本选择器精确定位。这种组合能力让几乎任何元素都变得可定位。
需要注意的是,深度组合器只能在选择器的第一个深度工作,不能嵌套在 :is() 等伪类中。例如 :is(div > > a) 是无效的语法。这个限制源于 CSS 解析器的实现机制,使用时需要特别注意。
自定义选择器引擎
为什么需要自定义选择器
测试框架层出不穷,React、Vue、Angular 等都有自己的组件开发模式。虽然可以用 CSS 类名或测试 ID 定位,但这暴露了实现细节,且容易因重构而失效。自定义选择器允许直接通过框架概念定位元素,比如"查找名为 UserProfile 的 React 组件"。
注册自定义查询处理器
Puppeteer.registerCustomQueryHandler 是扩展选择器系统的入口。它需要两个参数:伪元素名称和包含 queryOne、queryAll 方法的对象。
// 注册 React 组件选择器
Puppeteer.registerCustomQueryHandler('react-component', {
queryOne: (elementOrDocument, selector) => {
// 在页面上下文中执行,可访问 React 内部 API
return elementOrDocument.querySelector(`[data-react-component="${CSS.escape(selector)}"]`);
},
queryAll: (elementOrDocument, selector) => {
return elementOrDocument.querySelectorAll(`[data-react-component="${CSS.escape(selector)}"]`);
},
});
注册后,就可以使用 ::-p-react-component(ComponentName) 语法。queryOne 返回单个元素,queryAll 返回所有匹配元素。这两个函数在浏览器上下文中运行,意味着可以访问页面内的全局变量、框架 API,甚至调用组件方法。
React 组件定位实战
实际项目中,React 组件通常不会直接标记 data-react-component 属性。更可靠的方法是访问 React Fiber 树。
Puppeteer.registerCustomQueryHandler('react', {
queryOne: (element, name) => {
// 遍历 DOM 树,通过 React 内部属性匹配组件名
const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
do {
const currentNode = walker.currentNode;
// React 17+ 使用 __reactFiber$ 前缀的属性
const fiberKey = Object.keys(currentNode).find(key =>
key.startsWith('__reactFiber$')
);
if (fiberKey) {
const fiber = currentNode[fiberKey];
if (fiber.elementType?.name === name) {
return currentNode;
}
}
} while (walker.nextNode());
return null;
},
});
这个实现通过 document.createTreeWalker 遍历 DOM 树,检查每个元素的 React Fiber 属性。__reactFiber$ 是 React 内部使用的键,包含组件的元数据。通过比较 elementType.name 可以精确匹配组件定义时的名称。这种方法不依赖额外的 DOM 属性,更加健壮。
Vue 组件定位方案
Vue 3 的组件实例挂载在 DOM 元素的 __vnode 属性上,利用这个特性可以实现 Vue 专用选择器。
Puppeteer.registerCustomQueryHandler('vue', {
queryOne: (element, name) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
do {
const currentNode = walker.currentNode;
// Vue 3 组件实例存储在 __vnode.ctx 中
if (
currentNode.__vnode?.ctx?.type?.name.toLowerCase() ===
name.toLowerCase()
) {
return currentNode;
}
} while (walker.nextNode());
return null;
},
});
使用方式与 React 类似:await page.$('::-p-vue(MyComponent)')。需要注意的是,这种依赖框架内部 API 的方式存在风险。Vue 或 React 的版本升级可能改变属性结构,导致选择器失效。因此,建议在测试基础设施中封装这些选择器,一旦框架升级只需修改一处。
自定义选择器的最佳实践
自定义选择器虽然强大,但滥用会导致测试代码难以维护。以下是一些经验法则:
- 优先使用标准选择器:只有 CSS、文本、ARIA 等无法满足需求时才考虑自定义
- 封装框架细节:将
registerCustomQueryHandler调用放在单独模块,避免散落在测试代码中 - 版本隔离:为不同版本的框架注册不同前缀的选择器,比如
react16、react17 - 性能考虑:
queryAll实现应尽量高效,避免在大型页面中造成卡顿
// 在 setup 文件中集中注册
import { Puppeteer } from 'puppeteer';
export function setupCustomSelectors() {
Puppeteer.registerCustomQueryHandler('react', {
// 实现略
});
Puppeteer.registerCustomQueryHandler('vue', {
// 实现略
});
}
// 在测试文件中导入
import { setupCustomSelectors } from './test-helpers';
setupCustomSelectors();
这种集中管理的方式让自定义选择器的维护变得清晰。当团队从 React 17 升级到 React 18 时,只需修改 setupCustomSelectors 函数,所有测试用例无需改动。
高级等待条件
超越可见性等待
基础等待通常只检查元素是否存在或可见,但业务逻辑往往需要更复杂的条件。比如等待某个元素从 DOM 中移除、等待文本内容更新、等待样式变化等。
函数定位器实现任意等待
Locator API 最强大的特性之一是支持函数作为定位器。这让我们可以用 JavaScript 表达任意等待条件。
// 等待 Canvas 元素出现
await page
.locator(() => {
return new Promise(resolve => {
const observer = new MutationObserver(records => {
for (const record of records) {
if (record.target instanceof HTMLCanvasElement) {
resolve(record.target);
}
}
});
observer.observe(document, { childList: true, subtree: true });
});
})
.wait();
这个例子使用 MutationObserver 监控整个文档树,当任何 HTMLCanvasElement 被添加时立即返回。函数定位器返回 Promise,Puppeteer 会等待其 resolve。这种模式非常适合监控动态内容,比如等待图表渲染、等待懒加载图片等。
函数定位器不仅可以等待,还可以执行动作。因为 Locator API 的所有方法(click、fill、hover)都支持函数定位器,所以可以实现"等待某条件满足后立即操作"。
配置 Locator 行为
默认的自动检查并非总是需要。有时需要点击隐藏元素,或者操作正在动画的元素。Locator 提供了链式配置方法调整行为。
// 禁用所有等待条件,立即点击
await page
.locator('button')
.setEnsureElementIsInTheViewport(false)
.setVisibility(null) // null 表示不检查可见性
.setWaitForEnabled(false)
.setWaitForStableBoundingBox(false)
.click();
每个配置方法都返回 Locator 实例,支持链式调用。setVisibility(null) 比较特殊,它接受 visible、hidden 或 null 三个值。设置为 null 时完全跳过可见性检查,这在测试加载状态或隐藏功能时很有用。
超时精细控制
全局超时设置可能过于粗糙,某些操作需要更长的等待时间。Locator 允许为单个操作设置超时。
// 这个操作最多等待 5 秒
await page.locator('.slow-loading-data').setTimeout(5000).wait();
// 其他操作仍使用默认超时
await page.locator('.fast-button').click();
超时时间以毫秒为单位。如果操作在指定时间内未完成,会抛出 TimeoutError。这种细粒度控制让测试既能快速失败于明显错误,又能容忍合理的加载延迟。
监听 Locator 事件
Locator 在执行动作前会触发事件,这提供了介入和调试的机会。
let willClick = false;
await page
.locator('button')
.on(LocatorEvent.Action, () => {
willClick = true;
console.log('即将点击按钮,当前时间:', Date.now());
})
.click();
console.assert(willClick, '应该触发 Action 事件');
LocatorEvent.Action 在 Locator 完成所有前置检查、即将执行动作时触发。如果动作失败并重试,事件会多次触发。这在调试间歇性失败的测试时非常有用,可以记录每次尝试的上下文信息。
与低级别 API 的协作
虽然 Locator 是推荐方案,但有时需要直接操作 ElementHandle。Locator 提供了 waitHandle 方法获取底层句柄。
const buttonHandle = await page.locator('button').waitHandle();
// 使用 ElementHandle 的特定方法
await buttonHandle.click({ offset: { x: 10, y: 10 } });
await buttonHandle.dispose(); // 必须手动释放
获取句柄后,可以使用 ElementHandle 的所有方法,比如指定点击偏移、拖拽等 Locator 不直接支持的操作。但必须记得调用 dispose() 释放内存,否则长期运行的测试可能耗尽资源。
等待元素消失
测试加载状态或临时通知时,经常需要等待元素从 DOM 中移除。Locator 的 wait 方法配合 setVisibility 可以实现。
// 等待加载指示器消失
await page.locator('.loading-spinner').setVisibility('hidden').wait();
设置 setVisibility('hidden') 后,wait 会等待元素变为隐藏状态。如果元素被完全移除,同样视为隐藏。这种模式在提交表单后等待处理完成时非常常见。
综合示例:复杂表单提交
将本章技巧综合起来,看一个真实场景:提交表单后,等待服务器响应,验证结果,同时处理可能出现的错误提示。
// 填写并提交表单
await page.locator('#username').fill('testuser');
await page.locator('#password').fill('securepass');
await page.locator('button[type="submit"]').click();
// 等待两种可能的结果之一
const resultLocator = page.locator(() => {
return Promise.race([
// 等待成功消息
new Promise(resolve => {
const check = () => {
const success = document.querySelector('.success-message');
if (success && success.offsetParent !== null) {
resolve({ type: 'success', element: success });
}
};
check();
new MutationObserver(check).observe(document, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
}),
// 等待错误消息
new Promise(resolve => {
const check = () => {
const error = document.querySelector('.error-message');
if (error && error.offsetParent !== null) {
resolve({ type: 'error', element: error });
}
};
check();
new MutationObserver(check).observe(document, {
childList: true,
subtree: true
});
})
]);
});
const result = await resultLocator.wait();
if (result.type === 'error') {
const message = await result.element.innerText;
throw new Error(`提交失败: ${message}`);
}
// 验证成功结果
const welcomeText = await page.locator('.welcome').map(el => el.innerText).wait();
expect(welcomeText).toContain('testuser');
这个示例展示了函数定位器的真正威力:同时等待多个条件,返回结构化数据。通过 Promise.race 让两个等待条件竞争,无论哪个先满足都会立即返回。错误处理分支提取错误文本并抛出异常,成功分支则继续验证欢迎信息。这种模式让异步流程的测试变得清晰可控。
高级页面交互技巧的核心在于理解 Puppeteer 的分层设计:Locator 提供智能封装,选择器系统提供灵活定位,函数定位器提供无限扩展。掌握这些工具后,再复杂的页面交互都能写出健壮、可维护的测试代码。下一章将探讨如何将这些技术应用到 Chrome 扩展的测试场景中,进一步扩展自动化能力边界。