Appium UI 自动化到底要不要用 Page Object 模式?(续 - 深入了解 PO 模式, 并改造 PO 模式)

小怪兽 · 2019年07月17日 · 最后由 xiaoqi_11 回复于 2021年01月13日 · 1238 次阅读
本帖已被设为精华帖!

背景

前几天发布了一个话题 “ UI 自动化到底要不要用 Page Object 模式?“ 原帖子:https://testerhome.com/topics/19831
大家给出了很多观点, 有倾向于 po 模式, 也有建议根据不同项目场景自行处理,最后还是一知半解,并没有搞懂,于是集中深入的了解了下 Page Object 模式

Page Object 模式 python webdriver 版本

这里介绍下我近期对 PO 模式的理解, 整体思想是分层,让不同层去做不同类型的事情,让代码结构清晰,增加复用性
一般分两层或三层(也有四层的):

两层:

  • 对象逻辑层
  • 业务数据层

三层:

  • 对象库层
  • 逻辑层
  • 业务层

四层:

  • 对象库层
  • 逻辑层
  • 业务层
  • 数据层

不同层本质差不多
下面以登录为例子 (网上绝大多数都是以登录为例子,但登录只能让新手明白 PO 大概是怎样子,优势却很难传递出来)
普通方式:

def test_user_login():
    driver = webdriver.Edge()
    base_url = 'https://mail.qq.com/'
    username = '3494xxxxx'  # qq号码
    password = 'kemixxxx'  # qq密码
    driver.get(base_url)
    driver.switch_to.frame('login_frame') #切换到登录窗口的iframe
    driver.find_element(By.ID, "u").send_keys(username) #输入账号
    driver.find_element(By.ID, "p").send_keys(password) #输入密码
    driver.find_element(By.ID, "login_button").click()  #点击登录

PO 模式

对象库层

#创建基础类
class BasePage(object):
    #初始化
    def __init__(self, driver):
        self.base_url = 'https://mail.qq.com/'
        self.driver = driver
        self.timeout = 30

    #打开页面
    def _open(self):
        url = self.base_url
        self.driver.get(url)
        self.driver.switch_to.frame('login_frame')  #切换到登录窗口的iframe

    def open(self):
        self._open()

    #定位方法封装
    def find_element(self,*loc):
        return self.driver.find_element(*loc)
#创建LoginPage类
class LoginPage(BasePage):
    username_loc = (By.ID, "u")
    password_loc = (By.ID, "p")
    login_loc = (By.ID, "login_button")

    #输入用户名
    def type_username(self,username):
        self.find_element(*self.username_loc).send_keys(username)

    #输入密码
    def type_password(self,password):
        self.find_element(*self.password_loc).send_keys(password)

    #点击登录
    def type_login(self):
        self.find_element(*self.login_loc).click()

逻辑层

#创建test_user_login()函数
def user_login(driver, username, password):
    """测试用户名/密码是否可以登录"""
    login_page = LoginPage(driver)
    login_page.open()
    login_page.type_username(username)
    login_page.type_password(password)
    login_page.type_login()

业务层

def test_user_login():
    driver = webdriver.Edge()
    username = '3494xxxxx'    #qq号码
    password = 'kemixxxx'    #qq密码
    test_user_login(driver, username, password)

分析

一 代码量多了大概三倍, 代码量增加是一定的先忽略,后面重点讨论

二 分层之后真的易于维护吗?

我们来看下当元素发生变化的时候,只需要在对象库层找打对应元素修改。 咦? 你会说普通方式不也一样吗, 看上去一样,其实有细微差异,而一些细微差异会导致很大不同

  • 效率高 : PO 模式每个元素有变量定义,更方便查找。 而普通方式得通过备注或上下文来推断效率低。 ps:随着 case 不断增加,海量元素的定义对于英语一般的同学挑战也大,有人说有谷歌翻译。定义的时候可以通过翻译, 但到时候回过来查过元素怎么办? 翻译通常是 1 对多,我们当时选哪个? 用哪个来搜索? 这或许也是海量变量定义带来的困扰
  • 复用多收益大: 当某个元素被多次引用的时候,只需要修改一处便可,而普通方式需要一处一处找出来并修改,可以看出来复用越多 PO 模式收益越大

