文章目录
- 测试框架
- pytest
- Mark
- skip
- 参数化
- 异常处理
- 数据驱动
- Allure
- 集成
- 生成报告
- Fixture
- 基操
- 作用域
- yield
- 数据共享
- 自动应用
- 参数化
- ini
- 运行规则
- 配置命令行参数
- 指定/忽略执行目录
- 配置日志
- 插件开发
- 常用插件
- 分布式并发
- 自定义插件
- 打包发布
- hook
- 小结
测试框架
- 先了解unittest
- 问题分析
- 自动化测试前,需要准备好数据,测试完成后,需要自动清理脏数据
- 自动化测试中,需要使用多套测试数据实现用例的参数化,有没有便捷的方式?
- 自动化测试后,如何优雅的生成报告?
- pytest框架可以满足以上的需求
pytest
- 优点
- 支持单元测试和复杂功能测试,兼容unittest
- 结合requests实现接口测试,还可以结合selenium,appium实现自动化功能测试
- 结合Allure集成到Jenkins实现持续集成
- 支持300+插件并可以自定义插件
- 安装
pip install -U pytest
- 使用规则
- 测试文件要以test_开头,或者_test结尾
- 类要以Test开头
- 方法/函数(无需继承类),要以test_开头
- 测试类不能写__init__方法
- PyCharm配置
- 默认Test Runner
- demo
def inc(x): return x+1 def test_assert(): assert inc(4) == 5 class TestAnswer(): def test_demo(self): assert 1==1
- 默认Test Runner
- 命令行执行case方式
- 运行:
pytest
,会执行当前目录下所有测试文件 - 运行:
pytest test_demo.py
,执行这个文件的所有case - 运行:
pytest test_demo.py::test_assert1
,执行文件下某个函数case - 运行:
pytest test_demo.py::TestDemo
,执行这个文件的这个类下的所有case - 运行:
pytest test_demo.py::TestAnswer::test_demo2
,执行类下面某个方法case - 总之,通过
::
递进
- 运行:
- 命令行参数
- 参数介绍
- demo,具体的用例在下面
- 参数介绍
- setup/teardown
- 规则
- demo,在terminal使用
-v -s
参数运行# 模块(文件)级别,当前文件(suite)下所有case前后执行一次 def setup_module(): print("\n连接资源") def teardown_module(): print("\n释放资源...") # 函数级别,每个函数case执行前后 def setup_function(): print("\n函数资源准备") def teardown_function(): print("\n函数资源释放...") def inc(x): return x+1 def test_assert1(): assert inc(4) == 5 def test_assert2(): assert inc(3) == 4 class TestAnswer(): # 类级别,只在所有类方法前后执行一次 def setup_class(self): print("\nsetup class") def teardown_class(self): print("\nteardown class...") def setup_method(self): print("\nclass method setup") def teardown_method(self): print("\nclass method teardown...") def test_demo1(self): assert 1==1 def test_demo2(self): assert 2==2
- 规则
Mark
- 一个module(测试文件)里可能需要一部分不运行,或只运行某些case
- 命令行一个个指定太麻烦,可以在编写case时加上标记
- 比如某些case只在Web测,有些只在APP测
- 自定义marker,标记测试用例:
@pytest.mark.xxx
# 模块(文件)级别,当前文件(suite)下所有case前后执行一次 import pytest def setup_module(): print("\n连接资源") def teardown_module(): print("\n释放资源...") # 函数级别,每个函数case执行前后 def setup_function(): print("\n函数资源准备") def teardown_function(): print("\n函数资源释放...") def inc(x): return x+1 @pytest.mark.integer def test_assert1(): print("test print with -s param") assert inc(4) == 5 @pytest.mark.integer def test_assert2(): assert inc(3) == 4 class TestAnswer(): # 类级别,只在所有类方法前后执行一次 def setup_class(self): print("\nsetup class") def teardown_class(self): print("\nteardown class...") def setup_method(self): print("\nclass method setup") def teardown_method(self): print("\nclass method teardown...") @pytest.mark.char def test_demo1(self): assert 1==1 @pytest.mark.char def test_demo2(self): assert 2==2
- 运行:
pytest test_demo1.py -vs -m "integer"
- 或者
pytest .\test_demo1.py -vs -m "not char"
- 或者
- 但我们发现有很多warning,因为pytest定义好了一些marker(比如skip),不用它的就会警告
- 怎么不报警呢?新建
pytest.ini
文件[pytest] markers = integer char
- 让pytest接受我们自定义的marker
- 怎么不报警呢?新建
skip
- 跳过某些case的另一种方法,也支持条件过滤
import sys import pytest @pytest.mark.skip def test_demo1(): print("skip this case") assert True @pytest.mark.skip(reason="开发还没写代码") def test_demo2(): assert False def check(): return False # 测试代码里不满足某个条件,直接跳过,有点像skipif def test_demo3(): print("test skip") if not check(): pytest.skip("unsupported") print("end") @pytest.mark.skipif(sys.platform=="win32", reason="this kind of platform is not supported!") def test_demo4(): assert True
mark.xfail
,如果case执行成功则XPASS,如果失败则标记为XFAIL,主要是提示的作用,表示这里有个bug还没解决,我们后续可以通过pytest test_demo1.py -vs -m "xfail"
执行这部分@pytest.mark.xfail def test_fail1(): assert False xfail = pytest.mark.xfail # 定义装饰器 @xfail def test_fail2(): assert True def test_fail3(): print("start test") pytest.xfail("功能代码未实现,失败") # 直接让case失败在这里,类似skip print("end")
参数化
- 参数化设计方法就是将模型中的定量信息变量化,使之成为可以任意调整的参数
- 比如要测试搜索框,搜索内容应该可传参,可以参数化;类似unittest + ddt
- 单参数和多参数
import pytest search_name = ['selenium', 'appium', 'ut', 'pytest'] @pytest.mark.parametrize('name', search_name) # 4个case,取决于参数个数 def test_param1(name): assert name in search_name @pytest.mark.parametrize("_input, expected", [('3+5', 9), ('4+4', 8)]) def test_param2(_input, expected): assert eval(_input)+1 == expected
ids
参数指定case名字,默认是你写的case名称,再拼上参数,参数之间用-
连接,
- 笛卡尔积,用的比较少
@pytest.mark.parametrize('p1', ['1','2','3']) @pytest.mark.parametrize('p2', ['4','5','6']) def test_param3(p1, p2): print(p1, "===", p2)
- 补充:命令行参数
--lf
,即--last-failed
,只重新运行失败的cass,(为啥不是 --of)--ff
,即--failed-first
,先执行上次失败的case,再执行其他测试
- Python命令直接执行pytest测试
- 命令:
python test_demo1.py
# test_demo1.py # 模块(文件)级别,当前文件(suite)下所有case前后执行一次 import pytest def setup_module(): print("\n连接资源") def teardown_module(): print("\n释放资源...") # 函数级别,每个函数case执行前后 def setup_function(): print("\n函数资源准备") def teardown_function(): print("\n函数资源释放...") def inc(x): return x+1 @pytest.mark.integer def test_assert1(): print("test print with -s param") assert inc(4) == 5 @pytest.mark.integer def test_assert2(): assert inc(3) == 4 class TestAnswer(): # 类级别,只在所有类方法前后执行一次 def setup_class(self): print("\nsetup class") def teardown_class(self): print("\nteardown class...") def setup_method(self): print("\nclass method setup") def teardown_method(self): print("\nclass method teardown...") @pytest.mark.char def test_demo1(self): assert 1==2 @pytest.mark.char def test_demo2(self): assert 2==2 # python test_demo1.py if __name__ == '__main__': # pytest.main() # 执行当前目录下的所有case,不只是这个文件 # 传参,指定case # pytest.main(['test_demo1.py::TestAnswer::test_demo2', '-v']) # 指定Mark pytest.main(['test_demo1.py', '-v', '-m', 'char'])
- 主要是为了避免后期在shell脚本同时使用python和pytest命令
- 命令:
异常处理
- 第一种方式就是
try...except...
- 第二种方式,pytest封装了
raises
def test_raises(): # 期望是Value异常 with pytest.raises(ValueError) as exp: # 这里面就是我们测试功能的代码,比如用户输入非法值,看是不是我们期望的异常 raise ZeroDivisionError('Value must gt 18') # 假装抛出异常,这种情况,case就会fail # assert exp.type is ValueError # 这两句没必要写,逻辑冗余 # assert exp.value.args[0] == 'Value must gt 18'
数据驱动
- pytest结合数据驱动测试(DDT)
- 通过参数化直接实现DDT,demo
- 配置文件,./env.yaml
test: 127.0.0.1
- case
import pytest import yaml class TestDemo: @pytest.mark.parametrize('env', yaml.safe_load(open('./env.yml'))) def test_demo1(self, env): if 'test' in env: print("测试环境") print(env) # test, 只能打印出key elif 'dev' in env: print("开发环境")
- 如果想打印出全部信息,需要修改yml的写法
- test: 127.0.0.1 t2: 10086 - t3: 10010
- 从结果可以看出,yml文件的所有信息作为一个参数列表,一个
-
容纳一个字典,作为列表的一个元素,对应一个caseclass TestDemo: @pytest.mark.parametrize('env', yaml.safe_load(open('./env.yml'))) def test_demo1(self, env): if 'test' in env: print("测试环境") print(env) # {'test': '127.0.0.1', 't2': 10086} print(env['test']) # 127.0.0.1 elif 'dev' in env: print("开发环境") def test_yml(self): # [{'test': '127.0.0.1', 't2': 10086}, {'t3': 10010}] print("\n", yaml.safe_load(open('./env.yml')))
- 这里只是做个准备,一般不直接写参数
- 配置文件,./env.yaml
- 通过pytest(参数化) + Excel实现DDT
- 安装工具:
pip install openpyxl
,及基本用法import openpyxl # 打开工作簿 book = openpyxl.load_workbook('./test.xlsx') # 读取工作表 sheet = book.active # 读取单元格 c1 = sheet['A2'] # Cell 对象 c2 = sheet.cell(column=1, row=3) # 读取一片 c3 = sheet['A1':'C3'] # 获取单元格的值 print(c1) # roy print(c3[0][0].value) # Name
- 准备数据,传给case,驱动测试;当然,离不开参数化
- 注意目录结构
- 安装工具:
- 通过pytest + CSV/JSON实现DDT
- csv的特点是以逗号/制表符分隔字段,纯文本形式,可以直接用
with open
打开,Excel可直接改为CSV文件 - json的特点是由嵌套的键值对组成,值的形式多样,可以是字符串、数组,内置json包
- 文件操作可以搜一搜看一看,枯燥;其他和上面Excel一样,不赘述
- csv的特点是以逗号/制表符分隔字段,纯文本形式,可以直接用
- 以上这部分是 pytest 基操,后续还有很多补充
Allure
- 使用Allure定制测试报告
- 优点:
- 官网
- 文档
- 安装
- Java环境(建议1.8,但我是Java17)
- 安装Allure(建议2.13),下载
- Allure支持多语言是因为它基于XUnit开发的
- xUnit中的 x 代表不同语言,Java就是JUnit,python就是unittest
集成
- 测试和报告是不分家的,虽然Allure是个独立的报告框架,但还是要集成到不同语言的测试框架中使用
- 集成到pytest
- 安装:
pip install allure-pytest
- 安装:
- 常用方法
- 一般以装饰器形式使用
- 一般以装饰器形式使用
- demo-title
- 新建result目录,存放临时结果
import allure class TestSearch: @allure.title("搜索:测试") def test_demo1(self): print("demo1")
- 命令行:
pytest test_allure.py --alluredir ./result --clean-alluredir
,pytest --help - 查看报告:
allure serve ./result
- 新建result目录,存放临时结果
- demo-feature/story
- 测试框架不同,但一般我们称一个测试文件为
suite
(或者说module),suite里面可以有多个类,称为case
(或feature/TestCase),每个case里面又可以包含多个具体的用例,称为story
,story有时还可进一步分为多个keyword
- 也就是:suites > suite > case/feature/TestCase > story/keyword,有些框架 TestCase 下面就是keyword,本质是看谁在安排具体的测试过程,pytest中是story;不必纠结这个,理清层次就行
import allure @allure.feature("登录模块") class TestLogin: # 不加说明会有warning @allure.story("登录成功") def test_login_success(self): print("success") @allure.story("登录失败") def test_login_fail(self): print("fail")
- 可以在下方看到指定的 features;SUITES包含所有suite
- feature也可以作为Marker:
pytest .\test_feature.py --allure-features="登录模块" --alluredir=./result --clean- alluredir
只运行这个feature的story - 同样的,
--allure-stories
指定跑哪些story
- 测试框架不同,但一般我们称一个测试文件为
- demo-step
- 给story下面的步骤划分step,一般以页面切换为分割点
@allure.feature("登录模块") class TestLogin: # 不加说明会有warning @allure.story("登录成功") @allure.title("fail") def test_login_success(self): with allure.step("1. 打开登录界面"): print("login page") print("输入用户名密码...") with allure.step("2. 跳转到首页"): print("首页...")
- 会分开展现
- 给story下面的步骤划分step,一般以页面切换为分割点
- demo-link
- 使用链接的方法有多种,包括
link/issue/testcase
pytest test_link.py --alluredir ./result --allure-link-pattern=issue:http://www.bug-platform.com/{} --clean-alluredir
TEST_CASE_LINK = 'https://github.com/qameta/allure-integrations/issues/8#issuecomment-268313637' # 链接 + 名称 @allure.link('https://www.youtube.com/watch?v=Su5p2TqZxKU', name='Click me') def test_with_named_link(): pass # 140这个位置一般是bug号,可以接入自己公司的bug平台,命令行要配置: # pytest directory_with_tests/ --alluredir=/tmp/my_allure_report --allure-link-pattern=issue:http://www.myself-bug-platform.com/issue/{} @allure.issue('140', 'Pytest-flaky test retries shows like test steps') def test_with_issue_link(): pass # 超链接到上面的link, 看起来和link好像没什么区别 @allure.testcase(TEST_CASE_LINK, 'Test case title') def test_with_testcase_link(): pass
- 使用链接的方法有多种,包括
- demo-级别
- 有五种级别可以设置
- 设置了severity,也相当于设置了一个Marker,跑指定级别的case:
pytest .\test_severity.py --allure-severities=blocker,trivial --alluredir=./result
import allure def test_with_no_severity_label(): pass # Blocker @allure.severity(allure.severity_level.BLOCKER) def test_with_blocker_severity_label(): assert 1==2 @allure.severity(allure.severity_level.TRIVIAL) def test_with_trivial_severity(): assert 2==4 @allure.severity(allure.severity_level.NORMAL) def test_with_normal_severity(): pass @allure.severity(allure.severity_level.NORMAL) class TestClassWithNormalSeverity(object): def test_inside_the_normal_severity_test_class(self): pass @allure.severity(allure.severity_level.CRITICAL) def test_inside_the_normal_severity_test_class_with_overriding_critical_severity(self): pass
- 有五种级别可以设置
- demo-添加附件
- 附件的类型有很多种
class TestLogin: def test_login_success(self): with allure.step("1. 打开登录界面"): print("login page") allure.attach.file("./sisi.jpg", name="wechat", attachment_type=allure.attachment_type.JPG) with allure.step("2. 跳转到首页"): print("首页...")
- 比如添加个图片
- 附件的类型有很多种
生成报告
- 上面使用
allure serve
命令得到在线报告,其实测试报告的生成有完整流程
- 最终版本的测试报告,意思就是不依赖IDE的allure进程,可以移植到自己搭建的服务器或者Jenkins
- 生成报告:
allure generate ./result
- 打开:
allure open -h 127.0.0.1 -p 8883 ./allure-report
,或者在IDE直接打开 index.html,但不能在文件夹直接打开(需要服务器解析,不是静态文件)
- 生成报告:
- 具体用法在学到Jenkins就知道了
Fixture
- Fixture :固定装置
- pytest提供的装饰器,可以更加灵活的安排用例的执行、需要的前置/后置操作等
- 官方文档,参数及许多内置的 fixture 都可以找到
基操
- 比如有些用例的执行不需要登录,有些需要;使用 setup 就不行,逐个在用例里 login 太繁琐
import pytest @pytest.fixture def login(): print("\n登录成功") # 需要登录,传入被fixture的函数即可 def test_card(login): print("加入购物车成功")
- 相当于随时随地 setup,文明又卫生
作用域
- 类似setup、setup_module 等,在这个作用域里都要执行某个 Fixture
- 主要分为这几个 scope,可以到源码里看注释
- 函数级别,注意:还是要在具体函数里面调用 login
@pytest.fixture(scope="function") def login(): print("\n登录成功") def test_card(login): print("加入购物车成功") def test_search(login): print("搜索商品")
- 模块级别,在所有用例之前执行一次,类似 setup_module
@pytest.fixture(scope="module") def login(): print("\n登录成功") def test_card(login): print("加入购物车成功") def test_search(login): print("搜索商品")
- class级别,注意:这里的每个函数也被当做类
@pytest.fixture(scope="class") def login(): print("\n登录成功") def test_card(login): print("加入购物车成功") def test_search(login): print("搜索商品") class TestClass: def test_demo1(self, login): print("class 1") def test_demo2(self, login): print("class 2")
- 看起来就是 setup 那些情况呀?好在哪?其实就灵活在每个函数都要写 login,控制了哪些能执行,有点像 Mark 了;麻烦了点但灵活了,祸兮福之所倚
yield
- 这个是python的关键字,主要是生成器用,实现懒加载节省内存;特点是控制了代码的执行流程,
yield
这里直接返回,但还能回来接着执行后续代码@pytest.fixture(scope="class") def login(): print("\n登录成功") yield print("\n登出") def test_card(login): print("加入购物车成功") # 登录成功 加入购物车成功 登出
- 这就类似 teardown 操作
数据共享
- 不需要 import,就可以用一些公共的模块,也可以限制共享的区域
- 新建 conftest.py,名字不能变,放在哪个位置,哪个目录下面的用例就可以共享这个 fixture
- demo,顺便测一下 session 级别(一般也就是这么用,session和conftest一起)
import pytest @pytest.fixture(scope="session") def login(): print("\n登录成功") yield print("\n登出")
- conftest.py 所在的目录视为一个session 域,不管下面有多少用例引用,只执行一次
自动应用
- 不在用例中传 fixture,也想自动引用
import pytest # 设置 autouse @pytest.fixture(scope="function", autouse=True) def login(): print("\n登录成功") yield print("\n登出")
# 不需要写 login,也能使用 def test_conf_1(): print("测试 conftest")
参数化
- 和 pytest 参数化类似
- demo,同样的,有几个参数就会复制几个 case
import pytest @pytest.fixture(scope="session", params=["roy", "allen"]) def parameter(request): print(f"this is {request.param}") yield request.param # 返回参数 print("baibai %s"%request.param)
def test_conf_1(parameter): print("\nfixture parameters test") print("参数为:", parameter) def test_conf_2(parameter): print("\n参数化") print("参数为:", parameter)
- 虽然设置了 session 级别,但这里也相当于在 session 级别复制出两份 case,可以理解为两个会话了,在各自的会话里 consume 参数;级别和参数化并不冲突,一个参数一个域,分开看即可
- 这部分更多是属于逻辑层次问题,还是要多试验,才能更得心应手,死记硬背容易搞复杂
ini
- pytest.ini 文件,是 pytest 的配置文件,可以修改 pytest 的默认行为,不能包含任何中文(Windows)
- 主要配置以下几项
运行规则
- 项目根目录下新建 pytest.ini 文件
- 执行
check_
开头和test_
开头的测试文件(suite/module),要加*
;这是个注释,以分号开头,但是Windows下不能有中文 python_files = check_* test_*
- 执行
Check
和Test
开头的类(case)python_classes = Test* Check*
- 执行
check_
开头和test_
开头的方法(story)python_functions = check_* test_*
配置命令行参数
- 命令行参数,一般用等号赋值
;就不用手动添加了 addopts = -vs --alluredir=./result
指定/忽略执行目录
- 设置执行路径
testpaths = demo demo3
- 忽略某些路径
norecursedirs = result md2
配置日志
- 日志开关,设置日志级别,打印,保存位置
- 文件记得提前创建,或者加判断
插件开发
- 插件分类
- 外部插件:pip install 安装的
- 本地插件:自己编写,通过 pytest 自动模块发现机制使用(conftest.py)
- 内置插件:代码内部的 _pytest 目录加载(hook函数)
- 没啥神奇的,就是一些封装好的方便测试的代码
常用插件
- 可以到这里找
- 举例:pytest-ordering
- 安装:
pip install pytest-ordering
- 用法:@pytest.mark.run(order=2),也可以看源码,用 first/second 等代替
- 多个装饰器同时使用可能会有冲突
- 对于顺序,有时不应该刻意定义
- 安装:
- 常见的插件
分布式并发
- 分布式:多台机器同时执行缩短耗时,也可称为并行;机器数 * 内核数 = 并行进程个数
- 并发:多个进程同时操作同一批数据,要避免弄脏数据,或者说实现高并发
- 解决这两个问题:pytest-xdist,可以去官网搜搜看
- 安装:
pip install pytest-xdist
- 使用:
pytest -n auto
或pytest -n NUMCPUS
即内核数
自定义插件
- 这里要用到下面的 hook 函数部分
- 插件1:修改默认编码
- 在 conftest.py 中使用 hook 函数
def pytest_collection_modifyitems( session: "Session", config: "Config", items: List["Item"] ) -> None: print(items) # 单步调试可以发现:我们需要改每个 item(用例) 的 name 和 nodeID 两个编码 for item in items: item.name = item.name.encode('utf-8').decode('unicode-escape') item._nodeid = item.nodeid.encode('utf-8').decode('unicode-escape')
- 运行测试用例,会自动调用钩子函数修改编码
- 在 conftest.py 中使用 hook 函数
- 插件2:添加命令行参数
- 在 conftest.py 定义
# hook 函数,添加命令行参数 def pytest_addoption(parser): mygroup = parser.getgroup("Roy") # 参数组 mygroup.addoption("--env", default='allen', dest='env', help='set your env') # 用 fixture 过滤参数 @pytest.fixture(scope='session') def cmdoption(request): myenv = request.config.getoption("--env", default='allen') if myenv == 'roy': datapath = "datas/roy/data.yml" elif myenv == 'allen': datapath = "datas/allen/data.yml" else: datapath = "datas/data.yml" with open(datapath) as f: data = yaml.safe_load(f) return myenv, data
- 数据格式
env: ip: 127.0.0.1 port: 8999
- 传入 fixture,过滤参数
- 命令行调用
pytest --env 'roy' .\test_conf.py -vs
,能看到对应输出 - 使用
pytest --help
也能看到参数介绍
- 在 conftest.py 定义
打包发布
- 想让我们自定义的插件给别人用,有几种方式
- 源代码提交到 git
- 打包项目
- 具体看打包项目的方式,就是借用 python 的打包工具
- 需要准备:源码包,pyproject.toml,测试包
- 需要安装:
pip install setuptool
pip install wheel
,一个是打包的,一个是压缩的 - 打包命令:
python -m build
- 发布到 PyPI:需要用到
twine
工具;都是参考上面那个教程,英文的慢慢看
hook
- hook:钩子,即在需要的时候挂一个东西上去;在pytest中
- 是个函数,被系统调用(系统消息触发),在不同阶段实现不同功能
- 自动触发机制
- hook函数的名称固定
- pytest 有非常多的 hook 函数,在跑一个 case 的时候,会经历下面的过程:
- 总结如下
- 官方文档
- which can be implemented by conftest.py files and plugins
- 参考文档
- 上面的 hook 函数大部分只有一个“影子”(只定义了方法名),我们可以做具体实现(implement);有些必要的会自动实现(挂钩)
- 这怎么感觉有点像 Java 的 interface
- 名称定义在
site-packages/_pytest/hookspec.py
文件中,pip 安装的包都放在 site-packages
- 那就在 conftest.py 中实现两个试试
- 这两个有点像 suite 中定义的 setup/teardown,底层应该是一样的
- hook 是一种编程机制,和具体的语言没有直接的关系
- hook 又和回调函数相类似,参考文章
- 这种设计模式实现起来并不复杂,关键在于定义注册函数,并合理消费被注册的hook函数
小结
- 以上就是 pytest 框架及相关的知识点
- 框架的定位和使用技巧还是要在实践中体会,何时用,怎么用合适才是功力