自动化测试框架设计实践

2524 篇文章 2 订阅
2361 篇文章 14 订阅

自动化测试框架大致可以分为这么几类:单元测试框架、工具型自动化测试框架和业务型自动化测试框架。单元测试框架大家都比较熟了,比如知名的 xUnit 系列;工具型自动化测试框架提供对特定对象的操作封装,比如 Selenium、UIAutomator 等;业务型自动化测试框架通常基于前两者,是面向特定业务而开发的框架。

我们为什么需要框架?或者说我们什么时候需要框架?这就得先讲下框架的作用。关于框架的说法有很多,比如提升效率、简化流程、促进质量等等,我觉得可以做一些精简,核心归纳成两个词:复用、约束。

复用来源于框架提供的底层统一抽象、各种工具包和封装代码,用于提升我们的开发效率。比如 Selenium 统一了 Chrome 和 Firefox 的操作,编写一套代码就能作用于多个浏览器;再比如很多团队的业务自动化测试框架,会将登录流程封装成一个公共方法,供其他业务流程直接调用。

约束则由框架内置的工作流或对外的接口来定义,使得基于框架编写的代码更加规范,便于将来扩展和维护。比如 React 会对输出到 HTML 中的文本做转义处理,避免来自 XSS 的危胁;再比如一些应用框架会自动记录用户的访问链路日志,确保所有的调用均可被追踪,有助于应用的可维护性。

有时候我们会在框架的选型上遇到困惑,纠结使用哪种框架(和语言)最好。其实没有所谓“最好”的框架,每个框架都有其特点。我们在考虑要不要采用某种框架时,必须先考虑它的 ROI。可以参考团队的主要技术栈、平均水平、业务适性、框架成熟度等因素,不要为了新鲜或流行之类的理由,而去使用一个并不适合的框架。

举个例子,有团队因为业务使用 JAVA,于是自动化测试框架代码也要求使用 JAVA 编写,开发、调试成本翻了几倍,完全没有必要。笔者也曾踩过类似的坑,为了追求“先进”而使用了某个新出的自动化框架,不想却因为该框架缺乏充分的文档和成熟的社区,开发过程困难重重,最后不得不推倒重来。

回到上面说的几类框架里:单元测试框架可介绍的点不多,而且一般也不由质量团队来编写,这里就不再继续展开;工具型自动化测试框架基于特定对象,数量相对较多,也没有很大的共性,不妨放到后面慢慢讲;下个小节先从业务型自动化测试框架开始。

自动化框架分层设计

业务型自动化测试框架没有固定的形式,对于缺乏相关经验的小伙伴而言,大体的印象可能就是 PO 模式、关键字驱动之类。常见的业务框架包括 API 层和 UI 层,UI 层相对更为复杂,如果能够理解 UI 层的设计,API 层自然也能明白。所以我们再把范围缩小到 UI 层业务型自动化测试框架上。

说到 UI 测试框架,基本就绕不开 PO 模式,即 Page Object Model,一种框架分层设计的思想。我们前面讲过框架的其中一个主要作用就是复用,分层设计也是这个逻辑。我们来看这样一个场景:某商城后台系统有这么几个页面:一个登录页,包含两个输入框和一个提交按钮;一个商品列表页,展示可编辑的商品项;还有一个账号中心页,可以修改自己的个人信息。很显然,后两个页面都需要先登录才能访问。

现在我们要测试商品编辑和修改密码这两个用例,最原始的测试代码写法就是在两个 Case 文件中分别编写各自的测试逻辑(以下省略 driver 的初始化过程),例如:

// 登录
driver.find_element(By.ID, "name").send_keys("admin")
driver.find_element(By.ID, "password").send_keys("123456")
driver.find_element(By.ID, "button").click()

// 编辑商品
driver.get("https://www.xxx.com/products")
driver.find_element(By.XPATH, "...").click()
driver.find_element(By.ID, "product_name").send_keys("Keyboard")
driver.find_element(By.ID, "submit").click()