当界面需求发生变化

  • 1.新增或删除了一些功能点或调整操作步骤先后顺序,但上层业务不变
    • 效率高 :同理,PO 模式的逻辑层方法有具体定义,情况和元素发生变化一样 修改逻辑层,业务层不变。这样看来结构简单清晰,舒服更符合人类习惯, 普通方式就是继续堆 case
    • 复用多收益大: 同样这里如果逻辑复用越多,PO 模式收益越大,因为对于 PO 模式来说都只需要修改一个地方多处受益
    1. 上层业务发生变化

    看上去两者差异不大

小结

总得来看:

  • case 越多使用 PO 模式会使你的代码结构更清晰
  • 元素复用越多 PO 模式下维护非常容易
  • 逻辑复用越多 PO 模式下维护非常容易(如果逻辑复用多,需要多考虑逻辑层的颗粒度)
  • 元素/逻辑/数据复用越多应选择更多层的 PO 模式
PO 模式 普通方式
代码量 a*N(a>=2) N
可阅读性 很差
维护性

好,我们再回过头来看看代码量大的问题,有没有办法精简一些呢? 把 a*N 中的 a 变成 1.8, 1.5, 1.2, 甚至接近 1 呢?
开始下一轮探索:

探索 代码量大的问题

以三层 PO 为例我们大概的流程是这样的:
在对象库层,我们定义了元素,再为元素定义了一些基本的操作流, 在逻辑层 为集成了基本操作流,在业务层 组装逻辑 和 数据输入
看上去 第二 第三步骤 有点重复 能不能去掉? 如果只剩下第一 四个步骤 那代码量瞬间就下来了 那该有多爽

试试看

如果去掉第二/三步骤,那意味着我们只需要定义元素,并在业务层需要指定操作的时候再自动生成对应所需操作。即需要时生成,用完后丢弃。
这里需要用到 python 下面的魔法方法 "getattribute"
思路:
在访问类 App 属性时挡截下来,历遍对象库层找到对应元素返回对应的对象类 App.LoginPage,而对象库层都继承了 BasePage 类,在 BasePage 中同样重构了"getattribute",当 App.LoginPage 对象尝试调用 click() 之类的方法时,就临时绑定 click 方法(click/swip/get_text/set_text.....)。

这样做的话,就只需要编写元素对象库,在 业务层直接自由调用,即时生成,用完丢弃。 代码量大幅减少

对象库层 (这里使用 airtest 下面的 poco 控件识别框架举例,和 Appium Selenium 略微不同)

class AndroidHomePage(BasePage):
    def __init__(self, driver):
        super().__init__(driver)

        self.p_account= "NormalWindow/AccountInputField"
        self.p_password = "NormalWindow/PwdInputField"

业务层

def test_login(id,pw)
  App.LoginPage.p_account.set_text(id)
  App.LoginPage.p_password.set_text(pw)

如此看来用例编写者就更接近只需要关注业务

以下是关键思路的实现 以 App 类为例子 BasePage Element 大概相同

  • App 类

    def __getattribute__(self, attr):
        """
        挡截属性访问
        """
        target_page = None
        if attr.endswith('page'):  # 过滤page
            page = import_module(attr)  #历遍 对象库层目录src/page 找到目标文件 
    
            if self.client_version == CHINA_PLATFORM:  # 国内版本
                for item in page.__dict__:
                    if item.startswith(CHINA_PAGE_PREFIX) :
                        target_page = getattr(page, item)
            elif self.client_version == OVERSEAS_PLATFORM: # 海外版本
                for item in page.__dict__:
                    if item.startswith(OVERSEAS_PAGE_PREFIX) :
                        target_page = getattr(page, item)
    
            return target_page(self._driver)
        else:
            # 非过滤直接访问
            return object.__getattribute__(self, attr)
    

    结尾

    以上这近几天对 PO 的新认识,欢迎大家多多指点

添加部分具体代码

对象元素库层:
BasePage:

