开发 Web 自动化测试辅助工具 nopo 的历程

起因

做自动化 UI 测试,我最开始用的是 Selenium,用得算是比较精通,除了等待方面往往直接摆烂写 time.sleep()

后来,知道了 Page Object 模型(POM),这种将一系列对页面元素的操作封装起来的思路,让我感觉耳目一新。

不过,Selenium 的 WebElement 对象需要在能够找到对应元素的时候定义,而且太不稳定,页面有什么变化,马上就失效了。所幸后来知道了 poium 这个工具,能够事先定义元素、自动等待元素出现,挺好用的。我也试着查阅文档,把它和基于 unittestSeldom 测试框架一并使用。

但是,poium 有些不尽人意的地方:

  • 它向控制台输出的东西有点多,我为了让它在控制台输出的比较好看,每一个组件都要写详细信息,很麻烦。
  • 它查找到元素后,还要等上一段时间、在浏览器中圈出这个元素,浪费了很多时间。而我的工作很多情况下都是大量操作数据,这种速度显然不行。
  • 虽然给了比较详尽的 API,但是缺乏我需要的功能(如清空内容再输入文字——虽然这个可以写几行代码实现,但是我可能要写 20 多个文本框,每一个文本框写这么多代码太麻烦),想要引入就要重新写一个类,而且要调用私有方法,比较麻烦。

我曾经给 poium 发了 Pull Request,至少想添加一些常用的功能吧,但是石沉大海。既然这样,不如自己制作一个吧。

虽然如上面所说,我开发 nopo 的原因是不满足 poium 的功能和响应速度,但是实际上,poium 的设计思想也给了我很大的启发。

初始化元素类的传入参数

元素类主要是为了封装元素。新建时它不会去查找元素,只有在被调用时才在页面上查找给定的元素,并进行操作。

我这个人比较懒,这个类的名称直接写 El

poium 中,定义元素是用 选择器=选择器文本 的参数形式定义的。但是其中的 id 因为和内置方法重名,改为了 id_

1
2
search_input = Element(name='wd')
search_button = Element(id_='su')

虽然这看起来比较容易,不过我不是非常喜欢这样做,因为它的源码中,参数名并非预先定义好,而是在初始化时判断参数名是否正确。这样一来,存在以下的缺点:

  • 对编辑器不友好;
  • 写错、写多参数时,不容易被发现。

我的方式是:和 Selenium 的 find_element(By.选择器, 选择器文本) 一样,最开头的两个参数分别是选择器和选择器文本。这样调用 Selenium 也方便(我后来把 Selenium 的 By 也封装了):

1
2
3
4
5
6
7
class El:
def __init__(by: str = None, selector: str = None, ...): ...


# 实例化:
search_input = El(By.NAME, 'wd')
search_button = El(By.ID, 'su')

最大等待时长默认定为 10 s,不改也可以在初始化时设置 max_time 参数。

考虑到扩展性,我在 El 类的初始化方法中提供了可选的 el 参数。如果你新建了一个基于 El 类的自定义类 MyEl,想要转换已有的 El 对象 eMyEl 对象,直接如下处理:

1
e = MyEl(el=e)

driver 参数传入需要使用的 WebDriver 对象。这个我本来不想添加进去的,定义的时候直接读取多好。但是看起来并没有什么太好的方法不添加这个参数。

不过,我参考 poium 中的写法,变相实现了在定义的时候直接读取当前环境的 WebDriver:

1
2
3
def __get__(self, instance, owner):
self.driver = instance.driver
return self

如果你定义元素的时候,定义为类属性,那么就可以读取父实例的 driver 属性作为自己要调用的 WebDriver:

1
2
3
4
5
6
class Page:
def __init__(self, driver):
self.driver = driver

search_input = El(By.NAME, 'wd')
search_button = El(By.ID, 'su')

不能很好地解决这个问题,算是一种遗憾吧。

如果你看源码,可以发现还有一个参数 selectors。这个参数的用途我会在后面讲。

层叠选择器

对我来说,Selenium 有一个惊艳的地方,就是 WebElement 对象仍然可以调用 find_element() 方法,寻找与这个元素相关的元素:

1
2
3
4
5
e1 = driver.find_element(By.ID, 'id')
e2 = e1.find_elemnet(By.NAME, 'name')

# 等价于
e2 = driver.find_element(By.XPATH, '(//*[@id="id"])[1]//*[@name="name"]')