// 断言部分
......
// 登录
driver.find_element(By.ID, "name").send_keys("admin")
driver.find_element(By.ID, "password").send_keys("123456")
driver.find_element(By.ID, "button").click()

// 修改密码
driver.get("https://www.xxx.com/account/password/modify")
driver.find_element(By.ID, "current_password").send_keys("123456")
driver.find_element(By.ID, "new_password").send_keys("654321")
driver.find_element(By.ID, "submit").click()

// 断言部分
......

我们发现登录部分是通用的,可以把它抽象成一个 LoginPage 类,和一个登录方法 login,单独放在别的文件里,原来的测试代码就可以简化成:

// 登录
LoginPage(driver).login("admin", "123456")

// 编辑商品
driver.get("https://www.xxx.com/products")
driver.find_element(By.XPATH, "...").click()
driver.find_element(By.ID, "product_name").send_keys("Keyboard")
driver.find_element(By.ID, "submit").click()

// 断言部分
......
// 登录
LoginPage(driver).login("admin", "123456")

// 修改密码
driver.get("https://www.xxx.com/account/password/modify")
driver.find_element(By.ID, "current_password").send_keys("123456")
driver.find_element(By.ID, "new_password").send_keys("654321")
driver.find_element(By.ID, "submit").click()

// 断言部分
......

抽象出来有什么好处呢?假设登录的流程发生了一些变化,比如多了一个手机验证码,那么只要修改 LoginPage.login() 里的代码就好,不需要修改每个测试文件;还有一点,我们对账号名和密码做了参数化,就可以在不同的测试里,使用不同的账号身份。印证前面说的,分层的目的就是为了复用

那为什么要以 Page 作为分层的维度?这个倒也没有硬性规定,只是对于前端应用而言,Page 相对比较独立,理解起来方便,颗粒度也适中。如果一定要以其它维度,比如业务模块来分割也行,只是文件长度很难控制,而且像登录页属于公共模块还是用户模块,各人会有各自的看法,没必要在这里增加无谓的理解成本。

说过了 Page,再进一步解释 Object。延续上面的例子,如果直接把登录部分写成 Login Page 的 login 方法,差不多是这个样子:

class LoginPage {

  ... // 其他代码
  
  def login(self, name, password) {
    self.driver.find_element(By.ID, "name").send_keys(name)
    self.driver.find_element(By.ID, "password").send_keys(password)
    self.driver.find_element(By.ID, "button").click()
  }
}

逻辑比较简单,我们加点复杂度,在页面上增加一个校验用户名是否存在的功能:点击另一个按钮,校验 name 文本框中输入的名字是否已经存在。那么现在 LoginPage 中就有两个方法:

class LoginPage {

  ... // 其他代码
  
  def login(self, name, password) {
    self.driver.find_element(By.ID, "name").send_keys(name)
    self.driver.find_element(By.ID, "password").send_keys(password)
    self.driver.find_element(By.ID, "button").click()
  }
  
  def check_user_name(self, name) {
    self.find_element(By.ID, "name").send_keys(name)
    self.find_element(By.ID, "check_button").click()
  }
}

我们又会发现,driver.find_element(By.ID, "name") 重复出现了,同样的道理,我们希望尽量复用代码,所以又把页面元素也独立了出来:

class LoginPage {

  ... // 其他代码
  
  input_name = self.driver.find_element(By.ID, "name")
  
  def login(self, name, password) {
    self.input_name.send_keys(name)
    self.find_element(By.ID, "password").send_keys(password)
    self.find_element(By.ID, "button").click()
  }
  
  def check_user_name(self, name) {
    self.input_name.send_keys(name)
    self.find_element(By.ID, "check_button").click()
  }
}

剩下的部分我们也可以用同样的方式处理:

class LoginPage {

  ... // 其他代码
  
  input_name = self.driver.find_element(By.ID, "name")
  input_password = self.driver.find_element(By.ID, "password")
  button_login = self.driver.find_element(By.ID, "button")
  button_check = self.driver.find_element(By.ID, "check_button")
  