class BasePage(object):

    def __init__(self, driver):
        self.poco = driver
        self._p_help_btn = ['帮助按钮', 'HelpBtn']   # “帮助按钮" 为注释,有几个作用:1.用于之后元素查找 2.访问self._p_help_btn 时自动绑定一个_name属性并赋值,在使用click()等具体操作时,会自动给对应方法添加 with allure.step(“步骤: 点击 %s”% self._name) (allure报告框架)从而使每个case都会展示具体的操作步骤信息; “HelpBtn” :具体的元素(这里是以poco框架的元素为例)
        self._p_help_text = ['帮助文本信息', 'GuideDialog(Clone)/DialogTx']
        self._p_help_continue_btn = ['帮助-》继续按钮', 'GuideDialog(Clone)/Continute/Glim']
        self._dict = object.__getattribute__(self, '__dict__')  # 获取属性集用于历遍查找目标属性

    #解析key的方法,不同提取元素框架自行实现解析函数,返回一个对应框架的控件操作对象
    def resolve_poco(self, key):
        return poco_key(self.poco, key)

    def __getattribute__(self, attr):
        #挡截 “p_” 和“_p_”的属性,“_p_”通常为BasePage的通用元素,加下横线用以区分
        if attr.startswith('p_') or attr.startswith('_p_'):
            _proxy = self.resolve_poco(self._dict[attr][1])  #获取对应元素操作对象的代理
            _proxy._name = self._dict[attr][0]   #绑定注释信息
            _proxy.click = types.MethodType(allure_click, _proxy)  #绑定click方法
            return _proxy
        else:
            return object.__getattribute__(self, attr)

    #这里的帮助文档检查是每个功能模块都有的,所有放在BasePage里面,不同继承类如果有元素差异重写元素即可
    def check_help_text(self, texts, timeout=3):
        self._p_help_btn.click()
        for text in texts:
            self.regular_wait(timeout)
            assert self._p_help_text.wait(2).get_text() == text
            self._p_help_continue_btn.click()

ScoutingPage:

class ScoutingPage(BasePage):

    def __init__(self, driver):
        super().__init__(driver)

业务层:

@allure.story('帮助')
@allure.title('文案检查')
def test_0(self, mt):
    #mt 是pytest下面的一个fixture,完成了一系列操作最后返回对应的Page类对象,操作包括:登录/前置界面智能跳转/前置数据准备等等
    mt.check_help_text([
        '球探介绍所可以帮助球队搜索到潜力新星,但每次搜索需要消耗大量机票',
        '董事会每<color=#FFBE34>5</color>分钟会赞助球队1张机票,解雇球员也可以获得大量机票'
    ])

执行报告如下:

最佳回复

楼主,每个 case 都要走一遍登录吗,这样 1000+ 的 case 是不是很浪费时间,还是说有别的方式

共收到 88 条回复 时间 点赞

学习了

3楼 已删除

不是哦 这里是参考的这个思路 很棒

暂时放弃 ui,感觉接口上手更简单

陈恒捷 将本帖设为了精华贴 07月18日 03:02
9楼 已删除

我通常是在 page 类定义各元素、以及各元素操作方法;case 类传参调用元素操作方法

cheunghr 回复

恩 你这是两层 PO 了

总结的很好啊,个人觉得 PO 模式好处还是很大的

看来我写的还不算是 PO 啊,少了逻辑层,逻辑和业务放在一块的,考虑的是某些同学不在项目中,可能不熟悉业务,让项目中的人自己去写业务和逻辑了

层次清晰,便于维护。

#输入密码
    def type_password(self,password):
        self.find_element(*self.password_loc).send_keys(password)

请问一下,这里 self.password_loc 中的 是什么作用,百度了的内容感觉没法套用进来

Wayyt 回复


解包的意思,可以粗劣的认为是去掉 “()”

楼主总结的很好,个人觉得应该按业务模块来分层较好,比如登录,可能涉及多个页面,但只写到一个登录模块去。

原来我一直写的就是 PO。。。

能出一个详细的整体的例子吗?就拿邮箱登陆

