Page Object Page Object 是 UI 自动化测试项目开发实践的最佳设计模式之一,它的主要特点体现 在对界面交互细节的封装上,使测试用例更专注于业务的操作,从而提高测试用例的可维 护性。 8.1 认识 Page Object 当为 Web 页面编写测试时,需要操作该 Web 页面上的元素。然而,如果在测试代码中 直接操作 Web 页面上的元素,那么这样的代码是极其脆弱的,因为 UI 会经常变动。 Page Object 原理如图 8-1 所示。 page 对象的一个基本经验法则是:凡是人能做的事,page 对象通过软件客户端都能做 到。因此,它应当提供一个易于编程的接口,并隐藏窗口中底层的部件。当访问一个文本 框时,应该通过一个访问方法(Accessor Method)实现字符串的获取与返回,复选框应当 使用布尔值,按钮应当被表示为行为导向的方法名。page 对象应当把在 GUI 控件上所有查 询和操作数据的行为封装为方法。 一个好的经验法则是,即使改变具体的元素,page 对象的接口也不应当发生变化。 尽管该术语是 page 对象,但并不意味着需要针对每个页面建立一个这样的对象。例如, 页面上有重要意义的元素可以独立为一个 page 对象。经验法则的目的是通过给页面建模, 使其对应用程序的使用者变得有意义。
Page Object 是一种设计模式,在自动化测试开发中应遵循这种设计模式来编写代码。 Page Object 应该遵循以下原则进行开发:
Page Object 应该易于使用。 有清晰的结构,如 PageObjects 对应页面对象,PageModules 对应页面内容。 只写测试内容,不写基础内容。 在可能的情况下防止样板代码。 不需要自己管理浏览器。 在运行时选择浏览器,而不是类级别。 不需要直接接触 Selenium。
8.2 实现 Paget Object 下面我们将通过例子介绍这种设计模式的使用。 8.2.1 Paget Object 简单实例 以百度搜索为列,假设我们有如下测试代码。 …
def test_baidu_search_case1(self):
self.driver.get(self.base_url)
self.driver.find_element_by_id("kw").send_keys("selenium")
self.driver.find_element_by_id("su").click()
def test_baidu_search_case2(self):
self.driver.get(self.base_url)
self.driver.find_element_by_id("kw").send_keys("unittest")
self.driver.find_element_by_id("su").click()
def test_baidu_search_case3(self):
self.driver.get(self.base_url)
self.driver.find_element_by_id("kw").send_keys("page object")
self.driver.find_element_by_id("su").click()
这段代码最大的问题就是在三条测试用例中重复使用了元素的定位和操作。这会带来
一个很大的问题,当元素的定位发生变化后,例如,id=kw 失效了,应及时调整定位方法,
这时就需要在三条测试用例当中分别进行修改。假设,我们的自动化项目有几百条测试用
例,而 UI 很可能是频繁变化的,那么就会提高自动化测试用例的维护成本。
Page Object 的设计思想上是把元素定位与元素操作进行分层,这样带的来最直接的好
处就是当元素发生变化时,只需维护 page 层的元素定位,而不需要关心在哪些测试用例当
中使用了这些元素。在编写测试用例时,也不需要关心元素是如何定位的。
创建 baidu_page.py 文件,内容如下。
class BaiduPage():
def __init__(self, driver):
self.driver = driver
def search_input(self, search_key):
self.driver.find_element_by_id("kw").send_keys(search_key)
def search_button(self):
self.driver.find_element_by_id("su").click()
首先,创建 BaiduPage 类,在__init__()初始化方法中接收参数 driver 并赋值给 self.driver。 然后,分别封装 search_input()方法和 search_button()方法,定位并操作元素。这里的封装只 针对一个页面中可能会操作到的元素,原则上是一个元素封装成一个方法。当元素的定位 方法发生改变时,只需维护这里的方法即可,而不需要关心这个方法被哪些测试用例使用 了。
from baidu_page import BaiduPage
…
def test_baidu_search_case1(self):
self.driver.get(self.base_url)
bd = BaiduPage(self.driver)
bd.search_input("selenium")
bd.search_button()
def test_baidu_search_case2(self):
self.driver.get(self.base_url)
bd = BaiduPage(self.driver)
bd.search_input("unittest")
bd.search_button()
def test_baidu_search_case3(self):
self.driver.get(self.base_url)
bd = BaiduPage(self.driver)
bd.search_input("page object")
bd.search_button()
…
首先在测试中导入 BaiduPage 类,然后在每个测试用例中为 BaiduPage 类传入驱动, 这样就可以轻松地使用它封装的方法来设计具体的测试用例了。这样做的目的就是在测试 用例中消除元素定位。如果你要操作百度输入框,那么只需调用 search_input()方法并传入 搜索关键字即可,并不需要关心百度输入框是如何定位的。 8.2.2 改进 Paget Object 封装 上面的例子演示了 Page Object 设计模式的基本原理,这样的分层确实带来了不少好处, 但同时也带来了一些问题。例如,需要写更多的代码。以前一条测试用例只需写 4 到 5 行 代码即可,现在却不得不先在 Page 层针对每个待操作的元素进行封装,然后再到具体的测试用例中引用。为了使 Page 层的封装更加方便,我们做一些改进。 创建 base.py 文件,内容如下。
import time
class BasePage:
"""
基础 Page 层,封装一些常用方法
"""
def __init__(self, driver):
self.driver = driver
# 打开页面
def open(self, url=None):
if url is None:
self.driver.get(self.url)
else:
self.driver.get(url)
# id 定位
def by_id(self, id_):
return self.driver.find_element_by_id(id_)
# name 定位
def by_name(self, name):
return self.driver.find_element_by_name(name)
# class 定位
def by_class(self, class_name):
return self.driver.find_element_by_class_name(class_name)
# XPath 定位
def by_xpath(self, xpath):
return self.driver.find_element_by_xpath(xpath)
# CSS 定位
def by_css(self, css):
return self.driver.find_element_by_css_selector(css)
# 获取 title
def get_title(self):
return self.driver.title
# 获取页面 text,仅使用 XPath 定位
def get_text(self, xpath):
return self.by_xpath(xpath).text
# 执行 JavaScript 脚本
def js(self, script):
self.driver.execute_script(script)
创建 BasePage 类作为所有 Page 类的基类,在 BasePage 类中封装一些方法,这些方法
是我们在做自动化时经常用到的。
open()方法用于打开网页,它接收一个 url 参数,默认为 None。如果 url 参数为 None,则默认打开子类中定义的 url。稍后会在子类中定义 url 变量。 by_id()和 by_name()方法。我们知道,Selenium 提供的元素定位方法很长,这里 做了简化,只是为了在子类中使用更加简便。 get_title()和 get_text()方法。这些方法是在写自动化测试时经常用到的方法,也可 以定义在 BasePage 类中。需要注意的是,get_text()方法需要接收元素定位,这里默认为 XPath 定位。 当然,我们还可以根据自己的需求封装更多的方法到 BasePage 类中。
修改 baidu_page.py 文件。
from base import BasePage
class BaiduPage(BasePage):
"""百度 Page 层,百度页面封装操作到的元素"""
url = "https://www.baidu.com"
def search_input(self, search_key):
self.by_id("kw").send_keys(search_key)
def search_button(self):
self.by_id("su").click()
创建 BaiduPage.py 类继承 BasePage 类,定义 url 变量,供父类中的 open()方法使用。 这里可能会有点绕,所以举个例子:小明的父亲有一辆电动玩具汽车,电动玩具汽车需要 电池才能跑起来,但小明的父亲并没有为电动玩具汽车安装电池。小明继承了父亲的这辆 电动玩具汽车,为了让电动玩具汽车跑起来,小明购买了电池。在这个例子中,open()方法 就是“电动玩具汽车”,open()方法中使用的 self.url 就是“电池”,子类中定义的 url 是为 了给父类中的 open()方法使用的。 在 search_input()和 search_button()方法中使用了父类的 self.by_id()方法来定位元素,比 原生的 Selenium 方法简短了不少。 在测试用例中,使用 BaiduPage 类及它所继承的父类中的方法。
import unittest
from time import sleep
from selenium import webdriver
from baidu_page import BaiduPage
class TestBaidu(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.driver = webdriver.Chrome()
def test_baidu_search_case1(self):
page = BaiduPage(self.driver)
page.open()
page.search_input("selenium")
page.search_button()
sleep(2)
self.assertEqual(page.get_title(), "selenium_百度搜索")
@classmethod
def tearDownClass(cls):
cls.driver.quit()
if __name__ == '__main__':
unittest.main(verbosity=2)
因为前面封装了元素的定位,所以在编写测试用例时会方便不少,当需要用到哪个 Page 类时,只需将它传入浏览器驱动,就可以使用该类中提供的方法了。 8.3 poium 测试库 poium 是一个基于 Selenium/appium 的 Page Object 测试库,最大的特点是简化了 Page层元素的定义。 项目地址:https://github.com/defnngj/poium。 支持 pip 安装。 > pip install poium 8.3.1 基本使用 使用 poium 重写 baidu_page.py。
from poium import Page, PageElement
class BaiduPage(Page):
"""百度 Page 层,百度页面封装操作到的元素"""
search_input = PageElement(id_="kw")
search_button = PageElement(id_="su")
创建 BaiduPage 类,使其继承 poium 库中的 Page 类。调用 PageElement 类定义元素定 位,并赋值给变量 search_input 和 search_button。这里仅封装元素的定位,并返回元素对象, 元素的具体操作仍然在测试用例中完成,这也更加符合 Page Object 的思想,将元素定位与 元素操作分层。 在测试用例中的使用如下。
from baidu_page import BaiduPage
class TestBaidu(unittest.TestCase):
…
def test_baidu_search_case1(self):
page = BaiduPage(self.driver)
page.get("https://www.baidu.com")
page.search_input = "selenium"
page.search_button.click()
…
首先导入 BiaduPage 类,传入浏览器驱动。然后,调用 get()方法访问 URL,该方法由 Page 类提供。接下来调用 BaiduPage 类中定义的元素对象,即 search_input 和 search_button , 实现相应的输入和单击操作。 8.3.2 更多用法 想要更好地使用 poium,需要了解下面的一些使用技巧。 1.支持的定位方法 poium 支持 8 种定位方式。
from poium import Page, PageElement
class SomePage(Page):
elem_id = PageElement(id_='id')
elem_name = PageElement(name='name')
elem_class = PageElement(class_name='class')
elem_tag = PageElement(tag='input')
elem_link_text = PageElement(link_text='this_is_link')
elem_partial_link_text = PageElement(partial_link_text='is_link')
elem_xpath = PageElement(xpath='//*[@id="kk"]')
elem_css = PageElement(css='#id')
2.设置元素超时时间 通过 timeout 参数可设置元素超时时间,默认为 10s。
from poium import Page, PageElement
class BaiduPage(Page):
search_input = PageElement(id_='kw', timeout=5)
search_button = PageElement(id_='su', timeout=30)
3.设置元素描述 当一个 Page 类中定义的元素非常多时,必须通过注释来增加可读性,这时可以使用 describe 参数。
from poium import Page, PageElement
class LoginPage(Page):
"""
登录 Page 类
"""
username = PageElement(css='#loginAccount', describe="用户名")
password = PageElement(css='#loginPwd', describe="密码")
login_button = PageElement(css='#login_btn', describe="登录按钮")
user_info = PageElement(css="a.nav_user_name > span", describe="用户信息")
需要强调的是,describe 参数并无实际意义,只是增加了元素定义的可读性。 4.定位一组元素 当我们要定位一组元素时,可以使用 PageElements 类。
from poium import Page, PageElement
class ResultPage(Page):
# 定位一组元素
search_result = PageElements(xpath="//div/h3/a")
poium 极大地简化了 Page 层的定义,除此之外,它还提供了很多的 API,如 PageSelect 类简化了下拉框的处理等。读者可以到 GitHub 项目中查看相关信息。目前,poium 已经在 Web 自动化项目中得到了很好的应用。
第 9 章 pytest单元测试框架 在学习了 unittest 单元测试框架之后,还有必要学习 pytest 吗?答案是肯定的。pytest 是一个第三方单元测试框架,更加简单、灵活,而且提供了更加丰富的扩展,弥补了 unittest 在做 Web 自动化测试时的一些不足。 9.1 pytest 简单例子 pytest 官方网站:https://docs.pytest.org/en/latest/。 pytest 支持 pip 安装。 > pip install pytest 通过 pytest 编写一个简单的测试用例,创建 test_sample.py 文件。
def inc(x):
return x + 1
def test_answer():
assert inc(3) == 5
这是官方给出的一个例子。inc()函数接收一个参数 x,返回 x+1。test_answer()为测试 用例,调用 inc()方法并传参数为 3,使用 assert 断言返回结果是否为 5。 接下来运行测试,切换到 test_sample.py 文件所在目录,执行“pytest”命令。
> pytest
================= test session starts ==================
platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0
rootdir: D:\git\book-code\pytest_sample\first_demo, inifile:
collected 1 item
test_sample.py F [100%]
======================= FAILURES =======================
_____________________ test_answer ______________________
def test_answer():
> assert inc(3) == 5
E assert 4 == 5
E + where 4 = inc(3)
test_sample.py:9: AssertionError
=============== 1 failed in 0.15 seconds ===============
“pytest”命令在安装 pytest 测试框架时默认生成于...\Python37\Scripts\目录。
通过上面的例子,相信你已经感受到了 pytest 的优点,它更加简单。首先,不必像 unittest
一样必须创建测试类。其次,使用 assert 断言也比使用 unittest 提供的断言方法更加简单。
不过,它也有自己的规则,即测试文件和测试函数必须以“test”开头。这也是在执行
“pytest”命令时并没有指定测试文件也可以执行 test_sample.py 文件的原因,因为该文件名
以“test”开头。
能否像 unittest 一样,通过 main()方法执行测试用例呢?当然可以,pytest 同样提供了
main()方法。
import pytest
def inc(x):
return x + 1
def test_answer():
assert inc(3) == 5
if __name__ == '__main__':
pytest.main()
main()方法默认执行当前文件中所有以“test”开头的函数。现在可以直接在 IDE 中运 行测试了。
9.2 pytest 的基本使用方法 因为我们已经具备了 unittest 的基础,对于单元测试框架中的概念也已经理解,所以在 学习 pytest 时要轻松许多,只需对比它与 unittest 之间的不同即可。 9.2.1 断言 在 unittest 单 元 测 试 框 架 中 提 供 了 丰 富 的 断 言 方 法 , 如 assertEqual()、assertIn()、assertTrue()、assertIs()等。pytest 单元测试框架并没有提供专门的断言方法,而是直接使用Python 的 assert 进行断言。 创建 test_assert.py 文件。
# 功能:用于计算a,b 相加的和
def add(a, b):
return a + b
# 功能:用于判断素数
def is_prime(n):
if n <= 1:
return False
for i in range(2, n):
if n % i == 0:
return False
return True
# 测试相等
def test_add_1():
assert add(3, 4) == 7
# 测试不相等
def test_add_2():
assert add(17, 22) != 50
# 测试大于等于
def test_add_3():
assert add(17, 22) <= 50
# 测试小于等于
def test_add_4():
assert add(17, 22) >= 38
# 测试包含
def test_in():
a = "hello"
b = "he"
assert b in a
# 测试不包含
def test_not_in():
a = "hello"
b = "hi"
assert b not in a
# 判断是否为True
def test_true_1():
assert is_prime(13)
# 判断是否为True
def test_true_2():
assert is_prime(7) is True
# 判断是否不为True
def test_true_3():
assert not is_prime(4)
# 判断是否不为True
def test_true_4():
assert is_prime(6) is not True
# 判断是否为False
def test_false_1():
assert is_prime(8) is False
上面的例子展示了 pytest 断言的用法,借助 Python 的运算符号和关键字即可轻松实现 不同数据类型的断言。 9.2.2 Fixture Fixture 通常用来对测试方法、测试函数、测试类和整个测试文件进行初始化或还原测 试环境。创建 test_fixtures_01.py 文件。 # 功能函数
# 功能函数
def multiply(a, b):
return a * b
# =====fixtures========
def setup_module(module):
print("setup_module================>")
def teardown_module(module):
print("teardown_module=============>")
def setup_function(function):
print("setup_function------>")
def teardown_function(function):
print("teardown_function--->")
def setup():
print("setup----->")
def teardown():
print("teardown-->")
# =====测试用例========
def test_multiply_3_4():
print('test_numbers_3_4')
assert multiply(3, 4) == 12
def test_multiply_a_3():
print('test_strings_a_3')
assert multiply('a', 3) == 'aaa'
这里主要用到模块级别和函数级别的 Fixture。
setup_module/teardown_module:在当前文件中,在所有测试用例执行之前与之后
执行。
setup_function/teardown_function:在每个测试函数之前与之后执行。
setup/teardown:在每个测试函数之前与之后执行。这两个方法同样可以作用于
类方法。
pytest 是支持使用测试类的,同样必须以“Test”开头,注意首字母大写。在引入测试 类的情况下,Fixture 的用法如下。创建 test_fixtures_02.py 文件。
# 功能函数
def multiply(a, b):
return a * b
class TestMultiply:
# =====fixtures========
@classmethod
def setup_class(cls):
print("setup_class=========>")
@classmethod
def teardown_class(cls):
print("teardown_class=========>")
def setup_method(self, method):
print("setup_method----->>")
def teardown_method(self, method):
print("teardown_method-->>")
def setup(self):
print("setup----->")
def teardown(self):
print("teardown-->")
# =====测试用例========
def test_numbers_5_6(self):
print('test_numbers_5_6')
assert multiply(5, 6) == 30
def test_strings_b_2(self):
print('test_strings_b_2')
assert multiply('b', 2) == 'bb'
这里主要用到类级别和方法级别的 Fixture。
setup_class/teardown_class :在当前测试类的开始与结束时执行。
setup_method/teardown_method :在每个测试方法开始与结束时执行。
setup/teardown :在每个测试方法开始与结束时执行,同样可以作用于测试函数。
运行结果。
> pytest -s test_fixtures_02.py
========================= test session starts ==========================
platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0
rootdir: D:\git\book-code\pytest_sample\base_used, inifile:
collected 2 items
test_fixtures_02.py
setup_class=========>
setup_method----->>
setup----->
test_numbers_5_6
.teardown-->
teardown_method-->>
setup_method----->>
setup----->
test_strings_b_2
.teardown-->
teardown_method-->>
teardown_class=========>
======================= 2 passed in 0.03 seconds =======================
9.2.3 参数化 当一组测试用例有固定的测试数据时,就可以通过参数化的方式简化测试用例的编写。 pytest 本身是支持参数化的,不需要额外安装插件。创建 test_parameterize.py 文件。
import pytest
import math
# pytest 参数化
@pytest.mark.parametrize(
"base, exponent, expected",
[(2, 2, 4),
(2, 3, 8),
(1, 9, 1),
(0, 9, 0)],
ids=["case1", "case2", "case3", "case4"]
)
def test_pow(base, exponent, expected):
assert math.pow(base, exponent) == expected
用法与 unittest 的参数化插件类似,通过 pytest.mark.parametrize()方法设置参数。 “base,exponent,expected”用来定义参数的名称。通过数组定义参数时,每一个元组都 是一条测试用例使用的测试数据。ids 参数默认为 None,用于定义测试用例的名称。 math 模块的 pow()方法用于计算 xy(x 的 y 次方)的值。 运行结果如下。
> pytest -v test_parameterized.py
======================== test session starts =========================
platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 --
c:\python37\python37.exe
cachedir: .pytest_cache
rootdir: D:\git\book-code\pytest_sample\base_used, inifile:
collected 4 items
test_parameterize.py::test_pow[case1] PASSED [ 25%]
test_parameterize.py::test_pow[case2] PASSED [ 50%]
test_parameterize.py::test_pow[case3] PASSED [ 75%]
test_parameterize.py::test_pow[case4] PASSED [100%]
====================== 4 passed in 0.14 seconds ======================
“-v”参数增加测试用例冗长。不设置 ids 参数的结果如下。
test_parameterize.py::test_pow[2-2-4] PASSED [ 25%]
test_parameterize.py::test_pow[2-3-8] PASSED [ 50%]
test_parameterize.py::test_pow[1-9-1] PASSED [ 75%]
test_parameterize.py::test_pow[0-9-0] PASSED [100%]
9.2.4 运行测试 pytest 提供了丰富的参数运行测试用例,在前面的例子中已经使用到一些参数,例如, “-s”参数用于关闭捕捉,从而输出打印信息;“-v”参数用于增加测试用例冗长。 通过“pytest --help”可以查看帮助。 > pytest––help pytest 提供的参数比较多,下面只介绍常用的参数。 1.运行名称中包含某字符串的测试用例 > pytest -k add test_assert.py ======================== test session starts ========================= platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 rootdir: D:\git\book-code\pytest_sample\base_used, inifile: collected 11 items / 7 deselected / 4 selected test_assert.py ...[100%] =============== 4 passed, 7 deselected in 0.12 seconds =============== 在 9.2.1 节的 test_assert.py 文件中,我们写了很多测试用例,其中有 4 条是关于 add() 功能的,并且在测试用例的名称上包含了“add”字符串,因此这里可以通过“-k”来指定 在名称中包含“add”的测试用例。 2.减少测试的运行冗长 > pytest -q test_assert.py ....[100%] 11 passed in 0.03 seconds 这一次运行日志少了很多信息,“-q”用来减少测试运行的冗长;也可以使用“--quiet” 代替。 3.如果出现一条测试用例失败,则退出测试 > pytest -x test_fail.py ======================== test session starts ========================= platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 rootdir: D:\git\book-code\pytest_sample\base_used, inifile: collected 2 items test_fail.py F ============================== FAILURES ============================== _____________________________ test_fail ______________________________ def test_fail(): > assert (2 + 1) == 4 E assert (2 + 1) == 4 test_fail.py:5: AssertionError ====================== 1 failed in 0.16 seconds ====================== 这在测试用例的调试阶段是有用的,当出现一条失败的测试用例时,应该先通过调试 让这条测试用例运行通过,而不是继续执行后面的测试用例。 4.运行测试目录 > pytest ./test_dir ======================== test session starts ========================= platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 rootdir: D:\git\book-code\pytest_sample, inifile: collected 2 items test_dir\test_file_01.py . [ 50%] test_dir\test_file_02.py . [100%] ====================== 2 passed in 0.15 seconds ====================== 测 试 目 录 既 可 以 指 定 相 对 路 径 ( 如 ./test_dir ) , 也 可 以 指 定 绝 对 路 径 ( 如 D:\pytest_sample\test_dir)。 5.指定特定类或方法执行 > pytest test_fixtures_02.py::TestMultiply::test_numbers_5_6 ======================== test session starts ========================= platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 rootdir: D:\git\book-code\pytest_sample\base_used, inifile: collected 1 item test_fixtures_02.py . [100%] ====================== 1 passed in 0.05 seconds ====================== 这里指定运行 test_fixtures_02.py 文件中 TestMultiply 类下的 test_numbers_5_6()方法, 文件名、类名和方法名之间用“::”符号分隔。 6.通过 main()方法运行测试 import pytest if __name__ == '__main__': pytest.main(['-s', './test_dir']) 创建 run_tests.py 文件,在文件中通过数组指定参数,每个参数为数组中的一个元素。
9.2.5 生成测试报告 pytest 支持生成多种格式的测试报告。 1.生成 JUnit XML 文件 > pytest ./test_dir --junit-xml=./report/log.xml XML 类型的日志主要用于存放测试结果,方便我们利用里面的数据定制自己的测试报 告。XML 格式的测试报告如图 9-1 所示。 图 9-1 XML 格式的测试报告 2.生成在线测试报告 > pytest ./test_dir --pastebin=all ======================== test session starts ========================= platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 -- c:\python37\python37.exe rootdir: D:\git\book-code\pytest_sample, inifile: collected 2 items test_dir\test_file_01.py . [ 50%] test_dir\test_file_02.py . [100%] ====================== 2 passed in 0.03 seconds ====================== ================ Sending information to Paste Service ================ pastebin session-log: https://bpaste.net/show/61aabfff6cfd 上述代码可生成一个 session-log 链接,复制链接,通过浏览器打开,会得到一张 HTML 格式的测试报告,如图 9-2 所示。 1 图 9-2 HTML 格式的测试报告 9.2.6 conftest.py conftest.py 是 pytest 特有的本地测试配置文件,既可以用来设置项目级别的 Fixture, 也可以用来导入外部插件,还可以用来指定钩子函数。 创建 test_project/conftest.py 测试配置文件。 import pytest # 设置测试钩子 @pytest.fixture() def test_url(): return "http://www.baidu.com" 创建 test_project/test_sub.py 测试用例文件。 def test_baidu(test_url): print(test_url) 这里创建的函数可以直接调用 conftest.py 文件中的 test_url()钩子函数,测试结果如下。 > pytest -s -v test_project\ =========================== test session starts ============================ platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 -- c:\python37\python37.exe cachedir: .pytest_cache rootdir: D:\git\book-code\pytest_sample, inifile: 第 collected 1 item test_project/test_web.py::test_baidu http://www.baidu.com PASSED ========================= 1 passed in 0.02 seconds ========================= 需要说明的是,conftest.py 只作用于它所在的目录及子目录。 9.3 pytest 扩展 Pytest 可以扩展非常多的插件来实现各种功能,这里介绍几个对做 Web 自动化测试非 常有用的插件。 9.3.1 pytest-html pytest-html 可以生成 HTML 格式的测试报告。首先,通过 pip 命令安装 pytest-html 扩 展。 > pip install pytest-html 其次,运行测试用例,并生成测试报告。 > pytest ./ --html=./report/result.html 最后,在 report 目录下打开 result.html,pytest-html 测试报告如图 9-3 所示。 图 9-3 pytest-html 测试报告 pyest-html 还支持测试用例失败的截图,这对于 Web 自动化测试来说非常有用,在 9.4 节详细介绍。 9.3.2 pytest-rerunfailures pytest-rerunfailures 可以在测试用例失败时进行重试。 > pip install pytest-rerunfailures 创建 test_ rerunfailures.py。 def test_fail_rerun(): assert 2 + 2 == 5 通过“--reruns”参数设置测试用例运行失败后的重试次数。 > pytest -v test_rerunfailures.py --reruns 3 =========================== test session starts ============================ platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 -- c:\python37\python37.exe cachedir: .pytest_cache metadata: {'Python': '3.7.1', 'Platform': 'Windows-10-10.0.17763-SP0', 'Packages': {'pytest': '4.3.0', 'py': '1.8.0', 'pluggy': '0.9.0'}, 'Plugins': {'rerunfailures': '6.0', 'metadata': '1.8.0', 'html': '1.20.0'}, 'JAVA_HOME': 'D:\\Java\\jdk1.8.0_144'} rootdir: D:\git\book-code\pytest_sample, inifile: plugins: rerunfailures-6.0, metadata-1.8.0, html-1.20.0 collected 1 item test_rerunfailures.py::test_fail_rerun RERUN [100%] test_rerunfailures.py::test_fail_rerun RERUN [100%] test_rerunfailures.py::test_fail_rerun RERUN [100%] test_rerunfailures.py::test_fail_rerun FAILED [100%] ================================= FAILURES ================================= _____________________________ test_fail_rerun ______________________________ def test_fail_rerun(): > assert 2 + 2 == 5 E assert 4 == 5 E -4 E +5 test_rerunfailures.py:3: AssertionError ==================== 1 failed, 3 rerun in 0.10 seconds ===================== 从运行结果可以看到,在测试用例运行失败后进行了 3 次重试。因为 Web 自动化测试 会因为网络等因素导致测试用例运行失败,而重试机制可以增加测试用例的稳定性。 9.3.3 pytest-parallel 扩展 pytest-parallel 扩展可以实现测试用例的并行运行。 > pip install pytest-parallel 创建 test_ parallel.py,在每条测试用例中分别设置 sleep()来模拟运行时间较长的测试用 例。 from time import sleep def test_01(): sleep(3) def test_02(): sleep(5) def test_03(): sleep(6) 不使用线程运行测试用例。 > pytest -q test_parallel.py ...[100%] 3 passed in 14.05 seconds 参数“--tests-per-worker”用来指定线程数,“auto”表示自动分配。 pytest -q test_parallel.py --tests-per-worker auto pytest-parallel: 1 worker (process), 3 tests per worker (threads) ... [100%] 3 passed in 6.02 seconds 运行时间由 14.05s 被缩短到 6.02s,因为运行时间最长的测试用例为 6s。 pytest-parallel 的更多用法如下所示。 # runs 2 workers with 1 test per worker at a time > pytest --workers 2 # runs 4 workers (assuming a quad-core machine) with 1 test per worker > pytest --workers auto # runs 1 worker with 4 tests at a time > pytest --tests-per-worker 4 # runs 1 worker with up to 50 tests at a time > pytest --tests-per-worker auto # runs 2 workers with up to 50 tests per worker > pytest --workers 2 --tests-per-worker auto 虽然并行运行测试可以非常有效地缩短测试的运行时间,但是 Web 自动化测试本身非 常脆弱,在并行运行测试时很可能会产生相互干扰,从而导致测试用例失败,因此建议谨 慎使用。 9.4 构建 Web 自动化测试项目 相比 unittest 单元测试框架,pytest 更适合用来做 UI 自动化测试,它提供了以下功能。 (1)在 unittest 中,浏览器的启动或关闭只能基于测试方法或测试类;pytest 可以通过 conftest.py 文件配置全局浏览器的启动或关闭,整个自动化测试项目的运行只需启动或关 闭一次浏览器即可,这将大大节省测试用例执行时间。 (2)测试用例运行失败截图。unittest 本身是不支持该功能的,pytest-html 可以实现测 试用例运行失败自动截图,只需在 conftest.py 中做相应的配置即可。 (3)测试用例运行失败重跑。UI 自动化测试的稳定性一直是难题,虽然可以通过元素 等待来增加稳定性,但有很多不可控的因素(如网络不稳定)会导致测试用例运行失败, pytest-rerunfailures 可以轻松实现测试用例运行失败重跑。 9.4.1 项目结构介绍 pyautoTest 项目是对基于 pytest 进行 UI 自动化测试实践的总结,在该项目的基础上, 可以快速编写自己的自动化测试用例。 GitHub 地址:https://github.com/defnngj/pyautoTest。 第 9 章 pytest 单元测试框架 ∣ 167 1.pyautoTest 项目结构如图 9-4 所示。 图 9-4 pyautoTest 项目结构 page/:用于存放 page 层的封装。 test_dir/:测试用例目录。 test_report/:测试报告目录。 conftest.py:pytest 配置文件。 run_tests.py:测试运行文件。 2.命名与设计规范 (1)对于 page 层的封装存放于 page/目录,命名规范为“xxx_page.py”。 (2)对于测试用例的编写存放于 test_dir/目录,命名规范为“test_xxx.py”。 (3)每一个功能点对应一个测试类,并且以“Test”开头,如“TestLogin”“TestSearch”等。 (4)在一个测试类下编写功能点的所有的测试用例,如“test_login_user_null”、 “test_login_pawd_null”及“test_login_success”等。 3.克隆与安装依赖 (1)安装 Git 版本控制工具,将 pyautoTest 项目克隆到本地。 > git clone https://github.com/defnngj/pyautoTest (2)通过 pip 命令安装依赖。 > pip install -r requirements.txt 4.依赖库说明 selenium:Web UI 自动化测试。 pytest:Python 第三方单元测试框架。 pytest-html:pytest 扩展,生成 HTML 格式的测试报告。 pytest-rerunfailures:pytest 扩展,实现测试用例运行失败重跑。 click:命令行工具开发库。 poium:基于 Selenium/appium 的 Page Object 测试库。 9.4.2 主要代码实现 首先,封装页面 Page 层,创建 page/baidu_page.py 文件。 from page_objects import Page, PageElement, PageElements class BaiduPage(Page): search_input = PageElement(id_="kw", describe="搜索框") search_button = PageElement(id_="su", describe="搜索按钮") settings = PageElement(link_text="设置", describe="设置下拉框") search_setting = PageElement(css=".setpref", describe="搜索设置选项") save_setting = PageElement(css=".prefpanelgo", describe="保存设置") # 定位一组元素 search_result = PageElements(xpath="//div/h3/a", describe="搜索结果") 在第 8 章详细介绍了 poium 测试库的使用方法,基于该测试库可以非常简单地封装页 面 Page 层的元素。 其次,编写测试用例,创建 test_dir/test_baidu.py 文件。 import sys from time import sleep from os.path import dirname, abspath sys.path.insert(0, dirname(dirname(abspath(__file__)))) from page.baidu_page import BaiduPage class TestSearch: """百度搜索""" def test_baidu_search_case(self, browser, base_url): """ 百度搜索:pytest """ page = BaiduPage(browser) page.get(base_url) page.search_input = "pytest" page.search_button.click() sleep(2) assert browser.title == "pytest_百度搜索" 创建测试 test_baidu_search()函数,接收 browser 和 base_url 钩子函数。这两个函数需 要在 conftest.py 文件中定义。接下来,创建测试方法调用 BaiduPage 类,传入 browser 驱动, 调用 BaiduPage 类和父类所实现的方法及定义来完成相应的操作。 在测试用例中,可以将注意力集中在测试用例设计本身的操作上,而不需要关心浏览 器驱动、访问的 URL 以及测试用例运行失败截图,因为这些都已经在 conftest.py 文件中配 置好了。 1.conftest.py 文件之自动化配置 … ############################ # 配置浏览器驱动类型(Chrome/Firefox) driver = "chrome" # 配置运行的 URL url = "https://www.baidu.com" # 设置失败重跑次数 rerun = "3" # 运行测试用例的目录或文件 cases_path = "./test_dir/" ############################ … 在不熟悉整个项目配置之前,需要关心以上几个配置,在代码中已经分别加了注释, 这里不再说明。 2.conftest.py 文件之浏览器配置 # 配置浏览器驱动类型 driver_type = "chrome" … # 启动浏览器 @pytest.fixture(scope='session', autouse=True) def browser(): """ 定义全局浏览器驱动 :return: """ global driver global driver_type if driver_type == "chrome": # 本地 Chrome 浏览器 driver = webdriver.Chrome() driver.set_window_size(1920, 1080) elif driver_type == "firefox": # 本地 Firefox 浏览器 driver = webdriver.Firefox() driver.set_window_size(1920, 1080) elif driver_type == "chrome-headless": # chrome headless 模式 chrome_options = CH_Options() chrome_options.add_argument("--headless") chrome_options.add_argument('--disable-gpu') chrome_options.add_argument("--window-size=1920x1080") driver = webdriver.Chrome(chrome_options=chrome_options) elif driver_type == "firefox-headless": # firefox headless 模式 firefox_options = FF_Options() firefox_options.headless = True driver = webdriver.Firefox(firefox_options=firefox_options) elif driver_type == "grid": # 通过远程节点运行 driver = Remote(command_executor='http://10.2.16.182:4444/wd/hub', desired_capabilities={ "browserName": "chrome", }) driver.set_window_size(1920, 1080) else: raise NameError("driver 驱动类型定义错误!") return driver # 关闭浏览器 @pytest.fixture(scope="session", autouse=True) def browser_close(): yield driver driver.quit() print("test end!") Selenium 在启动浏览器时会创建一个 session,当通过@pytest.fixture()装饰浏览器开启 和关闭函数时,scope 参数需要设置为“session”。browser()函数用于定义浏览器,根据全 局变量 driver_type 的定义创建不同的浏览器驱动。browser_close()函数用于实现浏览器的关 闭。 3.conftest.py 文件之失败截图配置 … @pytest.mark.hookwrapper def pytest_runtest_makereport(item): """ 用于向测试用例中添加开始时间、内部注释和失败截图等 :param item: """ pytest_html = item.config.pluginmanager.getplugin('html') outcome = yield report = outcome.get_result() report.description = str(item.function.__doc__) extra = getattr(report, 'extra', []) if report.when == 'call' or report.when == "setup": xfail = hasattr(report, 'wasxfail') if (report.skipped and xfail) or (report.failed and not xfail): case_path = report.nodeid.replace("::", "_") + ".png" if "[" in case_path: case_name = case_path.split("-")[0] + "].png" else: case_name = case_path capture_screenshot(case_name) img_path = "image/" + case_name.split("/")[-1] if img_path: html = '<div><img src="%s" alt="screenshot" style="width:304px;height:228px;" ' \ 'οnclick="window.open(this.src)" align="right"/></div>' % img_path extra.append(pytest_html.extras.html(html)) report.extra = extra def capture_screenshot(case_name): """ 配置测试用例失败截图路径 :param case_name: 用例名 :return: """ global driver file_name = case_name.split("/")[-1] new_report_dir = new_report_time() if new_report_dir is None: raise RuntimeError('没有初始化测试目录') image_dir = os.path.join(REPORT_DIR, new_report_dir, "image", file_name) driver.save_screenshot(image_dir) … 这里的配置会复杂一些,核心参考 pytest-html 文档。pytest_runtest_makereport()钩子函 数的主要功能是判断每条测试用例的运行情况,当测试用例错误或失败后会调用 capture_screenshot()函数进行截图,并将测试用例的“文件名+类名+方法名”作为截图的名 称,保存于 image/目录中。 pytest-html 会生成一张 HTML 格式的测试报告,那么如何将截图插入 HTML 格式的测 试报告中呢?核心就是添加<img>标签,并通过 src 属性指定图片的路径。明白了这一点后, 测试用例失败自动截图就很好理解了。 9.4.3 测试用例的运行与测试报告 在整个项目中还有一个关键文件 run_tests.py,它是用来执行整个项目的测试用例的。 … @click.command() @click.option('-m', default=None, help='输入运行模式:run 或 debug.') def run(m): if m is None or m == "run": print("回归模式,执行完成,生成测试结果") now_time = time.strftime("%Y_%m_%d_%H_%M_%S") init_env(now_time) html_report = os.path.join(REPORT_DIR, now_time, "report.html") xml_report = os.path.join(REPORT_DIR, now_time, "junit-xml.xml") pytest.main(["-s", "-v", cases_path, "--html=" + html_report, "--junit-xml=" + xml_report, "--self-contained-html", "--reruns", rerun]) elif m == "debug": print("debug 模式运行测试用例:") pytest.main(["-v", "-s", cases_path]) print("运行结束!!") if __name__ == '__main__': run() 这里用到了命令行工具开发库 click。click 提供了两种运行模式:run 模式和 debug 模 式。不同模式下的 pytest 的执行参数不同。查看帮助如下。 > python run_tests.py --help Usage: run_tests.py [OPTIONS] Options: -m TEXT 输入运行模式:run 或 debug. --help Show this message and exit. 注意:不要试图在 IDE 中直接运行 run_tests.py 文件,因为 click 并不允许这么 做。请在 Windows 命令符/Linux 终端下运行。 运行方式如下。 174 ∣ Selenium 3 自动化测试实战——基于 Python 语言 > python run_tests.py -m run 回归模式,执行完成,生成测试结果 ============================= test session starts ============================= platform win32 -- Python 3.7.1, pytest-4.3.0, py-1.8.0, pluggy-0.9.0 -- C:\Python37\python.exe cachedir: .pytest_cache metadata: {'Python': '3.7.1', 'Platform': 'Windows-10-10.0.17134-SP0', 'Packages': {'pytest': '4.3.0', 'py': '1.8.0', 'pluggy': '0.9.0'}, 'Plugins': {'rerunfailures': '6.0', 'metadata': '1.8.0', 'html': '1.20.0'}} rootdir: D:/git/pyautoTest, inifile: plugins: rerunfailures-6.0, metadata-1.8.0, html-1.20.0 collected 5 items test_dir/test_baidu.py::TestSearch::test_baidu_search_case PASSED test_dir/test_baidu.py::TestSearch::test_baidu_search[case1] PASSED test_dir/test_baidu.py::TestSearch::test_baidu_search[case2] PASSED test_dir/test_baidu.py::TestSearch::test_baidu_search[case3] PASSED test_dir/test_baidu.py::TestSearchSettings::test_baidu_search_setting test end! PASSED generated xml file: D:\git\pyautoTest\test_report\2018_12_27_23_44_56\junit-xml.xml ------- generated html file: D:\git\pyautoTest\test_report\report.html -------- ========================== 5 passed in 22.23 seconds ========================== 生成的 HTML 测试报告在 test_report 目录下,test_report 目录如图 9-5 所示。当测试用 例运行失败时自动截图,并显示在 HTML 测试报告中,HTML 测试报告如图 9-6 所示。 图 9-5 test_report 目录 第 9 章 pytest 单元测试框架 ∣ 175 图 9-6 HTML 测试报告