专栏文章 UI 自动化中的分层设计

孙高飞 · 2021年11月07日 · 最后由 丁剑 回复于 2023年05月10日 · 15900 次阅读

背景

2,3 年前更写过一些 UI 自动化的相关文章, 包括一些设计原则,怎么设计划分页面封装, 常用的设计模式等。 但是没有详细描述 UI 自动化中的分层理念, 赶上最近在新项目里做 UI 自动化测试并且今天有时间,所以补充一下 UI 自动化中比较重要的分层设计。 以前的帖子链接如下:
UI 自动化军规: http://testerhome.com/topics/15540
UI 自动化常用设计模式(一):http://testerhome.com/topics/15768
UI 自动化常用设计模式(二):http://testerhome.com/topics/16042

以前的设计

在过去 UI 自动化测试领域有一个规范的设计模式是 page object 模式。 意思是测试用例不会直接定位页面元素, 而是把每一个页面封装成一个类。 在这个类中封装页面元素。 然后测试用例调用 page 类来操作页面元素完成测试用例。如下图:

但这个模式已经诞生了差不多 20 年了。 它是以当时的前端开发模式为基础进行规定的。 而 20 年间前端技术和研发模式已经发生了很大的变化,这个模式已经不再适用当前的 UI 自动化项目了。所以我在我的项目用将上面的模式做了一些改良

改良后的设计

组件层

当下前端框架都有组件库的概念。 目的是封装通用的页面元素来让各个页面统一调用。 避免了重复开发的问题并且保持风格统一, 比如我之前在写前端项目的时候也会封装组件库, 然后在各个页面调用组件库中的组件。 我拿我之前写的前端项目举例:

如上图中在一个前端项目中, 会专门有一个叫 component 的目录(第一张图),该目录里会封装常用的页面组件,比如 button,input,dropdown,link 等。 而每个组件都会编写独立的逻辑代码和 css 代码(第二张图)。 最后在页面中通过调用组件库的组件来完成页面逻辑(第三张图, 页面中调用了第二张图里展示的 LinkButton)。 所以我们在产品界面中才会发现很多控件长得都一样, 连属性都一样, 比如 class 里的值都是一样的。 这是因为他们其实是完完全全的同一个组件。 PS:在 CSS 中比较常用 class 来定位控件。 所以我们在控件中一般能看到各种 class 定义。 这就是现代前端项目的开发模式, 我们总能听到前端研发工程师们说的组件化,其实就是这个了。 这也是为什么我们会专门抽象出一层组件层的原因。 如果研发在迭代过程中修改了组件库, 那么会影响所有的页面。 一般这对 UI 自动化来说是一个很大的打击。 所以为了避免这种情况出现,同时为了简化页面定位成本。 所以 UI 自动化项目中也要对应的封装出一个组件层来专门做元素的定位和操作。 比如我之前曾经在产品中见过这样的情况, 之前我们的 button 都是下面这个样子的。

<button> 确定 </button>

但是突然研发修改了 button 组件库。 于是所有的 button 就变成了下面这个样子:

<button>
    <span> 确定 </span>
</button>

在修改之前一切很美好, 我们只需要用 bytext 方法或者用一个 xpath: //button[text()='确定'] 就能很方便的找到这个 button 并点击. 但某一天研发的组件库变了, button 不在有文案了, 而是在 button 里加了个 span 的子元素放置文案。 于是在 UI 自动化项目中以前所有页面的所有 button 定位和操作都挂了, 都需要改一遍。所以我们一般在 UI 自动化中通过增加一个组件层来解决这个问题。 如下图:

