震惊!!Xpath封装还能这么玩?
背景
酷家乐有一套自己的UI自动化框架--Hades,其主要以puppeteer与playwright为核心进行了二次封装改造,并整合了许多酷家乐设计工具前端api。使得UI自动化对canvas交互、前端性能测试有比较好的支持。
除了能力上的扩展以外,Hades还有一个显著特点是:它将puppeteer/playwright中的api都代理到一个pyBell对象上,使得我们无需关注browser、page、element等对象,大大简化了用例编写的难度。
对于酷家乐设计工具而言,UI 自动化是比较重要的一个上线回归手段,目前整体自动化用例数已经超过1500条,参与编写自动化case的同学众多。
在这样的背景下,我们对自动化case编写门槛、编写效率,以及稳定性有比较高的要求。为此,我们探索与积累了许多技巧,包括 自动化用例编写规范、xpath 封装技巧、前端测试辅助工具开发、VScode 辅助插件开发 等。
本篇将分享几例 xpath 元素定位封装的技巧。
一、从一个用例的演变开始
由于酷家乐设计工具为大型单体应用,不存在page的概念,并且许多”按钮“、”菜单“元素都是动态加载,再加上框架封装的特点,所以业界通用的PageObject设计模式并不完全适用。
我们采用的方式是将用例分为三层:
- selector:定义一些 xpath、css selector。
- function:封装一些较为通用的逻辑函数。通常以npm二方包形式存在,便于跨业务线共享(也有部分特定的函数封装封装在用例repo中,未提供二方包)。
- testcase:编写的test case。通过调用一些公共函数+直接操作元素组成我们的业务用例。
在早期,我们的用例往往是这种风格。
其特点是:
- 用例内容较直观,但是业务函数封装过少,可读性较差。用例代码量大。
- xpath定义冗余,可维护性不理想。
UI自动化始终不能脱离的一个痛点是xpath(或 css selector),很多新人往往会在调试 xpath 元素定位上花费大量时间,并且最终写出一些不易读、不易维护的元素定位。
为此,我们探索了一些函数封装、xpath封装的技巧,加强了一些封装与用例规范。现阶段的用例演变为这种风格。
其特点是:
- 因为加强了业务函数封装,用例中几乎完全隔离了 xpath,可读性与可维护性大大提升。
- xpath 也进行了函数化,编写的 xpath 比之前减少70%以上。xpath 的稳定性也提升了不少。
- 用例代码量大大降低,对新人比较友好。
二、函数封装技巧
2.1 xpath函数封装,减少70%的xpath编写
函数封装并不局限于元素操作。其实将函数运用到 xpat h编写,也能带来巨大的价值。
以酷家乐工具顶部栏举例,如果想点击 撤销、保存、hover 文件,可以有以下三种定义xpath元素方法:
方式一:偷懒式,使用 pyBell.clickByText()函数
// 无需定义xpath。该函数本质上等于 await pyBell.click("//*[text()='撤销'"]
await pyBell.clickByText("撤销")
await pyBell.hoverByText("文件")
缺点:该函数本质是用了"//*[text()='xxx']"这样的xpath定位,所以很容易找到重复的元素,导致操作与预期不符。尤其是酷家乐设计工具为大型单体应用,定位到多个相同文本元素的概率特别大。优点:使用简单。无需自己定义xpath。
方式二:传统式,为每个元素定义一个xpath
// 每个元素单独定义xpath
const undoButton = "//*[contains(@class,'TopBar-root')]//*[text()='撤销']"
const saveButton = "//*[contains(@class,'TopBar-root')]//*[text()='保存']"
const fileButton = "//*[contains(@class,'TopBar-root')]//*[text()='文件']"
await pyBell.click(undoButton)
await pyBell.click(saveButton)
await pyBell.hover(fileButton)
缺点:每个按钮都要定义一个xpath。重复内容多,维护成本高,可读性差。优点:非常精确。
方式三:xpath函数化
// 定义一个xpath,利用按钮名称的差异,支持所有按钮元素
const TopBarMenu = (name) => `//*[contains(@class,'TopBar-root')]//*[text()='${name}']`
await pyBell.click(TopBarMenu("撤销"))
await pyBell.click(TopBarMenu("保存"))
await pyBell.hover(TopBarMenu("文件"))
缺点:对多语言不友好(可忽略)。优点:非常精确。可读性高,使用简单。一个xpath覆盖所有按钮。大大降低了定义xpath的工作量。
有了xpath函数,还可以进一步将其与业务函数结合,让用例与xpath完全解耦。
// 定义xpath函数
const TopBarMenu = (name) => `//*[contains(@class,'TopBar-root')]//*[text()='${name}']`
// 定义顶部栏click函数
async function TopBarClick(menuName) {
// 可以加入一些其它逻辑
await pyBell.waitFor(TopBarMenu(menuName))
await pyBell.click(TopBarMenu(menuName))
}
// 定义顶部栏hover函数
async function TopBarHover(menuName) {
// 可以加入一些其它逻辑
await pyBell.waitFor(TopBarMenu(menuName))
await pyBell.hover(TopBarMenu(menuName))
}
// 函数使用
await TopBarClick("撤销")
await TopBarClick("保存")
await TopBarHover("文件")
await TopBarHover("渲染") // 支持顶部栏所有按钮。完全与xpath解耦。
2.2 DOM元素相对位置+xpath函数通过上面的技巧,就实现了通过 一个 xpath + 两个函数 覆盖了20+的按钮操作,大大降低了xpath编写量,并且提升了可读性、可维护性。
前面提到的场景中,元素都有中文名称,对于没有名称的input,也可以根据DOM元素相对位置找到关联关系。
举例:酷家乐设计工具中参数面板使用的频率较高,但是不同模型的参数名称都不一样,参数数量也不一样。如果对每个参数定义一个xpath,简直可怕。
思路:通过分析参数面板Dom结构特点,可以发现参数名元素与input元素之间的相对位置关系是确定的。所以可以想办法通过参数名定位到input元素。
以下有两种xpath写法:
// 方法一:根据子节点+层级关系 查找祖先节点
// 先找到参数名,然后找到参数名和input的共有祖先节点,然后定位到input
let input = (text)=>`//div[contains(@class,'FunctionPanel-root')]//*[text()='${text}']/../../..//input`
// 方法二:根据子节点+祖先节点关键字 查找期望的祖先节点
// 不用..的方式找祖先节点,通过ancestor关键字查找所有祖先节点,然后找到含关键字的最近的父类
let input = (text)=>`//div[contains(@class,'FunctionPanel-root')]//*[text()='${text}']/ancestor::div[contains(@class,'root')][1]//input`
// 函数使用。设置宽深高
await pyBell.keyboardType(input("宽度"), 200)
await pyBell.keyboardType(input("深度"), 100)
//再结合前面提到的函数技巧,就可以很轻松封装成下面这种函数:
await setParams({"宽度":"200", "深度":"100"})
2.3 定义xpath中文key,提升可读性通过以上的技巧,我们仅仅用了 1个xpath+1个函数 就搞定了参数面板所有input元素,无论该模型自定义了任何参数都能支持,大大降低了后续的维护工作量。
除了input以外,还有一类元素即没有名称也没有关联的名称。我们可以直接定义其通用的中文名。
举例:以酷家乐设计工具中的selectMenu举例,selectMenu存在许多按钮,每个按钮存在名称,但是xpath无法通过text定位。
// 方法一:通常我们会用英文key定义xpath,可读性比较差
const selectMenus = {
// 复制
copy: "//div[starts-with(@class, 'SelectMenu-content-wrapper_')]//i[@class='tui-icon tui-icon-select-menu-copy']",
// 删除
delete: "//div[starts-with(@class, 'SelectMenu-content-wrapper_')]//i[starts-with(@class, 'tui-icon tui-icon-select-menu-delete')]",
}
// 点击删除
await pyBell.click(selectMenus.delete)
// 点击复制
await pyBell.click(selectMenus.copy)
// 方法二:改用中文key定义
const selectMenus = {
"复制": "//div[starts-with(@class, 'SelectMenu-content-wrapper_')]//i[@class='tui-icon tui-icon-select-menu-copy']",
"删除": "//div[starts-with(@class, 'SelectMenu-content-wrapper_')]//i[starts-with(@class, 'tui-icon tui-icon-select-menu-delete')]"
}
// 定义一个selectmenu操作函数
async function selectMenuClick(menuName) {
// 内部可以加一些逻辑判断,提升稳定性
await pyBell.waitFor(selectMenus[menuName])
await pyBell.click(selectMenus[menuName])
}
// 用例中可实现与xpath解耦。如果有新增按钮,直接增加xpath定义即可,无需维护函数
await selectMenuClick("复制")
await selectMenuClick("删除")
2.4 顶层元素+关键字 定义通用xpath通过这个技巧,虽然并没有减少 xpath 编写量,但是提升了可读性,并且用例中可以实现与 xpath 完全解耦。
举例:酷家乐设计工具中有很多类似这种动态生成的按钮,我们采用的xpath方式是:顶层元素+文本名称 的方式。
// 通常可以找到这些元素最顶层的元素
const LeftPanel = "//*[starts-with(@class,'LeftSidePanel-root')]"
// 然后在该元素内部查找文本关键字。为了处理按钮名称重复的场景,增加一个index参数
const item = (name, index=1)=> `(${LeftPanel}//*[text()='${name}'])[index]`
// 简单的函数封装
async function ClickItem(name, index=1){
await pyBell.click(item(name,index))
}
// 函数使用
await ClickItem("组件库")
await ClickItem("查看全部")
await ClickItem("查看全部", 2) // 表示点击左侧面板中第2个 查看全部 按钮
2.5 合理的函数模块划分通过以上技巧,就实现用 1个函数+1个xpath 覆盖某个区域内所有按钮点击操作,并且不担心文本重复的问题。
函数封装一定是提升用例编写效率与质量的必备技巧。但是,当函数逐步增长,存在上百个函数时,一定会给“找寻合适的函数”带来很大的麻烦,反而会降低函编写效率。
所以,在封装函数前一定要做好合理的模块定义。
以酷家乐设计工具主场景为例,由于并没有page概念,所以我们将函数按照功能分为了多个模块:
TopBar:顶部栏相关函数。
LeftPanel:左侧面板相关函数。比如切换当前环境、搜索、拖出模型等。
RightPanel:右侧面板相关函数,比如切换房间,修改参数、获取参数。
SelectMenu:封装模型菜单操作。
ResourceManager:封装资源管理器操作。
我们针对每个公共模块都定义了几个基本的点击、文本获取、文本输入等通用函数,使用者只需传入正确的文本参数即可,无需关注元素 xpath。
使用举例:
三、总结
本次共分享了五个 xpath封装的技巧,虽然每个看起来都比较简单,但正是这些小技巧的合理运用帮助我们减少了大量 xpath 代码、提升了用例可读性与编写效率,并降低了维护成本。
以酷家乐定制业务线举例,由于一些公共模块的通用 xpath函数、业务函数较为完善,即使刚接触UI自动化几天的同学,也能快速上手编写 case。对新人而言,几乎无需关注元素定位,只需要寻找合适的函数进行调用即可,可读性也比较友好。如果有遇到需要维护元素定位、覆盖新功能元素,则让经验较为丰富的同学负责处理。
以上是本篇全部内容,如果你有任何想法或建议,欢迎在我们公众号聊天对话框中发送消息。
推荐阅读