第5章扩展现有自动化测试框架

5.1开发pytest插件
在2.3.10节和2.3.12节中使用了pytestxdist、pytesthtml和allurepytest 3个插件,插件是pytest的强大特性之一,其为第三方开发者提供了扩展pytest的无限可能。
在开发插件之前,首先了解一下插件的加载顺序: 
第1步,禁用命令行参数p no:plugin_name指定的插件。该方法可禁用包括内置插件在内的所有插件。
第2步,加载全部内置插件。内置插件是通过扫描pytest的内部包(_pytest包)加载的。
第3步,加载使用命令行参数p plugin_name指定的插件。
第4步,加载使用setuptools入口点注册的插件。
第5步,加载使用PYTEST_PLUGINS环境变量指定的插件。
第6步,加载conftest.py文件中全局变量pytest_plugins指定的插件。
5.1.1使用pytest Hook
pytest的Hook回调函数是开发外部插件的基础,因此首先需要了解它们。
pytest的Hook回调函数分为以下6类。
(1) 引导回调函数: 引导回调函数的作用是便于内部或外部插件尽早地注册。
(2) 初始化回调函数: 供插件或conftest.py文件用于初始化操作。
(3) 收集回调函数: 用于文件和目录的收集。
(4) 测试运行回调函数: 用于测试运行,它们大都接受一个Item对象。
(5) 报告回调函数: 用于测试报告的回调函数。
(6) 调试/交互回调函数: 用于开发调试或异常交互的回调函数。
下面以参数化测试的回调函数pytest_generate_tests()为例介绍pytest回调函数的使用,pytest_generate_tests()属于收集类的回调函数。为此新增extend_test_framework包,在extend_test_framework包中新增use_pytest_hook模块,并编写pytest_generate_tests()函数和test_add()测试函数的代码。
【例51】使用pytest_generate_tests()回调函数。

from pytest import main



from chapter_02.learning_unittest.calculator import Calculator





def pytest_generate_tests(metafunc):

metafunc.parametrize(('num_01', 'num_02', 'expected'), [

(1, 2, 3),

(1, 3, 4)

])





def test_add(num_01, num_02, expected):

assert Calculator().add(num_01, num_02) == expected





if __name__ == '__main__':

main()


以上代码使用了pytest_generate_tests()回调函数,其参数metafunc是一个Metafunc对象,用于生成参数化的测试函数/方法。Metafunc对象包含一个parametrize()方法,其使用方式类似于2.3.7节中介绍的@pytest.mark.parametrize装饰器。
除了直接放在模块中,也可以将pytest_generate_tests()回调函数放在conftest.py文件中,这样,在conftest.py文件的作用域内所有测试函数/方法均被参数化了。在pytest中,这种在conftest.py文件中使用回调函数的方式被称为local perdirectory plugins(本地每个目录单独的插件)。
5.1.2开发本地插件
在pytest中,插件的实质就是包含了一个或多个Hook回调函数的独立Python模块。理解这一点后,开发一个插件就非常简单了。
先做一些准备工作。在extend_test_framework包中新增一个calculator_plugin子包,在calculator_plugin子包中新增calculator_plugin模块,将5.1.1节中的pytest_generate_tests()回调函数复制到calculator_plugin模块中。
calculator_plugin模块就是一个插件,为了使用插件,新增了一个名为use_calculator_plugin的Python模块,并在其中编写使用calculator_plugin插件的代码。
【例52】使用本地插件。

from pytest import main



from chapter_02.learning_unittest.calculator import Calculator



pytest_plugins = 'chapter_05.extend_test_framework.calculator_plugin.calculator_plugin'





def test_add(num_01, num_02, expected):

assert Calculator().add(num_01, num_02) == expected





if __name__ == '__main__':

main()


从以上代码可以看出,使用插件非常简单,只需要在测试模块中使用pytest_plugins全局变量注册即可。
除了在测试模块中注册,也可以在conftest.py文件中注册,代码如下: 

pytest_plugins = 'chapter_05.extend_test_framework.calculator_plugin.calculator_plugin'


另外,如果需要注册多个插件,可将插件以列表形式传递给pytest_plugins全局变量,代码如下: 

pytest_plugins = ['path.to.plugin_01', 'path.to.plugin_02']



本节开发的插件是一个本地插件,即没有共享到公共仓库中的插件,如果其他人需要使用该插件,就只能复制它的源代码。因此为了让其他人可以使用自己开发的插件,需要将插件共享到公共仓库,这就是5.1.3节将要介绍的可安装插件。






 说明如果只在公司内部共享插件,可以使用公司内部的私有仓库,将插件打包并上传到私有仓库后,公司内部的其他人可通过私有仓库下载和安装插件,有关私有仓库详见7.3节。



