测试框架的设计有两种思路,一种是自底向上,从脚本逐步演变完善成框架,这种适合新手了解框架的演变过程。另一种则是自顶向下,直接设计框架结构和选取各种问题的解决方案,这种适合有较多框架事件经验的人。本章和下一张分别从两种设计思路来介绍框架的搭建过程。
从脚本到用例#
相比于一堆测试脚本,使用规范化的测试用例格式会方便我们灵活的执行和管理用例。一个完整的自动化测试用例应包含:
- 测试准备(setup):测试准备步骤、用例辅助方法或工具,可以共用;
- 测试步骤(test steps):核心测试步骤;
- 断言(assertions):期望结果于实际结果的比对,用例可以报告不止一个断言;
- 测试清理(teardown):对执行测试造成的影响进行清理和还原,以免影响后续执行,可以共用。
编写测试函数#
将测试脚本转化为Pytest测试用例的方法非常简单,只要将测试过程编写为test开头的测试函数即可。
有时候我们为了快速实现一个功能,会直接把代码按步骤写到模块里,如下例:
代码test_baidu_search_v0.9.py内容
from selenium import webdriver from time import sleep driver = webdriver.Chrome() driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys('博客园 韩志超') driver.find_element_by_id('su').click() sleep(1) if '韩志超' in driver.title: print('通过') else: print('失败') driver.quit()
然后我们开启第一步优化,首先,可以把步骤写到一个函数里,这样方便在脚步中写多个用例,另外,我们可以按照Pytest测试框架用例到写法,写成标准的用例。期望结果的判断我们使用标准的assert断言语句,修改后如下:
代码test_baidu_search_v1.0.py内容
from selenium import webdriver from time import sleep def test_baidu_search_01(): driver = webdriver.Chrome() driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys('博客园 韩志超') driver.find_element_by_id('su').click() sleep(1) assert '韩志超' in driver.title, '标题不包含韩志超' # 自定义失败消息 driver.quit()
不同于v0.9版Python脚本的运行方法(命令行使用python <脚步路径>),Pytest用例脚本使用pytest <脚本路径>或python -m pytest <脚本路径>来执行。
我们也可以在Pytest用例脚本下面加上以下语句,
if __name__ == '__main__': pytest.main([__file__])
这样便可以像Python脚本一样直接运行。其中__file__指当前脚本,也可以添加其他运行参数,如-qs等。
使用断言#
测试用例中必须包含期望结果来验证执行的通过与否。不同于“调试”,需要有人值守来人工判断没个执行过程是否通过,自动化“测试”往往需要批量运行,并自动判断用例是否通过。断言即是执行过程中的实际结果与期望结果的自动对比。
Pytest中使用标准的assert语句来进行断言。assert断言语句在用例执行失败时(和期望结果不一致)会抛出AssertionError异常,测试框架会自动捕获该异常,并将用例标记为执行失败状态,并且不会因为异常导致执行中断而影响其他用例的执行。
注:在用例中也可以使用if判断配合pytest.fail()或者手动抛出AsserionError异常来将用例设置为失败状态,示例如下:
if '韩志超' not in driver.title: # rasie AssersionError('标题不包含韩志超') pytest.fail('标题不包含韩志超')
Web UI自动化测试过程中常用的断言策略有以下几种:
- 流程成功执行视为通过:按确定的元素操作步骤,可以正常完成整个流程视为通过;
- 通过标题断言:通过当前网页标题driver.title来判断处于某一页面上;
- 通过URL断言:通过当前URL,driver.current_url来判断处于某一页面上;
- 通过页面源码断言:通过网页源代码driver.page_source中包含特定信息来判断处于某一页面上;
- 通过存在特定元素断言:通过存在某个特定元素来判断处于某一页面上。
通过元素判断是否在某一页面上的示例如下:
from selenium import webdriver from selenium.common.exceptions import NoSuchElementException def test_open_baidu(): driver = webdriver.Chrome() driver.get("https://www.baidu.com") try: driver.find_element_by_id('kw') # 尝试定位搜索框 except NoSuchElementException: pytest.fail('不存在搜索框')
在框架中,可以将常用的断言方法进行封装以方便使用。
分离测试准备及清理方法#
在测试用例中,我们要尽可能的分离核心测试步骤,将可以共用的测试准备及测试清理步骤单独提取出来,以方便复用。
在上例中,我们核心的测试步骤是从打开百度网站到断言网页标题,而启动浏览器和关闭浏览器可以视为测试准备和测试清理方法。
测试准备和测试清理方法我们可以使用Pytest中的setup_function()及teardown_function()方法,也可以使用自定义的Fixture方法来吧两个方法集中的一个函数中,如下例:
代码test_baidu_search_v3.py内容
from time import sleep from selenium import webdriver import pytest def setup_function(): global driver driver = webdriver.Chrome() def teardown_function(): driver.quit() def test_baidu_search_01(driver): driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys('博客园 韩志超') driver.find_element_by_id('su').click() sleep(1) assert '韩志超' in driver.title if __name__ == '__main__': pytest.main([__file__])
使用自定义Fixture方法#
代码test_baidu_search_v4.py内容
from time import sleep from selenium import webdriver import pytest @pytest.fixture def driver(): dr = webdriver.Chrome() yield dr dr.quit() def test_baidu_search_01(driver): driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys('博客园 韩志超') driver.find_element_by_id('su').click() sleep(1) assert '韩志超' in driver.title if __name__ == '__main__': # --html需要pip install pytest-html pytest.main([__file__, '--html=report.html','--self-contained-html'])
上例中我们自定义了一个名为driver的Fixture方法。yield上面对的所有语句属于测试准,这里创建了一个浏览器驱动对象dr。yield语句将dr对象交给用例执行,并等待用例执行完毕,再执行下面的测试清理语句,退出浏览器。
用例中使用Fixture函数名driver作为参数来注入测试准备和测试清理方法,用例中使用的driver即Fixture函数yield返回的dr,浏览器驱动对象。
使用Pytest-selenium插件#
Pytest框架的优点之一是,拥有很多功能丰富的插件。使用这些插件可以省略我们自己编写Fixture方法的过程,直接安装使用。
上例中我们自己编写了一个名为driver的fixture方法,我们也可以直接使用Pytest-Selenium插件,该插件提供了一个全局的driver(或selenium)Fixture方法,可以直接使用,并且支持切换使用的浏览器。安装Pytest-Selenium插件,并修改代码如下:
代码test_baidu_search_v5.py内容
from time import sleep from selenium import webdriver import pytest def test_baidu_search_01(driver): driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys('博客园 韩志超') driver.find_element_by_id('su').click() sleep(1) assert '韩志超' in driver.title if __name__ == '__main__': # --html需要pip install pytest-html # --driver 需要pip install pytest-selenium pytest.main([__file__, '--driver=chrome', '--html=report.html','--self-contained-html'])
pytest-selenium还支持配置浏览器选项及配合pytest-html失败自动截图等功能,详细可以参考其官方使用文档https://pytest-selenium.readthedocs.io/en/latest/。
注:pytest-selenium默认会拦截所有接口请求,可以在pytest.ini中配置sensitive_url = ''来设置无敏感url。
生成测试报告#
使用Pytest框架生成测试报告最常用的插件有pytest-html和allure-pytest两种,前者简单,可以生成单文件测试报告。后者华丽,功能强大,使用较为复杂。本章我们使用pytest-html来生成报告,allure-pytest的具体使用下章讲解。
pytest-html的使用方式非常简单,安装pytest-html并使用--html来生成报告即可:
if name == 'main':
# --html需要pip install pytest-html
pytest.main([file, '--html=report.html','--self-contained-html'])
注:如果想自己生成HTML测试报告,可以在conftest.py文件中通过pytest_terminal_summary钩子方法terminalreporter参数对象的stats属性结合三方库Jinjia2来自定义生成报告。
增加易维护性#
众所周知,UI的变动导致Web自动化用例的维护成本非常高,当一个元素变动时(如登录按钮),所有使用到这个元素的用例都将因此而失败,逐个修改每一条用例的成本是非常高的。
最好的做法就是使用模块封装的方式来隔离变动,隔离变动旨在隔离易变的和稳定的,常用的策略为:
- 代码:隔离易变(如元素定位)和稳定的(页面操作),可以使用模块封装的方式对易变的操作进行封装;
- 数据:变动较频繁,建议与代码隔离,以降低代码的修改;
- 配置:配置也是数据的一种,主要用于增加框架使用的灵活性,配置变动也较频繁,讲义与代码隔离。
另外使用数据驱动、添加日志和失败自动截图也是快速定位问题、降低维护成本的有效方法。
元素失败自动截图#
我们可以封装通用的定位元素方法来代替driver.find_element(),在其中捕获异常并截图。
并且为了方便区分元素,定位元素时为元素添加了一个高亮黄色的边框。实现方式如下:
import time import os from selenium import webdriver from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, NoSuchElementException SNAPSHOTS_DIR = 'snapshots' def find_element(driver: webdriver.Chrome, by, value, timeout=5): style = 'background: green; border: 2px solid red;' js = 'arguments[0].setAttribute("style", arguments[1]);' try: WebDriverWait(driver, timeout).until( EC.presence_of_element_located((by,value)) ) except TimeoutException: snapshot_file = 'snapshot_%s.png' % int(time.time()) driver.save_screenshot(os.path.join(SNAPSHOTS_DIR, snapshot_file)) raise NoSuchElementException('%s 秒内未找到元素 %s=%s' % (timeout, by, value)) else: element = driver.find_element(by, value) driver.execute_script(js, element, style) # 添加高亮样式 return element
分层-封装测试步骤#
我们可以使用分层的方式,将每个测试步骤,如打开百度、输入关键词、点击搜索按钮等,封装成函数以供用例调用。
我们可以每个元素操作封装一个函数,也可以封装一个包含这3步操作等搜索函数,来完成所有步骤。前一种方法虽然麻烦,但可以保证步骤操作的灵活性,并自由组合,如打开百度其他用例也可使用,如输入关键词后不点击搜索按钮等,示例代码如下:
代码test_baidu_search_v6.py内容
from time import sleep from selenium import webdriver import pytest def find_element(driver, by, value, timeout=5): ... def open_baidu(driver): print('打开百度') driver.get("https://www.baidu.com") def input_keyword(driver, keyword): print(f'输入关键字 {keyword}') find_element(driver, 'id', 'kw').send_keys(keyword) def click_search_btn(driver): print('点击百度一下按钮') find_element(driver, 'id', 'su').click() def test_baidu_search_01(driver): open_baidu(driver) # Step 01 input_keyword(driver, '博客园 韩志超') # Step 02 click_search_btn(driver) # Step 03 sleep(1) assert '韩志超' in driver.title # 断言 if __name__ == '__main__': # --html需要pip install pytest-html pytest.main([__file__, '--html=report.html','--self-contained-html'])
当我们将元素的操作进行封装,以实现只在一个地方定位和操作易变的元素。所有使用到该元素的该操作时(如输入关键词),都应该调用封装的函数,而不是直接定位函数完成操作。这样当元素变动是,只需要修复所封装的元素操作方法即可(用例不用修改)。这大大降低了维护成本。
分离测试数据#
相对于代码来说,测试数据是易变的,同时不同悲催环境使用的测试数据集也应该不一样。
在数据量较少的情况下,我们可以用一个JSON或YAML文件来存储所需的测试数据。
文件data.json内容
{ "keywords": ["博客园 韩志超","临渊", "简书 韩志超"] }
文件data.yaml内容
keywords: - 博客园 韩志超 - 临渊 - 简书 韩志超
代码test_baidu_search_v7.py内容
import json import yaml # 需要pip install pyyaml安装 import pytest def load_json(file_path): print(f'加载JSON文件{ file_path }') with open('data.json') as f: return json.load(f) def load_yaml(file_path): print(f'加载YAML文件{ file_path }') with open('data.json') as f: return yaml.safe_load(f) @pytest.fixture def case_data(): # return load_json('demo.json') return load_yaml('demo.yaml') @pytest.fixture def driver(): dr = webdriver.Chrome() yield dr dr.quit() def test_baidu_search_01(driver, case_data): keyword = case_data['keywords'][0] # 从用例数据中选取指定数据 driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys(keyword) driver.find_element_by_id('su').click() sleep(1) assert '韩志超' in driver.title
注:Fixtrue函数不建议用使用test_开头,如test_data定义fixture,以免识别为测试用例。
使用数据驱动#
示例代码如下:
import pytest KEYWORD_LIST= load_yaml('demo.yaml')['keywords'] @pytest.mark.paramitrize('keyword', KEYWORD_LIST) def test_baidu_search_01(driver, keyword): # keyword对应每一个要搜索的关键词 driver.get("https://www.baidu.com") driver.find_element_by_id('kw').send_keys(keyword) driver.find_element_by_id('su').click() sleep(1) assert keyword in driver.title # 有可能失败
使用日志#
在项目中必要的输出信息可以帮助我们显示测试步骤的一些中间结果和快速的定位问题,虽然Pytest框架可以自动捕获print信息并输出屏幕或报告中,当时更规范的应使用logging的记录和输出日志。 相比print, logging模块可以分等级记录信息。
日志等级
实用方法层、页面对象层、Fixture业务层、用例层都可以直接使用logging来输出日志, 使用方法。
# test_logging.py import logging def test_logging(): logging.debug('调试信息') logging.info('步骤信息') logging.warning('警告信息,一般可以继续进行') logging.error('出错信息') try: assert 0 except Exception as ex: logging.exception(ex) # 多行异常追溯信息,Error级别 logging.critical("严重出错信息")
使用pytest运行不会有任何的log信息,因为Pytest默认只在出错的信息中显示WARNING以上等级的日志。 要开启屏幕实时日志,并修改log显示等级。
Log等级: NOTSET < DEBUG < INFO < WARNING(=WARN) < ERROR < CRITICAL
# pytest.ini [pytest] log_cli=True log_cli_level=INFO
运行pytest test_logging.py,查看结果:
----------------------------- live log call ------------------------------- INFO root:test_logging.py:5 步骤信息 WARNING root:test_logging.py:6 警告信息,一般可以继续进行 ERROR root:test_logging.py:7 出错信息 ERROR root:test_logging.py:11 assert 0 Traceback (most recent call last): File "/Users/apple/Desktop/demo/test_logging.py", line 9, in test_logging assert 0 AssertionError: assert 0 CRITICAL root:test_logging.py:12 严重出错信息
由于日志等级设置的为INFO级别,因此debug的日志不会输出。
对于不同层日志级别的使用规范,可以在实用方法层输出debug级别的日志,如组装的文件路径,文件读取的数据,执行的sql,sql查询结果等等。 在PageObject层输出info级别的日志,如执行某个页面的某项操作等。 Fixtures层和用例层可以根据需要输出一些必要的info,warning或error级别的信息。
日志格式
默认的日志格式没有显示执行时间,我们也可以自定义日志输出格式。
# pytest.ini ... log_cli_format=%(asctime)s %(levelname)s %(message)s log_cli_date_format=%Y-%m-%d %H:%M:%S %(asctime)s表示时间,默认为Sat Jan 13 21:56:34 2018这种格式,我们可以使用log_cli_date_format来指定时间格式。 %(levelname)s代表本条日志的级别 %(message)s为具体的输出信息
再次运行pytest test_logging.py,显示为以下格式:
-------------------------------- live log call ------------------------------- 2019-11-06 21:44:50 INFO 步骤信息 2019-11-06 21:44:50 WARNING 警告信息,一般可以继续进行 2019-11-06 21:44:50 ERROR 出错信息 2019-11-06 21:44:50 ERROR assert 0 Traceback (most recent call last): File "/Users/apple/Desktop/demo/test_logging.py", line 9, in test_logging assert 0 AssertionError: assert 0 2019-11-06 21:44:50 CRITICAL 严重出错信息
更多日志显示选项
- %(levelno)s: 打印日志级别的数值
- %(pathname)s: 打印当前执行程序的路径,其实就是sys.argv[0]
- %(filename)s: 打印当前执行程序名
- %(funcName)s: 打印日志的当前函数
- %(lineno)d: 打印日志的当前行号
- %(thread)d: 打印线程ID
- %(threadName)s: 打印线程名称
- %(process)d: 打印进程ID
输出日志到文件
在pytest.ini中添加以下配置
... log_file = logs/pytest.log log_file_level = debug log_file_format = %(asctime)s %(levelname)s %(message)s log_file_date_format = %Y-%m-%d %H:%M:%S
log_file是输出的文件路径,输入到文件的日志等级、格式、日期格式要单独设置。 遗憾的是,输出到文件的日志每次运行覆盖一次,不支持追加模式。
用例依赖处理#
一般来说,不建议用例之间存在顺序依赖。用例应该不依赖其他任何用例能够独立运行。加入确实存在步骤的先后顺序,如:
def test_add_customer(): pass def test_query_customer(): pass def test_delete_customer(): pass
假设测试查询客户及测试删除用户需要先添加用户,常用的处理方法如下:
- 使用步骤封装代替用例顺序依赖
将业务步骤单独封装,并在用例中进行调用,如:
def add_customer(): pass def query_customer(): pass def delete_customer(): pass def test_add_customer(): add_customer() def test_query_customer(): add_customer() query_customer() def test_delete_customer(): add_customer() delete_customer()
虽然add_customer()方法会执行多次,但是每条用例都可以单独执行,比较推荐这种方式。
- 使用例按顺序执行
如果想要强制用例有序可以使用插件pytest-ordering,使用pip安装后,使用方式如下:
@pytest.mark.run(order=1) def test_add_customer(): pass @pytest.mark.run(order=2) def test_query_customer(): pass @pytest.mark.run(order=3) def test_delete_customer(): pass
这是我整理的《2024最新Python自动化测试全套教程》,以及配套的接口文档/项目实战【网盘资源】,需要的朋友可以下方视频的置顶评论获取。肯定会给你带来帮助和方向。
【已更新】B站讲的最详细的Python接口自动化测试实战教程全集(实战最新版)