看前端技术吧,现在都是组件化,在组件复用情况下,先模块对象,再页面对象,反而更合理,页面如果频繁变动(逻辑),你还是怎么简单怎么来。能录制就录制

再说 ui 本身偏业务,把业务描述转为行为代码应该才是优化的点

可以的! 楼主 是自己想的吗==


请问这个 return 怎么理解== 不是很明白

wengzexiong 回复

敲错啦,应该是 target_page 。已经修改

两层用着还是可以的。

多么简洁的代码!连我都能看懂! 公司第一工程师封给你。

推荐一个 BasePageObject eeaston/page-objects

excuter611 [该话题已被删除] 中提及了此贴 08月02日 13:28
excuter611 [该话题已被删除] 中提及了此贴 08月03日 06:52

楼主很棒,看起来也讲得很清楚,但是对于属性拦截生成那一块看得一知半解,能够给一个完整的 demo 例子代码?感激不尽!

xiaomingpapapa 回复

好,明天把代码贴上去

正在学习

xiaomingpapapa 回复

代码已补上

34楼 已删除

问一下,poco_key(driver,key)的作用是,浏览器找到元素么,返回的是 webelement 对象吧

36楼 已删除
Tao Lee 回复

这个自己去解析,你是 web 就解析成 web appium 就是解析成 appium 的可操作对象,我这里是使用的 poco 控件识别框架,所有解析出来的就是 poco 对象

小怪兽 回复

谢谢。还有个问题,这些页面是单例模式么?
pageObject 模式,设计的 pages 之间,如果有跳转操作,(比如 loginpage 成功后会返回 homepage,然后从 homepage 获取验证信息,得到 login 成功的 assert),怎么处理跳转后的对象,直接新建一个 homepage 对象?
网上找到一个类似问题,没看懂怎么解决😅
https://bbs.csdn.net/topics/392051168

Tao Lee 回复

1.我这里没有把 page 设计成单例,这里看你框架怎么设计的了,各有不同
2.我这里的界面非常多,考虑到智能跳转找最短路径,异常时尝试切换账号或重启 app 等容错机制等设定,我把界面跳转抽离出来了。把各个界面看成一个节点,以 homepage 为根结点,形成一颗树。最终不同界面跳转就变成找一颗树中两个节点的最短路径了

参考一下 mvc Model view Controll

等自动化测试用例达到上百条的时候,就会发现 PO 模式的代码量实际上减少了很多冗余代码

usky 回复

现在这个项目有一千 +case 了,优化之后的 po 模式用起来很舒服

楼主很棒,对于属性拦截生成那一块看得一知半解,能给出一个比较完整的 demo 看看嘛

这里 element 封装没体现出来在哪里调用,这层是必须的吗?能不能贴一下 element 的调用代码呢,感谢!

仅楼主可见
Serven 回复

你哪里不清楚,给你回复吧

你好,问一下你的属性拦截器中的,return target_page(self,_driver) 是如何得出的,按照代码来看你的 target_page 只是一个局部变量, 可是你返回的是一个方法, 这块如何理解?

jackyin 回复

target_page = getattr(page, item)

page:为目标 page.py 文件, item 为目标文件下的具体类名
然后通过 getattr 方法拿到目标类
最后 target_page(self._driver) 来实例化具体类的对象

小怪兽 回复

原来如此,谢谢指导!

源码能不能分享下

请问一下,element 对象里面是咋拦截的呢

秦无殇 回复

你去了解下getattribute的使用吧
访问类里面的任何属性都会先调用它,我们通过重写getattribute来达到挡截的效果

小怪兽 回复

getattribute 的用法已了解,举个例子 LoginPage.my_loc.click1(),我已经拦截到了 my_loc 这个元素并解析了 webelement,调用 my_loc.click1 怎么去拦截,因为 my_loc 是 LoginPage 中的一个属性,是没有 click1 的这个属性,

秦无殇 回复

my_loc.click 不需要挡截了呀
挡截 my_loc 解析 webelement 后应返回一个对应元素的可操作对象,而这个对象是自带 click get_text set_text 之类的方法

小怪兽 回复