5.1.3开发可安装的插件
开发可安装的插件需要依赖setuptools进行打包配置,配置完成后再进行打包和上传到公共仓库,本节以上传到PyPI为例。
在extend_test_framework包中新增setup模块,该模块中存放的是打包所需的配置信息。
【例53】打包所需的配置信息。

from setuptools import setup



setup(

name='pytest-demo-plugin',

version='1.0.0',

description='pytest示例插件',

author='卢家涛',

author_email='522430860@qq.com',

url='https://github.com/lujiatao2',

packages=['chapter_05.extend_test_framework.calculator_plugin'],

entry_points={

'pytest11': ['calculator_plugin = chapter_05.extend_test_framework.calculator_plugin.calculator_plugin']},

classifiers=['Framework :: Pytest']

)


以上代码只调用了setuptools的setup()函数,现对以上setup()函数的参数解释如下。
(1) name: 应用程序的名称,这里指pytest插件的名称。在PyPI中,该应用程序的URL会被指定为https://pypi.org/project/pytestdemoplugin/,并可使用pip install pytestdemoplugin命令来安装它。pytest插件一般使用pytest为前缀命名,但这只是规范,并非强制要求。
(2) version: 应用程序的版本号。
(3) description: 应用程序的简单描述。
(4) author: 作者姓名。
(5) author_email: 作者电子邮箱。
(6) url: 项目主页。
(7) packages: 需要打包的目录。
(8) entry_points: 入口点。要被pytest识别为插件,需将Key设置为pytest11。
(9) classifiers: 分类器。PyPI将读取分类器的列表数据对应用程序进行分类,比如应用程序支持Python 3.7,则可以写成Programming Language:: Python :: 3.7。以上代码中的分类器将该应用程序分类为pytest框架,即Framework:: Pytest。






 说明以上代码只是使用了基本的打包配置,更多打包配置详见6.10.1节。



为了能够顺利打包,在打包之前最好进行打包前的命令校验,命令如下: 


python chapter_05\\extend_test_framework\\setup.py check
如果回显结果有告警,那么需要解决告警。比如缺失URL,回显如下: 


warning: check: missing required meta-data:url
如果回显结果没有告警,那么可以进行正式打包操作了。
由于当前Python应用程序的主流打包格式是wheel,因此在打包之前需要安装wheel依赖,执行命令即可安装wheel,命令如下: 


pip install wheel
接着执行命令将应用程序打包到工程的dist目录,命令如下: 


python chapter_05\\extend_test_framework\\setup.pysdist bdist_wheel
执行完成后,查看工程的dist目录,可以看到此时已经生成了pytest_demo_plugin1.0.0py3noneany.whl和pytestdemoplugin1.0.0.tar.gz两个打包文件,前者是wheel格式的安装包,后者是源代码的压缩包。
打包完成后就可以上传到PyPI了。但在上传之前还需要安装twine,它用于上传应用程序到PyPI,执行命令即可安装twine,命令如下: 


pip install twine
最后执行命令上传应用程序到PyPI,命令如下: 