如上图, 在我们的测试代码中专门有一个名字叫 component 的目录, 里面根据控件的类型划分了很多文件, 每个文件里定义着这个类型的控件常用的定位和操作方式。 第二张图中我们看到的是 input 这个组件在产品中通用的逻辑。 比如拿 form_upload 这个控件来解释, 在产品中绝大部分上传文件的控件是由 input 组件来封装的。 点击上传操作其实触发的就是往一个属性 type=file 的 input 元素写入文件路径。 所以其实所有页面的上传操作都可以调用这个逻辑完成。 特别需要注意一点, 组件层不仅仅是封装元素的定位逻辑的, 也会封装一些常用操作。 还是拿上传控件来说, 用户在前端是要点击上传这个 button 后跳出一个文件选择框来完成上传操作的。 但是这个逻辑在 UI 自动化中是不可行的, 因为跳出这个文件选择框并不是浏览器的行为,而是当前操作系统的行为。 所以这个框实际上不受 webdriver 控制,并且不同的操作系统下这个框的样子也是不一样的。 所以在 UI 自动化中,我们只能通过给这个 input 元素直接写入上传的文件路径来触发文件操作。 但是这个 input 元素的 css 代码中设置了 style.display=none, 把这个 input 元素设置为了隐藏, 而 webdriver 是没办法与隐藏元素交互的。 所以我们需要先通过嵌入一段 js 代码, 把这个 input 修改成可见元素才能完成剩下的操作。 通过这个案例我想说明在组件层除了封装元素的定位方式外, 也会封装元素的操作方式。

原则上,我们所有的页面元素的交互都需要通过调用组件层的函数来完成, 一个是加快元素定位的速度 -- 调用者不再需要自己定位了, 只需要调用组件层的方法,传递参数即可。 第二个是通过统一调用, 一旦后续前端组件库发生变化,导致元素定位方式也要改变。 我们只需要修改组件层非常少量的代码即可。 而不是研发动了一行代码, 测试项目这边需要成片的逻辑修改。

页面层

页面层与老式的 page object 模式中的页面层基本类似。 一个页面封装成一个 class, 在这个 class 里定义所有的元素和对应的操作。 只不过这类对元素的定位工作基本都是通过调用组件层的能力来完成的。 如下图:


可以看到,一个页面上的所有元素都是定义在这个 class 里面的。 并且这些元素的定位都是通过组件层的方法来完成的。 我们在文件开头就会引入这些组件库来帮助页面层来完成元素的定位于操作。 同时建议页面层文件不仅仅是封装单个元素的定位, 也可以封装页面里常用的操作。 我们推荐这种封装方式, 我们应该尽量避免在 case 中直接调用页面元素操作。 原因有二

  • 代码复用, 大量 case 其实都需要调用相同的逻辑,封装成可复用的代码可以减少 case 编写成本
  • 当页面元素出现变化,比如增删元素的时候。 可只修改封装的逻辑, 而不必大量修改 case。

组件层封装基本控件,应对前端组件库的变化, 而页面层就是对产品界面进行封装, 应对的是页面逻辑发生的变动

业务逻辑层

在我们的代码里, 每个模块都有 service 文件, 这个文件就是封装的业务逻辑的文件。 我们在页面层上封装的逻辑都是单页面逻辑。 而在测试中我们往往要执行一个很复杂的业务流程。 为了执行这样一个业务流程我们跳转了很多个页面。 所以我们有必要单独封装这样一层来把业务逻辑进行封装。 这里需要提一下, 业务逻辑层相当于页面层的上层封装。 封装了完成一个业务逻辑的所有操作,比如我们假设创建一个工作流, 需要先填写基本配置, 然后跳转到高级配置页面继续填写, 填写玩后点击确定显示创建成功, 到了这里还没结束,还需要等待这个工作流程执行到某个状态,比如运行成功状态。 这里面需要跳转多个页面,甚至还需要给定一个超时时间并轮询工作流程的状态。 为什么要封装这么一层呢, 因为必然有非常多的 case 去完成这样一个流程。

需要注意的是, 我们每个模块都需要封装自己的业务逻辑层。 因为一个产品里必然有非常多的模块并且这些模块之间是有依赖关系的。 比如我在之前讲大数据的时候介绍过, 一个大数据平台的产品中做什么事都需要先把外部数据源中的数据同步到本系统里对吧, 所以数据中心的测试人员就的把自己模块的业务逻辑封装一下给其他模块的人用。 同样的,模型中心模块的人要测试得先有个模型对吧, 那用来建模的建模中心就得把自己的业务逻辑封装好了给其他人用。 在 UI 自动化中往往就是这样的,各个模块之间需要互相配合,互相调用。 因为就是有很多 case 是横跨多个模块,链路非常长非常复杂的。

用例层

用例层就没啥好说的, 就是写 case 而已。 只不过在我的这个设计里, 用例层很轻, 大部分逻辑都已经被上面的 3 层做完了。 用例层大多数时候再调用业务逻辑层, 少数情况调用页面层。 所以 case 层很轻,代码很少。 没什么可以特别强调的。