实际工作中,需要用到这个功能的场合并不罕见。我对这个功能没有什么太好的词语描述,就叫“层叠选择器”吧。

微软移植 Playwright 到 Python 上时,我抱着好奇的心态试了一下,发现它没法做到这一点;加上它对提示框的处理并不理想,我就没有用它。poium 也没有对层叠选择器的支持。我决定在 nopo 中,自己写出来。

借鉴 pathlib 对于路径的处理方式,我决定使用斜杠操作符 / 表示元素的层叠关系(包括自除操作符 /=——我写这篇文章的时候才想起来还有这个,于是补了一下):

1
2
3
e1 = El(By.ID, 'id')
e2 = e1 / El(By.NAME, 'name')
e1 /= El(By.NAME, 'name')

还记得我之前提到的 selectors 参数吗?我在初始化时,所有关于选择器的参数最终都会到 selectors 这个属性;如果传入 selectors 参数,就可以直接设置这个属性:

1
2
3
4
if not selectors:
self.selectors = ((by, selector_str),)
else:
self.selectors = selectors

使用 //= 操作符时,实际上是修改了 selectors 属性:

1
2
3
4
5
6
7
8
# /
def __truediv__(self, other):
return El(selectors=self.selectors + other.selectors, max_time=self.max_time, driver=self.driver)

# /=
def __itruediv__(self, other):
self.selectors = self.selectors + other.selectors
return self

查找元素时,从 selectors 属性中依次读取选择器和文本,并进行查找:

1
2
for selector in self.selectors:
web_elem = web_elem.find_element(*selector)

这样一来,就能够实现层叠的选择器了。

El 构成的 Els

Selenium 中,如果调用 find_elements 方法,返回的是一个由 WebElement 对象构成的列表。它有一个很明显的缺点:不稳定。

poium 中,实现 WebElements 的类 Elements 中,查找元素后,返回的也是这个列表。稳定的问题解决了一部分,元素操作还是很大程度上依赖于 Selenium 的。

我在设计 Els 类的时候,希望它能够像列表一样操作(包括迭代、切片),其子项为 El 类。这样一来,

  • 所有元素只在操作时查找,能够保证元素的稳定,同时尽量节省查找的时间;
  • 可以对子元素像单独的 El 类一样,使用自定义的功能。

好在 Python 在这方面能够做到很好的自定义。想要实现迭代和切片的功能,至少要定义下面的方法:

1
2
3
4
def __len__(self): ...              # 返回列表长度,可以使用 len() 调用
def __getitem__(self, item): ... # 返回切片,可以使用 a[i]、a[i:j]、a[i:j:k]
def __iter__(self): ... # 定义为迭代器
def __next__(self): ... # 迭代器下一项,与上项结合可以使用 for in

长度

返回列表长度不难,调用 Selenium 的 find_elements 方法,取长度即可,花不了太多时间。不过,我在这里进行了这样的配置:如果查不到元素,则等一段时间再查,到了预定时间仍然查不到,就算是 0。为了保险,我没有直接填 0,而是让它再查一次。

1
2
3
4
5
def __len__(self):
for _ in range(self.max_time * 4):
if len(self.driver.find_elements(By.XPATH, self.selectors_xpath)) == 0:
time.sleep(0.25)
return len(self.driver.find_elements(By.XPATH, self.selectors_xpath))

切片

切片是这里面最复杂的部分,主要是你要考虑到不同的情况。

__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
2
3
4
5
6
7
8
9
10
11
12
13
14
def __getitem__(self, item):
length = self.__len__()
if isinstance(item, slice):
return [self.__getitem__(i) for i in range(length)[item]]
elif isinstance(item, int):
if item >= 0:
xpath_index = item + 1
else:
xpath_index = length + item + 1
if xpath_index > length or xpath_index <= 0:
raise IndexError(f'index ({item}) out of range ({length})')
return El(By.XPATH, f'({self.selectors_xpath})[{xpath_index}]', driver=self.driver, max_time=self.max_time)
else:
raise TypeError(f'item must be slice or int, not {type(item)}')

迭代

迭代相对简单。定义一个迭代序号,达到长度时停止迭代,调用 __getitem__ 方法返回切片:

1
2
3
4
5
6
7
8
9
def __iter__(self):
self.__order = -1
return self

def __next__(self):
self.__order += 1
if self.__order >= self.__len__():
raise StopIteration()
return self.__getitem__(self.__order)

自动等待获取元素