  def login(self, name, password) {
    self.input_name.send_keys(name)
    self.input_password.send_keys(password)
    self.button_login.click()
  }
  
  def check_user_name(self, name) {
    self.input_name.send_keys(name)
    self.button_check.click()
  }
}

而对于测试用例,则是各种操作的调用,比如之前的两个用例最终会变成:

// 登录
LoginPage(driver).login("admin", "123456")

// 编辑商品
product_page = ProductPage(driver)
product_page.goto()
product_page.update_product("Keyboard")

// 断言部分
......
// 登录
LoginPage(driver).login("admin", "123456")

// 修改密码
account_page = AccountPage(driver)
account_page.goto()
account_page.update_password("123456","654321")

// 断言部分
......

至此 PO 模式的基本架子就出来了,整体结构如下图:

整体分为用例层和页面层,页面层又细分为操作和对象。那么到这就结束了吗?并没有,PO 只是最基本的分层理念,我们再回顾一下上面的内容,找找还有没有重复的点。有经验的小伙伴知道,上面操作方法参数化之后填入的数据,其实也是重复的

比如我们用的账号是“admin”,对应密码是“123456”,如果修改了该账号的密码,在上面的示例中也得到处查找对应的数据进行同步修正。因此这些数据我们最好还是放到一个统一的地方,在需要的时候引用相关变量就可以了:

// 登录
LoginPage(driver).login(admin_name, admin_password)

// 编辑商品
product_page = ProductPage(driver)
product_page.goto()
product_page.update_product("Keyboard")

// 断言部分
......
// 登录
LoginPage(driver).login(admin_name, admin_password)

// 修改密码
new_password = "654321"
account_page = AccountPage(driver)
account_page.goto()
account_page.update_password(admin_password, new_password)
admin_password = new_password

// 断言部分
......

注意这里只是将数据做了提取,不是数据驱动的意思,后面会具体讲关键字驱动和数据驱动。现在的整体结构图变成了:

基本的分层设计,到这其实也就差不多了。结束这个小节之前,我们再花些篇幅,额外介绍一些基于它的演变设计。

前面在写 Page 的时候,我们知道里面包含了对象和操作。那这两块是否可以彻底分拆开?可以。上面示例的设计逻辑是,Page 包含了对象和基于当前这些对象的操作,优点是减少过多层级带来的各种导入和复杂度,缺点是不能支持跨页面的操作。

比如修改密码,在有的应用里需要流转好几个页面(验证下用户身份之类),如果按 Page 做隔离,一个操作就得调多个方法,复用性也会减弱。所以确实有另一种设计是将操作作为与页面无关的一层独立存在。

要解决上面那个问题也不是只有这么一条路可走。有些聪明的同学可能会想到,我干脆在 Page 之上加一个业务逻辑层,抽象出常用的多页面或链路操作不就行了吗?这种设计和页面层分拆设计背后的思考其实是一样的,只是表现上有所不同而已。

操作有可能会跨页面,对象同样也可以。比如网站顶部的导航,在很多页面都会出现,内容也都是一样的。所以虽然在视觉上导航和主体存在同一个页面里,但我们仍然可以把这个“区块”特殊对待,作为一个独立“组件”分离出去,以达到方便维护的目的。最终我们的分层结构图如下:

自动化框架进阶设计

我们在实际的业务场景应用中,还会碰到一些问题,比如:脚本依赖的数据越来越多,难以维护怎么办?自动化跑到一半抛错了怎么办?用例太多导致自动化每次运行都要很久怎么办?等等。本小节我们就挑一些常见的问题做进一步的讲解。

第一个问题是关于自动化的非依赖性。假定我们有两个 Case,第一个 Case 测试创建商品,第二个 Case 测试商品的查看,并且它基于第一个 Case 创建好的数据,那么我们就认为这两个 Case 有依赖,在运行自动化测试的时候,一定要保证这两个用例的前后运行次序。