总结

就写到这吧, 最近又开始写 UI 自动化所以有些感受, 组织了一篇帖子跟大家探讨一下。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 31 条回复 时间 点赞

测试

感觉你已经用了前端,既然是 UI 自动化,又与前端相关,可以用 JavaScript 快、狠、优雅的实现你想要的一切。

大佬,你好,想问一下有没有这个 UI 结构的开源框架呢,想学习一下,组要是组件层的部分不太明白怎么去写

项目结构能否截图看下哈 高飞老师

黄clown 回复

组件层的主要功能已经讲解得比较清楚了,定位以及常用功能。是利用组件的特征以及传入关键参数去定位,常用方法的话就是避免在 PO 里面写太多控件操作的方法,就直接把操作方法写到组件层,比如一个 Form 的填充、提交,Table 的数据获取,排序,切换分页,筛选等等,把 PO 里面有关组件的属性获取和操作方法等下沉到组件层。

飞哥之前的 UI 自动化文章都仔细品读过,理论 + 实践完美结合,读完后理清了许多概念,了解了许多能解决痛点的解决方案。这次的文章也是一样,分层清晰,组件层有创新且能解决痛点,最难得的是能去改善传统的架构,总结经验并分享。这些优质文章,对于我这样写了几年代码,各方面都有涉猎但水平局限于熟练/精通的人来说,作用非常大,些微思想上的启迪和论证比写几个月的项目还有用。

最近自己也在写一个接口自动化 + 数据工厂结合的项目,分层也几乎差不多,传输层做各个系统的传参、鉴权等,对应组件层,接口层对应 PO,服务层差不多一样,之上还有测试用例层和数据脚本层。

我接触的 UI 自动化还停留在传统的 PO 封装上,看完此文后不知道我下面的表述是否正确:

  1. 组件层:这层基本上都是控件定位的封装,简单来说可能是各种 find、locate elements 等控件定位获取的操作?
  2. 页面层:基于组件层对所有控件定位的封装,在这一层就是把相同页面的控件整合在一起呈现一个页面的类,看起来似乎很薄?
  3. 业务逻辑层:这一层比较好理解,一个或多个页面上有业务一样的操作,比如账号登录是一个业务逻辑(当然也能说是一个用例,这里举例可能不够合适),在这一层给封装起来
  4. 用例层:很好理解,如果按照这样的模式,写用例基本就是调函数了

学习一下自动化用例的设计模式

久闻大佬大名!
想请教大佬一个 UI 数据准备的问题:
如果一个 用例 需要校验的步骤很靠后,也就是 在一个很长的流程的 后期阶段, 这个时候你准备数据是怎么准备的 ?
如果从头开始准备,必定会造成 用例执行时间特别长

黄clown 回复

结构很简单的

🔥🔥🔥 回复

看上面截图哈

frankxii 回复

以后多交流~ 我在写 API 测试的时候, 做法也跟你差不多。😀

王稀饭 回复
  • 组件层: 跟你说的差不多, 只不过除了定位以外,还有一些基本操作。 比如我举的上传的例子。 有些控件的操作还是很复杂的。
  • 页面层和业务逻辑层: 可以这么理解, 页面层是封装单页操作的, 业务逻辑层是调用页面层的多个 class 完成多页面操作以组合成业务逻辑。 比如我们要测试一个订单流程。 这个流程里肯定跳了很多个页面的, 每个页面在页面层里封装自己页面的操作逻辑。 而业务逻辑层就是调用这些页面来完成自己的业务逻辑。 所以理论上, 业务逻辑层才是哪个比较轻的层。
  • 用例层: 确实用例层就是调调函数。
树叶 回复

有多种方法解决。 比如可以通过直接在数据库中构造测试数据绕过之前的步骤。 不过我其实还是比较喜欢就是在 UI 上从头开始准备。 模拟真实用户的行为。 开发成本也较低, 有些后台数据过于复杂, 绕过 UI 和 API 直接在数据库中 mock 实在不太现实。

孙高飞 回复

是的,我目前也是这么操作的,只是执行时间有点长而已

反复翻过飞哥之前的自动化帖子,本文和上个项目做的思路一致,最近在搞 python 了么

