开发 Web 自动化测试辅助工具 nopo 的历程
开发 Web 自动化测试辅助工具 nopo 的历程
丁俊尧起因
做自动化 UI 测试,我最开始用的是 Selenium,用得算是比较精通,除了等待方面往往直接摆烂写
time.sleep()
。
后来,知道了 Page Object 模型(POM),这种将一系列对页面元素的操作封装起来的思路,让我感觉耳目一新。
不过,Selenium 的 WebElement 对象需要在能够找到对应元素的时候定义,而且太不稳定,页面有什么变化,马上就失效了。所幸后来知道了 poium 这个工具,能够事先定义元素、自动等待元素出现,挺好用的。我也试着查阅文档,把它和基于 unittest 的 Seldom 测试框架一并使用。
但是,poium 有些不尽人意的地方:
- 它向控制台输出的东西有点多,我为了让它在控制台输出的比较好看,每一个组件都要写详细信息,很麻烦。
- 它查找到元素后,还要等上一段时间、在浏览器中圈出这个元素,浪费了很多时间。而我的工作很多情况下都是大量操作数据,这种速度显然不行。
- 虽然给了比较详尽的 API,但是缺乏我需要的功能(如清空内容再输入文字——虽然这个可以写几行代码实现,但是我可能要写 20 多个文本框,每一个文本框写这么多代码太麻烦),想要引入就要重新写一个类,而且要调用私有方法,比较麻烦。
我曾经给 poium 发了 Pull Request,至少想添加一些常用的功能吧,但是石沉大海。既然这样,不如自己制作一个吧。
虽然如上面所说,我开发 nopo 的原因是不满足 poium 的功能和响应速度,但是实际上,poium 的设计思想也给了我很大的启发。
初始化元素类的传入参数
元素类主要是为了封装元素。新建时它不会去查找元素,只有在被调用时才在页面上查找给定的元素,并进行操作。
我这个人比较懒,这个类的名称直接写 El
。
poium 中,定义元素是用 选择器=选择器文本
的参数形式定义的。但是其中的 id
因为和内置方法重名,改为了
id_
:
1 | search_input = Element(name='wd') |
虽然这看起来比较容易,不过我不是非常喜欢这样做,因为它的源码中,参数名并非预先定义好,而是在初始化时判断参数名是否正确。这样一来,存在以下的缺点:
- 对编辑器不友好;
- 写错、写多参数时,不容易被发现。
我的方式是:和 Selenium 的
find_element(By.选择器, 选择器文本)
一样,最开头的两个参数分别是选择器和选择器文本。这样调用 Selenium
也方便(我后来把 Selenium 的 By 也封装了):
1 | class El: |
最大等待时长默认定为 10 s,不改也可以在初始化时设置
max_time
参数。
考虑到扩展性,我在 El
类的初始化方法中提供了可选的
el
参数。如果你新建了一个基于 El
类的自定义类
MyEl
,想要转换已有的 El
对象 e
为
MyEl
对象,直接如下处理:
1 | e = MyEl(el=e) |
driver
参数传入需要使用的 WebDriver
对象。这个我本来不想添加进去的,定义的时候直接读取多好。但是看起来并没有什么太好的方法不添加这个参数。
不过,我参考 poium 中的写法,变相实现了在定义的时候直接读取当前环境的 WebDriver:
1 | def __get__(self, instance, owner): |
如果你定义元素的时候,定义为类属性,那么就可以读取父实例的
driver
属性作为自己要调用的 WebDriver:
1 | class Page: |
不能很好地解决这个问题,算是一种遗憾吧。
如果你看源码,可以发现还有一个参数
selectors
。这个参数的用途我会在后面讲。
层叠选择器
对我来说,Selenium 有一个惊艳的地方,就是 WebElement
对象仍然可以调用 find_element()
方法,寻找与这个元素相关的元素:
1 | e1 = driver.find_element(By.ID, 'id') |
实际工作中,需要用到这个功能的场合并不罕见。我对这个功能没有什么太好的词语描述,就叫“层叠选择器”吧。
微软移植 Playwright 到 Python 上时,我抱着好奇的心态试了一下,发现它没法做到这一点;加上它对提示框的处理并不理想,我就没有用它。poium 也没有对层叠选择器的支持。我决定在 nopo 中,自己写出来。
借鉴 pathlib
对于路径的处理方式,我决定使用斜杠操作符 /
表示元素的层叠关系(包括自除操作符
/=
——我写这篇文章的时候才想起来还有这个,于是补了一下):
1 | e1 = El(By.ID, 'id') |
还记得我之前提到的 selectors
参数吗?我在初始化时,所有关于选择器的参数最终都会到
selectors
这个属性;如果传入 selectors
参数,就可以直接设置这个属性:
1 | if not selectors: |
使用 /
和 /=
操作符时,实际上是修改了
selectors
属性:
1 | # / |
查找元素时,从 selectors
属性中依次读取选择器和文本,并进行查找:
1 | for selector in self.selectors: |
这样一来,就能够实现层叠的选择器了。
由 El
构成的
Els
Selenium 中,如果调用 find_elements
方法,返回的是一个由
WebElement
对象构成的列表。它有一个很明显的缺点:不稳定。
poium 中,实现 WebElements
的类 Elements
中,查找元素后,返回的也是这个列表。稳定的问题解决了一部分,元素操作还是很大程度上依赖于
Selenium 的。
我在设计 Els
类的时候,希望它能够像列表一样操作(包括迭代、切片),其子项为
El
类。这样一来,
- 所有元素只在操作时查找,能够保证元素的稳定,同时尽量节省查找的时间;
- 可以对子元素像单独的
El
类一样,使用自定义的功能。
好在 Python 在这方面能够做到很好的自定义。想要实现迭代和切片的功能,至少要定义下面的方法:
1 | def __len__(self): ... # 返回列表长度,可以使用 len() 调用 |
长度
返回列表长度不难,调用 Selenium 的 find_elements
方法,取长度即可,花不了太多时间。不过,我在这里进行了这样的配置:如果查不到元素,则等一段时间再查,到了预定时间仍然查不到,就算是
0。为了保险,我没有直接填 0,而是让它再查一次。
1 | def __len__(self): |
切片
切片是这里面最复杂的部分,主要是你要考虑到不同的情况。
__getitem__
方法有传入值
item
,表示切片中传入的值。重点在于,这个值的数据类型不是固定的。如果是
a[1]
/ a[-1]
这样的,就是整数(int
);如果是
a[1:2]
、a[1:2:3]
这样的,就是切片(slice
)类型。
这里简单说一下切片类型。比如执行
a[1:2:3]
时,方括号里面就是一个切片类型,相当于
slice(1, 2, 3)
:
1 | a[1:2:3] == a[slice(1, 2, 3)] |
想要获取它对应的序号,可以通过 range()
方法:
1 | range(len(a))[slice(1, 2, 3)] |
写的时候,先把整数类型的写好,切片类型的情况可以通过递归解决。
因为取长度在这里用的比较多,而且取长度相对比较耗时间,所以我只在开头取一次,之后用的都是开头取的值。
取每一项的时候,我先是转换为 XPath 选择器,再定义下标。XPath 有一个和绝大多数编程语言不一样的情况:下标从 1 开始。这确实花了我不少时间。
还有一点,虽然可以填负数表示从右往左的顺序,但是 Python 不会帮你处理负数的情况,你要自己去写。
这里附上最终的代码:
1 | def __getitem__(self, item): |
迭代
迭代相对简单。定义一个迭代序号,达到长度时停止迭代,调用
__getitem__
方法返回切片:
1 | def __iter__(self): |
自动等待获取元素
我把获取元素写为如下情况:
- 即刻获取元素
- 等待到元素出现再获取元素
- 等待到元素可点击再获取元素
如下:
1 |
|
自动等待写得比较简单,用的是 Selenium 的等待方法,只看第一个选择器:
1 | def wait_for_click(self): |
对元素的操作
到了这里就简单不少了,按着 Selenium 的方法写就行了,想扩展的按照自己想的来写。
唯一的问题是写的很多,比较累。
加上自动等待获取元素,目前实现了以下功能:
1 | el.text # 元素的文本 |
项目
我写了这么多,自然也是发表为项目了。大家可以在 GitHub 或 Gitee 上 Fork、Star 或提问题:
我也把它的包放到了 PyPI 上,可以用 pip 进行安装:
1 | pip install nopo |
这个项目也被我用在工作中半年了,我的一个同事也在用。他知道这个模块,是在看我写的代码中得知的,我一开始还没有说是我自己开发的。一天,他问我为什么查不到关于 nopo 的信息,这时,我才说是我自己开发的。
为自己的项目写文档、写开发历程和心得是很重要的。