这种依赖比较危险。首先,在维护的时候,很难完全记忆全部用例的依赖关系,有可能因为不小心修改了前置依赖用例导致测试失败;其次,如果前置用例测试不如预期,也会导致后置用例连带失败,甚至都不能直接判断是哪个用例出了问题;再者,依赖关系会影响到多用例的分布式运行。因此在比较好的自动化框架设计里,每个用例都应该是完全独立的。要实现这种独立性,我们需要处理好两个条件。

第一个是场景重置。什么是场景?在 UI 测试里就是用例起始的 Page。比如要测试一个商品购买流程,我们就得先进入到商品页。在上一个 Case 跑完后,当前环境(比如浏览器状态)和停留位置是不确定的,所以所有用例在 setup 阶段要干的事情就是检查当前的环境是否正确,以及定位到需要的起始位置,这就是所谓的场景重置。

场景重置会增开额外的开销,使得整个测试的运行时间更长。但为了测试的整体稳定,这点时间值得投入。况且非依赖的用例集,我们还可以用分布并发运行的方式来加快测试的执行,但是存在依赖的用例集就没有办法这么做了。

第二是数据自主。测试用例中难免会需要用到一些数据,比如前面说的测试商品购买,必须要有商品数据才可以操作。数据自主的意思就是每个 Case 需要的全部数据都在 setup 阶段生成,在 teardown 阶段清理。像 UI 层的自动化,数据可以用接口调用的方式来处理,执行速度较快,对整体的测试时长不会有太多影响。

实际上,我们很难做到 100% 的数据完全自主,比如用户账号需要使用手机号,不太可能每个 Case 都去注册、销毁。所以大多数情况下,我们可以允许少量的预置数据存在,一般将那些不太变更、生成困难的数据作为预置项,放在配置文件里。

还有一种变通的方式是,设定 Suite 级别的预置数据。比如有一组用例都需要用到商品数据,那么我们可以在 Suite 开始之前生成商品,接着执行该 Suite 下的所有用例,最后销毁该数据。在分布式的情况下,我们以 Suite 为单位来做分布执行,既可减少数据处理的开销,又可以保持分布式的优势。

第二个问题是自动化过程中的异常处理。特别是对于 UI 层的自动化,测试不稳定是一个很让人头疼的问题,一旦出现异常,我们就得花时间去定位和重跑。自动化的意义就是“无人值守”,如果做不到这点,为什么要用自动化呢。因此在做自动化框架设计的时候,就要尽量去捕获和处理各种异常。

最常见的异常是定位元素找不到,可能是由于偶发因素(超时)或页面改变导致。偶发因素很难预判,所以我们可以通过在框架层设定一定次数的重试机制,保证测试的稳定性。但是需要注意的是,重试毕竟是有代价的,所以我们在重试前要尽量排除可预见的因素,比如弹窗、断言失败等。

初学自动化的小伙伴有个坏习惯:用 sleep 来应对所有元素定位异常。这个习惯不是很好,很容易造成整体测试时间大幅延长。如果底层使用 Selenium 之类的工具,框架可以默认设置好隐性等待,也可以通过类代理的方式(后面介绍)为元素查找置入强制显性等待。像一些不支持智能等待的框架,也可以用自定义的轮循式 sleep(比如 500ms 一个间隔)来模拟。

对于页面改变引起的元素定位失效,框架层面没法很好地处理,需要我们自己有一定的自动化经验。有时候失效并不一定是因为元素本身的标签或属性发生变化,而是外层的条件改变。比如一个按钮 C,它从位置 A 移到了 位置 B,如果我们定位使用的查询条件是 A/C,就会因为 A 的改变导致 C 定位失败。所以我们在设置定位方式的时候,尽量不要依赖外层元素,只依靠 C 自身的特征来识别。另外也尽量不要依赖一些样式特征(比如 css,class),这些特征也比较容易发生变化。

// 不稳定的识别方式
input_name = self.driver.find_element(By.XPATH, "/html/body/a/c")