aquichita 回复

嗯, 新项目用的 python, 没办法, 团队的小伙盘都熟 python, java 不熟。

如果是以图像识别为主的 UI 自动化项目,有没有什么好的设计模式

可以请教下吗?我使用 selenium 可以正常的打开 chrome 浏览器。但是使用 selenide 却打不开 chrome 浏览器

selenium 运行结果是成功的

其中 selenium 依赖也配置了

自娱自乐 回复

已解决~

和 react 项目的结构对应起来了。

现在在用 Katalon, 模式和这个差不多。

  1. 控件定位支持参数化。
  2. 基于控件的操作封装。 --支持参数化
  3. 基于基础操作的常见逻辑封装组合。 --支持参数化
  4. 基于 2 和 3 的组合形成用例。(加一些参数)。
  5. 另外通过提供的关键字接口封装所谓的常用工具类方法。
回复内容未通过审核,暂不显示

之前做过类似的框架。但是没想到组件公用这一层。现在自己也会写一些前端以后发现,确实是可以在用例中独立一个组件层出来,正常前端用的也就是那些组件的拼装。
后期可以尝试下,这种新的方式。维护量应该会变小

你好,Hybrid App 中的 H5 页面也是用吗?

你好,关于组件层的一个问题想请教一下
比如当前页面上有多个 button,用 type[button] 都能筛选出,如何比较好的去封装这样的组件呢

大佬有没有开源 demo 学习一下呢

学习了~pick

时隔大半年,自己在这中间也在公司实践 UI 自动化,结合自己的经验和网上的案例与理念,造了一些轮子。

一、组件化:

class Page(metaclass=ABCMeta):

    def __init__(self, driver: Chrome):
        self._driver = driver
        # 每一个页面都有独立的组件,所以page方法不能覆盖,需要全部调用一遍,这里是遍历继承链,把所有父类和本身的page方法都执行一遍
        for base in self.__class__.__mro__[::-1]:
            if issubclass(base, Page):
                base.__page__(self)

    @abstractmethod
    def __page__(self):
        pass

    # 使用self.btn_submit = Element()时调用,注入driver到component
    def __setattr__(self, name, component):
        if issubclass(component.__class__, Component):
            component.set(driver=self._driver)
        super().__setattr__(name, component)


class LoginPage(Page):
    def __page__(self):
        self._input_username = Input("请输入用户名")
        self._input_password = Input("请输入登录密码")
        self._btn_login = Button("登录")

    def login(self, username: str = "admin", password: str = "admin"):
        self._input_username.send_keys(username)
        self._input_password.send_keys(password)
        self._btn_login.click()


# 使用方式
# LoginPage(driver).login()

实际项目中封装的组件很多,除输入框、按钮外、还有日期选择,日期范围选择、表格、下拉等,适配 element、antd 等各个前端框架。组件化的核心理念在于类内部存储定位方式,使用惰性加载的方式后置传 driver,执行操作时动态查找元素,还用到了 xpath 的或条件等增加组件的兼容性。

二、链式调用:
这个其实使用范围很广了,但我看很多人写 UI 自动化还是没按这个理念去设计,所以还是贴一下,可以简化代码和增加可读性。

# 链式调用示例
page = ProductIndexPage(driver)
(
    page.navigate_to(PriceCalculatorPage)
        .add_product_info("电脑整机", "笔记本", "戴尔", "E7240")
        .choose_product_level("A")
        .choose_product_config({"CPU": "i7 8代", "内存": "4G"})
        .choose_product_property()
        .choose_other_property(is_new=True, is_return_after_rental=True, use_channel=0)
        .fill_in_price_valid_date(5000, 5000, False, "厂商", (3, 7, "天"))
        .fill_in_residual_value(5000)
        .compute()
        .save()
)

# 方法内部实现
class PriceCalculatorPage(ProductIndexPage):
    def choose_product_level(self, level: str):
        """选择商品等级

        Args:
            level: A、B、C
        """
        self._select_product_level.select_by_text(level)
        return self
    def compute(self):
        """点击计算"""
        self._btn_compute.click(delay=1)
        self.scroll_to_end()
        return self

@frankxii 大佬整个代码可以开源吗 很想学习一下

组件层的 driver 怎么传的

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册