twine upload dist/*
执行以上命令时会要求输入PyPI的用户名和密码(可在PyPI上免费注册账户),输入成功后会自动开始上传,上传完成后可访问PyPI查看该应用程序,如图51所示。



图51pytestdemoplugin的PyPI主页






 说明当修改应用程序后,必须修改版本号,否则PyPI将禁止上传的动作。即使将PyPI上的应用程序删除掉,也不能再上传相同版本号的应用程序。



由于当前工程存在pytestdemoplugin插件的源代码,为了避免冲突,这里重新创建了一个工程masteringtestautomationfortest用于pytestdemoplugin插件的测试。该工程同样也会作为第6章的测试工程。
由于这里使用了Python虚拟环境,因此需要在当前工程中重新安装pytest,然后执行命令安装pytestdemoplugin插件,命令如下: 


pip install pytest-demo-plugin
安装完成后,在masteringtestautomationfortest工程中新增use_pytest_demo_plugin包,并将masteringtestautomation工程中的chapter_02\\learning_unittest\\calculator.py模块复制到use_pytest_demo_plugin包中,最后新增use_pytest_demo_plugin模块,代码如下: 

from pytest import main



from use_pytest_demo_plugin.calculator import Calculator





def test_add(num_01, num_02, expected):

assert Calculator().add(num_01, num_02) == expected





if __name__ == '__main__':

main()


执行命令运行测试代码,命令如下: 

pytest -q use_pytest_demo_plugin\\use_pytest_demo_plugin.py

执行结果为通过,因此说明插件已经被正确安装和使用了。
5.2使用Requests Hook
在实际项目中,当请求响应后通常需要先判断响应是否正常,如果正常,才会从响应中提取指定内容作为后续使用或作为实际结果进行断言。
第一种判断响应是否正常的方式是判断状态码,当状态码为4××或5××,表示响应异常。在extend_test_framework包中新增use_requests_hook模块,代码如下: 

import requests



response = requests.get('https://httpbin.org/get')

if response.status_code < 400:

print(response.json())

else:

raise RuntimeError(f'请求失败: {response.reason}')


以上代码访问了Response对象的status_code属性来获取状态码,如果状态码小于400,那么将以JSON形式打印响应体,否则抛出运行时异常。Response对象的reason属性表示状态文本,如404 Not Found中的Not Found。
在Requests中,Response对象提供了一个ok属性来直接判断状态码是否小于400,如果小于400,就返回True,否则返回False。因此以上代码中的if语句可以被重构为使用ok属性,代码如下: 

if response.ok:

print(response.json())

else:

raise RuntimeError(f'请求失败: {response.reason}')







 说明ok属性实际上是一个使用@property装饰器修饰的方法,其内部实现是依赖了raise_for_status()方法,而raise_for_status()方法的作用是检测状态码是否为4××或5××,如果是,就抛出HTTPError异常。



另一种判断响应是否正常的方式是判断响应体的code,这种判断方式依赖于具体项目。例如,约定当code不等于0时就代表请求异常,并且在message(具体名称取决于实际项目,也可能叫msg、errorMessage、errorMsg等)中描述异常的具体原因,代码如下: 

response = requests.get('http://ims.lujiatao.com/api/goods-category')

body = response.json()

if body['code'] == 0:

print(body)

else:

raise RuntimeError(f'请求失败: {body["msg"]}')

由于还未登录IMS,因此直接获取物品分类会报错,执行以上代码后,执行结果如下: 

Traceback (most recent call last):

File "E:/Software_Testing/Software Development/Python/PycharmProjects/mastering-test-automation/chapter_05/extend_test_framework/use_requests_hook.py", line 13, in <module>

raise RuntimeError(f'请求失败: {body["msg"]}')

RuntimeError: 请求失败: 未登录!


现在的问题是如果自动化测试用例中需要调用成百上千个接口,每个接口都手动检测响应是否正常,那将存在大量冗余代码。幸亏Requests提供了Hook功能,可以在请求中注册回调函数来解决这个问题。
针对状态码的校验,可编写回调函数,代码如下: 

def check_status_code(response, *args, **kwargs):

if response.ok:

return response

else:

raise RuntimeError(f'请求失败: {response.reason}')


回调函数将Response对象作为了第一个参数,然后在函数体中对它进行进一步的校验操作。
回调函数编写完成后,可在请求中传递给hooks参数以注册该回调函数,代码如下: 

requests.get('http://ims.lujiatao.com/login', hooks={'response': check_status_code})


hooks参数接受一个字典作为参数,目前字典Key仅支持response,它表示对响应执行回调操作。
同理,也可以将判断响应体code的逻辑封装到回调函数中,代码如下: 

def check_body_code(response, *args, **kwargs):

body = response.json()

if body['code'] == 0:

return body

else:

raise RuntimeError(f'请求失败: {body["msg"]}')


要在请求中注册多个回调函数,只需要将它们以列表形式依次传入即可,代码如下: 

requests.get('http://ims.lujiatao.com/api/goods-category', hooks={'response': [check_status_code, check_body_code]})


另外,可将回调函数应用于会话,这样可对会话中的每个请求都执行相同的回调操作,代码如下: 

with Session() as session:

session.hooks['response'].append(check_status_code)

session.hooks['response'].append(check_body_code)

...


append()方法用于注册单个回调函数,若需一次性注册多个,则可使用extend()方法,代码如下: 

session.hooks['response'].extend([check_status_code, check_body_code])


5.3实现Selenium等待条件和事件监听器
5.3.1实现Selenium等待条件
在4.2.6节中使用WebDriverWait类进行显式等待时,用到了expected_conditions模块内置的invisibility_of_element_located类作为等待条件,而由于invisibility_of_element_located类中显式定义了__call__()方法,因此其实例是可以被直接调用的。而WebDriverWait类的until()方法,实际上是接受一个方法作为参数,既然如此完全可以不使用内置的expected_conditions模块,转而实现自定义的等待条件。
1.  使用lambda表达式
lambda表达式实际上是一个匿名函数,因此可以将4.2.6节中的invisibility_of_element_located类直接替换为lambda表达式。
【例54】使用lambda表达式实现Selenium等待条件。

from selenium.webdriver import Chrome

from selenium.webdriver.support.wait import WebDriverWait



with Chrome() as driver:

driver.get('http://wft.lujiatao.com/')

driver.find_element_by_link_text('处理等待').click()

web_driver_wait = WebDriverWait(driver, 60, poll_frequency=1)

web_driver_wait.until(lambda d: not d.find_element_by_id('loading').is_displayed())


2.  使用独立的代码封装
Python的lambda表达式被限制为只能包含一行代码,且其逻辑与数据是耦合在一起的。因此使用独立的代码封装是一种更好的选择,这里使用的是一个Python类作为独立的代码封装。为此在extend_test_framework包中新建一个my_expected_conditions模块,在该模块中新增一个InvisibilityOfElement类,该类意在提供与invisibility_of_element_located类一样的功能。
【例55】使用Python类实现Selenium等待条件。

class InvisibilityOfElement:



def __init__(self, locator):

self.locator = locator



def __call__(self, driver):

element = driver.find_element(*self.locator)

return element if not element.is_displayed() else False


接着将lambda表达式替换为InvisibilityOfElement类即可,代码如下: 

web_driver_wait.until(InvisibilityOfElement((By.ID, 'loading')))


5.3.2实现Selenium事件监听器
事件监听器用于在特定事件发生时触发特定操作,常见的一个场景是当Selenium抛出异常时,对窗口进行截图留存,以便后续的回溯工作。
要实现一个事件监听器,需要继承AbstractEventListener类,并重写其中的方法,因为AbstractEventListener类中的方法都是空实现,如表51所示。


表51AbstractEventListener类的方法


方 法 名 称方 法 含 义

before_navigate_to
导航到目标页面之前触发的操作
after_navigate_to
导航到目标页面之后触发的操作
before_navigate_back
返回上一个页面之前触发的操作
after_navigate_back
返回上一个页面之后触发的操作

before_navigate_forward
前进到下一个页面之前触发的操作

after_navigate_forward
前进到下一个页面之后触发的操作

before_find
查找元素之前触发的操作
after_find
查找元素之后触发的操作

before_click
单击元素之前触发的操作续表


方 法 名 称方 法 含 义

after_click
单击元素之后触发的操作

before_change_value_of
改变元素值之前触发的操作
after_change_value_of
改变元素值之后触发的操作
before_execute_script
执行脚本之前触发的操作
after_execute_script
执行脚本之后触发的操作
before_close
关闭窗口之前触发的操作

after_close
关闭窗口之后触发的操作
before_quit
结束浏览器会话之前触发的操作
after_quit
结束浏览器会话之后触发的操作

on_exception
Selenium抛出异常时触发的操作

下面以on_exception()方法的重写为例介绍事件监听器的实现。为此新增my_listener模块,并新增一个MyListener类用于继承AbstractEventListener类。
【例56】实现Selenium事件监听器。

from datetime import datetime



from selenium.webdriver.support.abstract_event_listener import AbstractEventListener





class MyListener(AbstractEventListener):



def on_exception(self, exception, driver):

filename = datetime.now().strftime('%Y%m%d%H%M%S')

driver.save_screenshot(fr'E:\Other\{filename}.png')



以上代码重写了on_exception()方法,该方法用于在Selenium抛出异常时对屏幕进行截图保存,截图名称是当前时间,精确到秒。
事件监听器需要配合EventFiringWebDriver类才能使用,在EventFiringWebDriver的构造方法中将事件监听器对象作为参数传递即可。以访问WFT首页,并尝试查找一个不存在的元素为例。为此新增implement_listener模块,代码如下: 

from selenium.webdriver import Chrome

from selenium.webdriver.support.event_firing_webdriver import EventFiringWebDriver



from chapter_05.extend_test_framework.my_listener import MyListener



with Chrome() as driver:

new_driver = EventFiringWebDriver(driver, MyListener())

new_driver.get('http://wft.lujiatao.com/')

new_driver.find_element_by_id('no-id')



执行以上代码后,E:\\Other目录生成了截图。