// 相对稳定的识别方式
input_name = self.driver.find_element(By.ID, "name")

还有一种常见异常是意外弹窗,比如系统弹窗 alter、confirm、prompt 等。我们很难预料什么时候会有弹窗出现,所以最保险的办法是在开始测试前,做统一的弹窗判断处理。利用 try/catch 可以在不影响正常测试代码的情况下判断,比如:

try:
  driver.switch_to.alert.accept()
except Exception:
  pass

同样的,业务也可能会有自己的弹窗(弹层),但它是以蒙层的形式出现的。我们想要处理,首先就得精确识别到它。一般应用的弹层都是使用固定的模板,只不过在里面填充不同的文本,所以可以通过识别模板的元素特征,来判断它是否存在,如果出现,则通过点击关闭按钮来移除弹层。

第三个问题是自动化测试的分布式处理。随着业务的增长,测试用例的数量也有可能增加到一个很庞大的数字,如果采用线性方式依次执行完所有的用例,可能需要花费数个甚至数十个小时。这对于持续集成等需要快速反馈的场景显然难以接受,所以大型业务的自动化采用分布式执行是一件必然的事情。分布式有两种模式,一种是全副本模式,一种是分发模式。

全副本模式的意思就是,在所有目标执行设备上执行完整的用例集,但是每个设备的环境都是不一样的,比如网页端自动化测试,X 设备执行的是 Chrome 浏览器下的测试,Y 设备执行的是 Firefox 浏览器下的测试。所以全副本模式一般用于不同环境的兼容性测试。

分发模式的意思是,总共 N 个用例,平均(或通过一定算法)分散到 M 台设备上执行。这种模式比较好理解,目标就是为了能够并行处理自动化用例,降低测试的总体运行时间。

几乎没有哪个工具型框架原生就同时支持这两种模式,所以大多是由业务型框架来处理。这里有个难点是报告的收集,比如 A 和 B 两个用例,分别在 X 和 Y 设备上执行,最后我们肯定希望能有一份统一的报告来呈现自动化执行的结果,所以框架就需要负责收集和合并不同设备上的测试结果。

最后再讲一下类代理。什么是类代理呢?比方说一个自定义的 driver 对象,拥有所有原始 driver 的方法,又多了一层封装处理。例如:

Class Driver:

  def __init__(self, type):
    if type == "chrome":
      self.driver = webdriver.Chrome()
    elif type == "xxx"
      ...
      
  def __getattr__(self, name):
    attr = self.driver.__getattribute__(name)
    if callable(attr):
      def func(*args, **kwargs):
        return attr(*args, **kwargs)
      return func
    else:
      return attr(name)

之后我们所有关于 driver 的调用都可以通过这个新的 Driver 类的对象来完成。这么做有什么好处呢?我们前面提到,框架除了复用之外,还有一个作用就是约束。比如我们希望查找元素之前都必须截个图,那么就可以对 find_element 开始的方法做拦截,例如:

Class Driver:

  def __init__(self, type):
    if type == "chrome":
      self.driver = webdriver.Chrome()
    elif type == "xxx"
      ...
      
  def __getattr__(self, name):
    attr = self.driver.__getattribute__(name)
    if name.startswith("find_element"):
      self.driver.save_screenshot(str(time.time()) + ".png")
    if callable(attr):
      def func(*args, **kwargs):
        return attr(*args, **kwargs)
      return func
    else:
      return attr(name)

类似的,其他所有原始 driver 拥有的方法都可以做统一拦截和处理,方便我们在框架层面做很多事情。可能有人会有疑问,为什么不用继承的方式?理论上也不是不行,但是类代理相对继承而言,做规则性的拦截会更加方便。具体也看个人喜好,设计模式的事情,不在这里讨论了。

不知不觉码了七千多字,下回再见。

后:下方这份完整的软件测试视频教程已经整理上传完成,需要的朋友们可以自行领取【保证100%免费】

整套资料获取

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值