我把获取元素写为如下情况:

  • 即刻获取元素
  • 等待到元素出现再获取元素
  • 等待到元素可点击再获取元素

如下:

1
2
3
4
5
6
7
8
9
10
11
12
@property
def elem_no_wait(self): ...

@property
def elem(self):
...
return self.elem_no_wait

@property
def elem_clickable(self):
...
return self.elem_no_wait

自动等待写得比较简单,用的是 Selenium 的等待方法,只看第一个选择器:

1
2
3
4
5
6
7
8
9
10
def wait_for_click(self):
"""Wait until the element is clickable."""
wait = WebDriverWait(self.driver, self.max_time)
wait.until(ec.element_to_be_clickable(self.selectors[0]))


def wait_for_present(self):
"""Wait until the element is present."""
wait = WebDriverWait(self.driver, self.max_time)
wait.until(ec.presence_of_element_located(self.selectors[0]))

对元素的操作

到了这里就简单不少了,按着 Selenium 的方法写就行了,想扩展的按照自己想的来写。

唯一的问题是写的很多,比较累。

加上自动等待获取元素,目前实现了以下功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
el.text             # 元素的文本
el.value # 返回 el.text 的值。如果 el.text 为 None 或 '',返回 value 属性(多用于 input 元素)
el.exist # 即刻判断元素是否存在
el.exist_wait # 判断元素是否存在,支持自动等待
el.is_selected # 判断元素是否被选择。
el.selectors_xpath # 返回元素的 XPath 选择器的值
el.elem # 返回元素的 WebElement 实例,支持自动等待到在 DOM 里出现
el.elem_clickable # 返回元素的 WebElement 实例,支持自动等待到可点击
el.elem_no_wait # 即刻返回元素的 WebElement 实例

el.options # 针对于 select 标签的元素,返回属于该元素的所有选项
el.all_selected_options # 针对于 select 标签的元素,返回属于该元素的所有已选选项
el.first_selected_option # 针对于 select 标签的元素,返回属于该元素的第一个已选选项

el.click() # 单击元素
el.clear(force=False) # 清除元素内文本(比如 input 元素). 设置 force=True 确保元素内文本清除干净,也就是强制模式(在某些场合适用)
el.send_keys(keys, clear=False, force_clear=False) # 向元素输入按键或文本。如果 clear 为 True,则输入前会清空元素内文本。如果 clear 和 force 均为 True,清除方法进入强制模式
el.csk(keys, force_clear=False) # 清空元素内文本,并向元素输入按键或文本。如果 force_clear 为 True,清除方法进入强制模式
el.nn_csk(keys, force_clear=False) # 如果keys 非 None,则清空元素内文本,并向元素输入按键或文本。如果 force_clear 为 True,清除方法进入强制模式
el.get_attribute(attr) # 获取元素的参数(attribute,偏向于 HTML 层面)
el.get_property(property_text) # 获取元素的属性(property,偏向于 JS 层面)
el.wait_for_click() # 等待到元素可点击
el.wait_for_present() # 等待到元素出现

el.select_by_value(value) # 针对于 select 标签的元素,根据给定 value 值选择选项
el.select_by_index(index) # 针对于 select 标签的元素,根据给定 index 值选择选项
el.select_by_visible_text(text) # 针对于 select 标签的元素,根据给定显示文本选择选项
el.deselect_all() # 针对于 select 标签的元素,取消选择所有内容
el.deselect_by_value(value) # 针对于 select 标签的元素,根据给定 value 值取消选择选项
el.deselect_by_index(index) # 针对于 select 标签的元素,根据给定 index 值取消选择选项
el.deselect_by_visible_text(text) # 针对于 select 标签的元素,根据给定显示文本取消选择选项

el.switch_in() # 针对于 iframe 标签的元素,切换到该框架

El.single_selector_to_xpath(by, selector) # 将单个选择器转换为 XPath

项目

我写了这么多,自然也是发表为项目了。大家可以在 GitHub 或 Gitee 上 Fork、Star 或提问题:

我也把它的包放到了 PyPI 上,可以用 pip 进行安装:

1
pip install nopo

这个项目也被我用在工作中半年了,我的一个同事也在用。他知道这个模块,是在看我写的代码中得知的,我一开始还没有说是我自己开发的。一天,他问我为什么查不到关于 nopo 的信息,这时,我才说是我自己开发的。

为自己的项目写文档、写开发历程和心得是很重要的。