了解了,非常感谢,我还有一个疑问,比如有多个元素,怎么决定给某个元素绑定那种方法呢,我目前的做法是,在元素 list 后面加了一个元素用来判断该绑定啥方法 my_loc = [(By.ID, 'com.xxxxxxx:id/tab_personal_iv'),
'touch']

秦无殇 回复

有两个方向咯
一:默认绑定一些常规方法
二:就是你这种,通过自带参数来指定绑定方法,不过这样代码量多了很多

你好,能留个联系方式? 想请教一下

小羊咩咩 回复

有什么问题贴这里嘛 大家可以一起讨论

是否用 PO 模式,以及该模式是否有价值,还是取决于具体自动化落地的场景。如果自动化用例场景多,且包含关系复杂,就比较适合吧。不过个人倾向于从自动化测试用例着手,设计的自动化用例应该要相互独立,且交集少。这样不仅可以减少脚本数量,也没必要弄得太过于复杂。欢迎讨论

你好,楼主。
想请教下 你们 Unity UI 自动化 iOS 平台 有做吗?据我了解 Airtest 目前不支持 iOS version 13。
方便的话加个微信详聊下吗?
本人微信:grant_sun

Curry 回复

已经支持 ios 13 了,看更新

个人观点: PO 模式不适合快速项目. 它适合母语为英语系的去搞. 多 1 倍 2 倍代码对我们来说不是问题,但是自己写的代码自己看不懂就是问题了.这是涉及代码复杂度的问题.
个人经验 :
测试代码和业务紧密相关,所以我是按模块分目录来组织代码的.我讨厌再分一级 PO 层.遇到可以公用部分,再建一个公共目录.
示例:

--做单目录
     - 前A端加工
     - 前B加工
     - 公共登陆
--数据库工具
--Mock工具

代码的复杂度越低,新人越好理解.

再说说 UI 自动化.
我们在做测试过程发现: 一直淹没在查找元素问题上. 60% 以上的时间在查找元素. 这时再给他们讲要优化代码把公共代码提取为 PO 模式是找打😂
UI 自动化测试的重点是: 如何做数据分离. (就像 robot-framework 哪样).

大家都想写这样的测试用例:

手机登陆   18912345678 123456
打开首页   
存在        欢迎你 
点击翻页
存在       正在加载

这才是大家想要的

同志仍需努力!

yu 回复

1.“PO 模式不适合快速项目” -》个人觉得 UI 自动化不太适合快速项目咯
2.“但是自己写的代码自己看不懂就是问题了.这是涉及代码复杂度的问题. ” -》这跟 PO 模式没关系呀,代码复不复杂在于自己项目整体设计和个人风格。在我看来 PO 能让项目结构更清楚,至少 page-case 这部分是这样的
3..“我讨厌再分一级 PO 层.遇到可以公用部分” -》不知道是你那边 case 量比较少,还是 ui 改动确实极少,不然频繁 ui 改动带来的大量元素维护是一个很痛苦耗时的事情啊, po 分层能很大程度提高效率

  1. “我们在做测试过程发现: 一直淹没在查找元素问题上. 60% 以上的时间在查找元素” -》我这边 50% 左右的时间是花费在构造业务数据,找元素大概占 20%-30% 吧
  2. “如何做数据分离. (就像 robot-framework 哪样).” 这个可能出发点不同吧,我希望组员能先从写业务 case 再慢慢读懂整个系统接触更多代码慢慢提升技术而不是全部都封装成 web 工具/excel/yaml 形式,每天只用做填空题,换一家公司后和普通功能测试没什么差异

楼主您好,动态添加进去的方法,不能被点.出来?这个能解决么?

xiaoqi_11 回复

我们这样做等于骗过了 IDE,所以它不能智能提示。
目前没有什么好的办法,不过常用的方法也就几个咯,click /exists/swipe/get_text... 还好咯

如果是这些方法,那为什么不直接把元素写成 self.p_sign = poco("com.xxx"),这样好歹 p_sign.click,get_text,set_text 之类原始的方法可以点出来。主要是不能.方法,书写体验太差了。

小怪兽 回复

你们是什么应用?我们自动化的用例一般是另外再设计,会从流程上去覆盖手工用例。所以大大减少了业务场景的重复和元素的复用,同时减少了用例数量

青谷 回复

手游,unity 3d
我们 UI 用例绝大部分是根据功能测试用例来的,各类异常路径也会尽量覆盖。
如果只是覆盖主流程,可能深度在 20-30% 左右吧
我们这里的深度大概到了 70% 上下

小怪兽 回复

那你们工具是用 airtest 么?

青谷 回复

恩 是的 airtest 下面的 poco 框架

楼主您好,请问这样的元素(没有唯一的 id)--poco("我的").child("android.widget.ListView")[1].child("android.view.View")[0]--要怎么定义?

xiaoqi_11 回复

针对元素唯一性问题,我这里通常是多片段来约束(一般是三层),如 poco().child().child(), (parent, sibling 等),仔细找下绝大部分情况还是能找到有差异的 text. 如果最后还是不能确定唯一,那就单独处理吧,自己定义个符号来控制这类情况并在解析元素方法中实现它

楼主您好,请问这个骚操作是怎么实现的,能方便透露一下么?--在使用 click() 等具体操作时,会自动给对应方法添加 with allure.step(“步骤: 点击 %s”% self._name)(allure 报告框架)从而使每个 case 都会展示具体的操作步骤信息

xiaoqi_11 回复


这不就是嘛

小怪兽 回复

这个是动态把注释塞进去,我是想问动态增加 with allure.step(“步骤: 点击 %s”% self._name)呢

xiaoqi_11 回复

def allure_click(self, **kwargs):
with allure.step('步骤: 点击%s' % self._name):
UIObjectProxy.click(self, **kwargs)

simple [精彩盘点] TesterHome 社区 2019 年 度精华帖 中提及了此贴 12月24日 14:32

类似 Keyword 和 TestSuit 的概念吧

楼主,每个 case 都要走一遍登录吗,这样 1000+ 的 case 是不是很浪费时间,还是说有别的方式

想问下,多个跳转页面你咋处理的,比如登录,需要跳过首页广告,跳过系统弹框,登录方式选择(手机,微信,qq)等几个步骤了,才到登录界面(输入用户和密码),这里边多次点击操作,咋封装

84楼 已删除

老哥玩的可以

hk 回复

不会每个 case 都走登录,我这里单独做了一套 case 前置场景自动跳转/异常自动恢复的机制(如何高效地从未知场景跳转至目标场景),本质是对所有业务界面 info 进行建模,生成一颗树,最后转化成查找两个节点的最短路径的问题

aibreze 回复

这和楼上的问题相同,都是前置页面/脏界面管理的问题。参考楼上的回复

仅楼主可见

楼主解析 resolve 出来的元素对象 WebElement,怎么样和 element 类进行挂钩呢,我看上面的那个思路示意图--》elment 类同样拦截属性,根据属性名为 element 绑定对应方法。不知道对解析出来的元素对象 webElement 和 自定义元素对象拦截 elment 类进行挂钩?楼主是怎么做的啊?

在 54 楼的问题和 55 楼 (楼主的回复),my_loc.click1() 解析出 my_loc 可操作元素对象后,不用继续拦截 click1 属性了,但是在思维导图中倒数第二步中,是需要在 elment 类中拦截 click1 属性然后进行绑定常用方法的。到底是否需要继续拦截,还是不拦截的呢?比较疑惑~~

mind 回复

查看下帖子尾部的示例

mind 回复

你阅读的很仔细呀,因为帖子中尾部的示例代码是后来补上的,确实和一开始的构思流程图有差异。到了绑定自动化的方法时,可以继续选择动态生成,也可直接绑定指定的方法(通常是 click get_key exists..等) 不过选哪种都没关系嘛,这里主要是介绍这种属性拦截的思路,根据自己项目业务情况灵活使用咯。

很受益。自己一直再琢磨,但是无果,楼主的思维开导了我

小怪兽 回复

受教,这里能分享一下具体的思路么?如果建模?

感谢楼主的思路分享,做了一些实践,另外解决了不能.出来方法的问题。附上地址 https://github.com/xiaoqi7s/airtest-pytest

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