1. 断言详解 测试的主要工作目标就是验证实际结果与预期结果是否一致,在接口自动化测试中,通过断言来实现这一目标。
Pytest 的断言方式及应用场景:
使用 assert 语句
断言预期的异常
利用上下文信息进行断言
assert 断言示例:https://docs.pytest.org/en/latest/example/reportingdemo.html
异常断言示例:https://docs.pytest.org/en/latest/assert.html#assertions-about-expected-exceptions
1.1 assert 断言示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import pytestimport requestsclass TestAssert (): def test_string (self ): assert "123" == 123 def test_assert (self ): r = requests.get("http://www.baidu.com" ) assert r.status_code == 200 , "返回200说明访问成功" class TestCollections (object ): def test_dict (self ): assert {"a" : 0 , "b" : 1 , "c" : 0 } == {"a" : 0 , "b" : 1 , "c" : 0 } def test_dict2 (self ): assert {"a" : 0 , "b" : {"c" : 0 }} == {"a" : 0 , "b" : {"c" : 0 }} def test_list (self ): assert [0 , 1 , 2 ] == [0 , 1 , 2 ] def test_tuple (self ): assert (0 , 1 , 2 ) == (0 , 1 , 2 ) def test_set (self ): assert {0 , 10 , 11 , 12 } == {0 , 10 , 11 , 12 } def test_function (): def f (): return [1 , 2 , 3 ] assert f() == [1 , 2 , 3 ] if __name__ == '__main__' : pytest.main()
除了相等断言,常用的类型断言还有以下几种:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import pytestdef test_assertIn (): assert 'mike' in 'mikezhou' def test_assertgreather (): assert 3 > 2 def test_assertbool (): flag = True assert flag if __name__ == '__main__' : pytest.main()
1.2 异常断言示例 1 2 3 4 5 6 7 8 9 10 11 12 13 import pytestdef test_case01_zero_division (): with pytest.raises(ZeroDivisionError): 1 / 0 def test_case02_zero_division (): with pytest.raises(ZeroDivisionError) as excinfo: 1 / 0 assert 'division by zero' in str (excinfo.value) if __name__ == '__main__' : pytest.main()
2. YAML 文件详解 2.1 Yaml 简介 yaml 是一种数据格式,支持注释、换行、多行字符串、裸字符串(整形 / 字符串)。
2.1.1 语法规则
区分大小写,注释使用 #
使用缩进表示层级,不能使用 tab 键缩进,只能用空格
缩进没有数量的,只要是前面对齐就行
2.1.2 数据组成
1 2 3 4 5 6 test: name: 张三 age: 18 test: {name: 张三 , age: 18 }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 - test1: - name: 张三 - age: 18 - test2: - name: 李四 - age: 20 - test1: [{name: 张三 }, {age: 18 }] - test2: [{name: 李四 }, {age: 20 }]
2.2 接口实战 我们来测试飞书开放平台里的一个接口,先直接在测试用例文件中测试一下观察是否通过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import requestsclass Test_feishu_api : def test_C001001 (self ): url = 'https://open.feishu.cn/open-apis/drive/v1/files/ERtrbjwHMaJGTisFICfcGTZxnpR/view_records?page_size=5&file_type=bitable' params = { "page_size" :"5" , "file_tye" :"bitable" , } headers = { "Authorization" :"Bearer u-xxxxx" } res = requests.get(url,params=params, headers=headers) print (res)
接下来我们通过数据驱动的方式来测试一下这个接口,在 yaml 文件中需要填写需要测试的接口的信息包括断言。
1 2 3 4 5 6 7 8 9 10 11 12 - name: 获取文件夹中的文件清单 requests: url: https://open.feishu.cn/open-apis/drive/v1/files/ERtrbjwHMaJGTisFICfcGTZxnpR/view_records?page_size=5&file_type=bitable method: GET headers: Authorization: Bearer u-xxxxx params: page_size: 5 file_type: bitable validate: - eq: {code: 0 }
写好 yaml 文件后就需要写代码去调用这个文件获取到其中的信息,因此可以在当前目录下创建一个 yaml_util.py 的文件,然后写一个工具类来读取 yaml 中的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import yamlclass YamlUtil : def __init__ (self, yaml_file ): self .yaml_file = yaml_file def read_yaml (self ): with open (self .yaml_file, encoding='utf-8' ) as f: value = yaml.load(f, Loader=yaml.FullLoader) print (value) if __name__ == '__main__' : YamlUtil('/cloudide/workspace/cases/登录/test_api.yaml' ).read_yaml()
运行这段代码,可以发现成功读取到了 yaml 文件中的信息。
这段代码测试无误后,我们就可以在用例测试文件中调用这个 read_yaml 方法来获取 yaml 文件中的信息了。当然需要注意的是,我们上面是测试是否调取信息成功,因此需要把代码中 print 函数改成 return 来返回调取到的信息。
1 2 3 4 5 6 7 8 9 10 11 12 import yamlclass YamlUtil : def __init__ (self, yaml_file ): self .yaml_file = yaml_file def read_yaml (self ): with open (self .yaml_file, encoding='utf-8' ) as f: value = yaml.load(f, Loader=yaml.FullLoader) return value
然后回到用例测试文件中,将之前所有测试数据的信息都放到 yaml 文件中,然后通过 YamlUtil 工具类中的 read_yaml 方法读取到 yaml 文件中的信息,并通过传入参数的方式传入到测试方法中使用。
下面 yaml_util.YamlUtil('/cloudide/workspace/cases/登录/test_api.yaml').read_yaml() 这段代码会返回一个字典类型的数据,通过数据驱动的方式传入参数就可以测试多组数据。
1 2 3 4 5 6 7 8 9 10 11 12 import pytestimport yaml_utilimport requestsclass Test_feishu_api : @pytest.mark.parametrize('args' , yaml_util.YamlUtil('/cloudide/workspace/cases/登录/test_api.yaml' ).read_yaml( ) ) def test_C001001 (self, args ): url = args['requests' ]['url' ] params = args['requests' ]['params' ] headers = args['requests' ]['headers' ] res = requests.get(url,params=params, headers=headers) print (res)
我们可以执行一下观察结果如何,可以发现是能成功调用接口的。
最后我们补全最后一步断言操作,同样是从 yaml 文件中读取中断言值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import pytestimport yaml_utilimport requestsclass Test_feishu_api : @pytest.mark.parametrize('args' , yaml_util.YamlUtil('/cloudide/workspace/cases/登录/test_api.yaml' ).read_yaml( ) ) def test_C001001 (self, args ): url = args['requests' ]['url' ] params = args['requests' ]['params' ] headers = args['requests' ]['headers' ] validate = args['validate' ] res = requests.get(url,params=params, headers=headers) for valid in validate: assert res.json()['code' ] == valid['eq' ]['code' ]
并在 yaml 文件中增加两个用例,分别是一个正例、一个反例和一个错误的用例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 - name: 获取文件夹中的文件清单 requests: url: https://open.feishu.cn/open-apis/drive/v1/files/ERtrbjwHMaJGTisFICfcGTZxnpR/view_records?page_size=5&file_type=bitable method: GET headers: Authorization: Bearer u-xxxxx params: page_size: 5 file_type: bitable validate: - eq: {code: 0 } - name: 获取文件夹中的文件清单 requests: url: https://open.feishu.cn/open-apis/drive/v1/files/ERtrbjwHMaJGTisFICfcGTZxnpR/view_records?page_size=5&file_type=bitable method: GET headers: Authorization: Bearer xxxx params: page_size: 5 file_type: bitable validate: - eq: {code: 99991668 } - name: 获取文件夹中的文件清单 requests: url: https://open.feishu.cn/open-apis/drive/v1/files/ERtrbjwHMaJGTisFICfcGTZxnpR/view_records?page_size=5&file_type=bitable method: GET headers: Authorization: Bearer xxxx params: file_type: bitable validate: - eq: {code: 0 }
然后执行用例并观察结果,可以发现前两个用例都测试通过符合预期,而最后一个因为用例填写错误所以没有通过。
3. 数据驱动 3.1 背景/介绍 问题背景
在没有使用数据驱动之前,我们可能会遇到因为一些数据带来的问题,我们来看两个例子。
当测试数据多起来的时候,代码中重复内容就会比较多,例如下面 3 个测试数据需要分别通过代码调用 3 次相同的测试函数,代码就会显得相当臃肿。
1 2 3 4 5 6 def test_web (user, password, status ): pass login_order("张三" , "123456" , 1 ) login_order("李四" , "123456" , 1 ) login_order("王五" , "654321" , 0 )
再比如,现在同样存在多个测试数据,我的测试函数中会进行断言。下面这段代码没有数据驱动,如果运行到中间用例数据时断言报错了,就会出现后面的用例数据执行不到的情况,导致测试不完全。
1 2 3 4 5 test_data = [1 , 0 , 1 , 0 ] def test_data_list (data_list ): for data in data_list: assert data > 0 test_data_list(test_data)
定义
数据驱动 :在自动化测试中处理测试数据的方式。
参数化 :是把测试过程中的数据提取出来,通过参数传递不同的的数据来驱动用例运行,其实也就是数据驱动的概念。
在自动化测试运行时,数据驱动框架会读取数据源中的数据,把数据作为参数传递到功能函数中,并会根据数据的条数多次运行同一个功能函数。
数据驱动的好处
数据驱动能够减少重复代码。
实现测试数据和测试逻辑的解耦,数据所属的测试用例执行失败,不会影响到其他测试数据对应的测试用例。
3.2 使用方法
在 Python 中,应用比较广泛的两个数据驱动的框架:
一个是 DDT (Data-Driven Tests) ,它是我们前面 unittest 框架中提到实现数据驱动的不二之选。
另外一个是 parameterized ,它是 pytest 能够实现数据驱动的秘诀。
这里我们着重介绍一下 parametrize 是如何使用的。
3.2.1 Parametrize 在 pytest 中,数据驱动是经由 pytest 自带的 pytest.mark.parametrize() 来实现的。
pytest.mark.parametrize 是 pytest 的内置装饰器,它允许你在 function 或者 class 上定义多组参数和 fixture 来实现数据驱动。
@pytest.mark.parametrize(args_name, args_value, ids=new_name) 装饰器必填两个参数:
args_name(必填):参数名。以字符串的形式存在,它代表能被被测试函数所能接受的参数,如果被测试函数有多个参数,则以逗号分隔。
args_value(必填):参数值(列表,元组,字典列表,字典元组)。用于保存测试数据,有多少个值用例就会执行多少次。如果只有一组数据,可以列表的形式存在;如果有多组数据,可以列表嵌套元组的形式存在。
ids(可选):自定义每条用例的名称,如果不指定名称则系统会自动生成每条用例的名称,默认是将所有测试数据拼接起来组成一个名称。
作用于测试方法
用例一:
1 2 3 4 5 6 7 8 9 10 11 import pytestclass Test_错误密码1 : @pytest.mark.parametrize("args" , ["张三" , "李四" , "王五" ] ) def test_C001001 (self, args ): print ('\n用例C001001:' + str (args)) assert 1 == 1 def test_C001002 (self ): print ('\n用例C001002' ) assert 2 == 2
用例二:
1 2 3 4 5 6 7 8 9 10 11 import pytestclass Test_错误密码1 : @pytest.mark.parametrize("name, age" , [["张三" , "15" ], ["李四" , 18 ], ["王五" , 24 ]] ) def test_C001001 (self, name, age ): print ('\n用例C001001:' + str (name) + " " + str (age)) assert 1 == 1 def test_C001002 (self ): print ('\n用例C001002' ) assert 2 == 2
作用于测试类
用例三:
1 2 3 4 5 6 7 8 9 10 11 import pytest@pytest.mark.parametrize("name, age" , [["张三" , "15" ], ["李四" , 18 ], ["王五" , 24 ]] ) class Test_错误密码1 : def test_C001001 (self, name, age ): print ('\n用例C001001:' + str (name) + " " + str (age)) assert 1 == 1 def test_C001002 (self, name, age ): print ('\n用例C001002:' + str (name) + " " + str (age)) assert 2 == 2
调用多次 patametrize
同一个测试用例还可以同时使用多个 @pytest.mak.paramtirize(),多个 paramtrize 的所有元素互相组合(类似笛卡儿乘积)生成大量测试用例。
同样还是来看一个案例,帮助我们快速理解。
1 2 3 4 5 6 7 8 9 import pytestclass Test_错误密码1 : @pytest.mark.parametrize("name" , ["张三" , "李四" , "王五" ] ) @pytest.mark.parametrize("age" , [18 , 20 , 22 ] ) @pytest.mark.parametrize("sex" , ["男" , "女" , "男" ] ) def test_C001001 (self, name, age, sex ): print ('\n用例C001001:' + str (name) + " " + str (age) + " " + str (sex)) assert 1 == 1
3.2.2 Fixture 扩展 概括地说,无论是什么形式的测试都只包括三个步骤:测试就前的准备 - 执行测试 - 测试后的清理。
fixtures 可用作初始化测试服务、数据和状态,也常常用来在测试执行前/后进行测试的前置/后置操作。
fixtures 可作为共享数据使用,也可被其他函数、模块、类或者整个项目,甚至另外的 fixtures 调用。
回顾一下 fixture 的语法:
1 fixture(scope="function" , params=None , autouse=False , ids=None , name=None )
接着来看看 fixture 是如何通过 params 参数实现数据驱动的。
params 参数:一个可选的参数列表,它能导致多次调用 fixture 函数和使用它的所有测试数据
获取当前参数可以使用 request.param
1 2 3 4 5 6 7 8 9 10 import pytestdata = ["张三" , "李四" ] @pytest.fixture(params=data ) def user (request ): return request.param def test_login (user ): print ("登录用户:{}" .format (user))
我们再来看个例子巩固一下这个知识点,下面在返回 request.param 的基础上,增加了前置/后置的操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import pytest@pytest.fixture(params=['chrome' , 'firefox' , 'ie' ] ) def driver (request ): print ('----- 创建浏览器驱动对象 -----' ) yield request.param print ('----- 销毁浏览器驱动对象 -----' ) class Test_wrb (): def test1 (self, driver ): print ('%s:打开京东' % driver) def test2 (self, driver ): print ('%s:打开淘宝' % driver)
另外,我们前面 parametrize 中用到了笛卡尔积的方法,在 fixture 中同样可以实现相同的效果。
但是我们需要定义多个 fixture 函数,相比 parametrize 来说更加麻烦一些,不过 fixture 毕竟不是专业实现参数化的,对于笛卡尔积还是更推荐使用专业参数化的 parametrize 装饰器来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import pytestuser_name = ['张三' , '李四' , '王五' ] user_pwd = ['123' , '456' ] @pytest.fixture(params=user_name ) def login_name (request ): name = request.param return name @pytest.fixture(params=user_pwd ) def login_pwd (request ): pwd = request.param return pwd def test_login (login_name, login_pwd ): print (login_name, login_pwd)
3.2.3 两者结合 在实际使用参数化的过程中,我们可以将 parametrize 和 fixture 相结合使用,能够兼顾到彼此的特点。
还是拿生成笛卡尔积举例,除了各自叠加使用去实现以外,还可以结合在一起达成同样的效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 import pytestuser_name = ['张三' , '李四' , '王五' ] user_pwd = ['123' , '456' ] @pytest.fixture(params=user_name ) def login_name (request ): name = request.param return name @pytest.mark.parametrize('login_pwd' , user_pwd ) def test_login (login_name, login_pwd ): print (login_name, login_pwd)
indirect
parametrize 中还存在一个可选参数 indirect,默认情况下会设置为 False,args_name 接收的参数就会被正常的当做变量去使用;但如果我们手动设置为 True,那么 args_name 接收的参数则会被当成函数去使用,这就可以和 fixture 结合起来使用了。
这里来看个例子,下面的 parametrize 中将 indirect 设置成了 True,那么 args_name 接收的参数 login 则会被当做一个函数。而此时 fixture 下又定义了一个名称相同的 login 函数,那么 parametrize 中的 login 函数则会与 fixture 下定义的 login 函数相匹配。
前面在使用 fixture 函数实现参数化时,都需要指定 params 参数,但此时已经和 parametrize 中的函数相匹配,parametrize 中定义的 test_user_data 则会自动的赋值到 fixture 中的 params 参数中,供 login 使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import pytesttest_user_data = ["张三" , "李四" ] @pytest.fixture(scope="module" ) def login (request ): user = request.param print ("登录账户:%s" % user) return user @pytest.mark.parametrize('login' , test_user_data, indirect=True ) def test_login (login ): a = login print ("测试用例中login的返回值:%s" % a) assert a != ""
3.3 数据源选择 数据驱动的数据源可以是函数外的数据集合、CSV 文件、Excel 表格、TXT 文件,以及数据库等。
数据源
优点
缺点
Excel
生成数据方便
二进制文件不利于版本管理
CSV
可使用 Excel 编辑
文本格式方便版本管理
YAML
格式完备、可读性好
格式简单
XML
格式完备
冗长复杂
JSON
格式完备,可读性一般
不能编写注释,格式死板
当测试数据量大且结构类似时,推荐采用 Excel 或 CSV 作为外部数据源。
使用 Pytest 支持外部文件实现数据驱动的思路:
JSON/YAML 文件实现数据驱动
创建一个读取 JSON 文件和 YAML 文件的方法
实现自动读取 JSON 文件和 YAML 文件,并把 JSON 文件和 YAML 文件中的数据提取出来,并按照 pytest.mark.parametrize 可接受的方式将数据返回。
Excel 文件实现数据驱动
在 python 中,读写 Excel 的 library 有很多,常见的有 xlrd、xlwt、openpyxl(推荐)、Pandas 等。
核心思路 :将读取的 Excel 文件中的数据读取出来, 作为 @pytest.mark.parametrize() 的参数即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import pytestfrom openpyxl import load_workbookclass ParseExcel (object ): def __init__ (self, excelPath, sheetName ): print (excelPath, sheetName) self .wb = load_workbook(excelPath) self .sheet = self .wb[sheetName] self .maxRowNum = self .sheet.max_row def getDatasFromSheet (self ): dataList = [] for line in self .sheet.rows: tmpList = [] tmpList.append(line[0 ].value) tmpList.append(line[1 ].value) dataList.append(tmpList) return dataList[1 :] excelPath = r'测试数据.xlsx' sheetName = '参数表' excel = ParseExcel(excelPath, sheetName) datas = excel.getDatasFromSheet() @pytest.mark.parametrize('username, password' , datas ) def test_print (username, password ): print (username, password)
不管选择哪个数据源,总体的思路都是不变的,都是先通过代码写一个工具来获取到数据源中的数据,然后将这个数据传到 parametrize 中进行测试即可。
4. Mock 接入 4.1 使用背景 在前面的 unittest 框架章节中,已经提到过 mock 是什么,并且讲解了 unittest 中 mock 是怎么用的,接下来看看在 pytest 中是如何使用 mock 的。
在此之前,先来简单回顾下 mock 的概念。mock 测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,自行创建一个虚拟的对象以便测试的测试方法。这个虚拟的对象就是 mock 对象,mock 对象就是真实对象在调试期间的代替品。
常见 Mock 适用场景:
前后端联调:当前端需要的接口后端还在开发,就可以使用 mock 模拟后端返回我们需要的数据。
单元测试:当我们所要测试的接口还未开发完,而我们还要完成测试用例代码的编写,我们可以先模拟这个接口进行测试。
屏蔽无关的真实服务:专注于要测试服务本身,而其它服务又不在本次测试范围内,则可以使用 mock 只模拟出我们关注的服务。
依赖第三方接口:当我们的接口需要调用第三方,而第三方接口我们还未得到权限或未购买时,可使用 mock 模拟进行测试。
注意 mock 只适合用于临时的测试,而不能代替真实场景的测试。
4.2 搭建方案 Mock Server 的搭建有两种方式,分别是借助第三方工具直接提供 Mock Server,以及自主编码实现 Mock Server。
常见的第三方 Mock Server 工具:Postman、easy-mock、Yapi、Mockjs、mockserver、Moco、lyrebird
4.3 使用方法
仓库地址:https://github.com/pytest-dev/pytest-mock/
Python 目前有两个库可以使用,mock 从 3.3+ 就已经集成到了 unittest 框架,而 pytest-mock 集成了 mock 的所有功能,变得更加灵活和强大。
pip install mock
pip install pytest-mock
pytest-mock 是一个 pytest 的插件,安装即可使用,使用的时候无需导入,只需要导入 pytest 即可。它提供了一个名为 mocker 的 fixture,仅在当前测试 function 或 method 生效,而不用自行包装。
案例 1
我们直接来看一个基础的案例,现在 mock_server.py 文件中存在两个方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import requestsdef send_request (url ): return requests.get(url).status_code def invoke_method (url ): flag = send_rquest(url) if flag == 200 : print ("success" ) else : print ("fail" ) return flag
假设现在 send_request 方法还未开发完,但我需要通过这个方法的返回值来验证我的 invoke_method 方法逻辑是否正确。那么就可以通过 mocker.patch 的方式,给 send_request 方法自定义一个返回值。
1 2 3 4 5 6 import pytestfrom mock_method import *def test_baidu (mocker ): mocker.patch('mock_method.send_request' , return_value=200 ) assert invoke_method("https://www.baidu.com" ) == 200
案例二
接下来再来看一个稍微真实一点的例子巩固一下 mocker 的用法,现在将上面两个方法替换成下面这两个具有认证功能的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import requestsclass Api_Person_Auth (object ): def api_person_auth (self, real_name, id_card, mobile ): url = "http://v.juhe.cn/telecom/query" body = { "real_name" : real_name, "id_card" : id_card, "mobile" : mobile } res = requests.post(url, json=body) print (res.text) return res def api_my_auth (self, real_name, id_card, mobile ): response = self .api_person_auth(real_name, id_card, mobile) try : if response['resultcode' ] == '200' : return '认证成功' elif response['resultcode' ] == '403' : return '认证失败' elif response['resultcode' ] == '404' : return '有必填参数为空' else : return '未定义错误' except Exception: return '错误的请求KEY'
从上面的 api_my_auth 中的代码逻辑可以看出,如果想覆盖所有路径的判断,则变得十分困难,很难通过真实场景的测试来覆盖全。
因此,我们可以通过参数化 + mocker 的方式模拟出 api_my_auth 所有路径的条件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import pytestfrom api_person_auth import Api_Person_Authtestdata = [] testdata.append(['测试' , '123456789012345678' , '12345678901' , {'resultcode' : '200' , 'resultmsg' : '认证成功' }]) testdata.append(['' , '' , '' , {'resultcode' : '102' , 'resultmsg' : '未定义错误' }]) testdata.append(['' , '123456789012345678' , '12345678901' , {'resultcode' : '404' , 'resultmsg' : '有必填参数为空' }]) testdata.append(['测试' , '123456789012345678' , '12345678901' , {'resultcode' : '403' , 'resultmsg' : '认证失败' }]) class Test_person_Auth (object ): @pytest.mark.parametrize('real_name,id_card,mobile,expect' , testdata, ids=['正确传入参数' , '全部参数为空' , '姓名为空' , '姓名错误' ] ) def test_person_auth (self, mocker, real_name, id_card, mobile, expect ): auth = Api_Person_Auth() auth.api_person_auth = mocker.patch('api_person_auth.Api_Person_Auth.api_person_auth' , return_value=expect['resultmsg' ]) msg = auth.api_person_auth(real_name, id_card, mobile) assert msg == expect['resultmsg' ]
5. Logging 日志 在写自动化测试用例时,添加有用的 log 日志信息是非常有必要的。比如在初期的调试过程中,一旦运行有问题,就可以通过日志获取到精确的调试信息。而等到后期稳定运行了,其他测试代人员也可以通过日志信息很容易上手这些用例,所以大家一定要重视测试用例的调试信息。
5.1 介绍 作为初学者的时候,我们常用 print 语句打印日志来调试代码。
但是 Python 中提供了一个日志模块 logging,可以通过它存储各种格式的日志,其主要用于输出运行日志。另外,也可以设置输出日志的等级、日志保存路径、日志文件回滚等。
使用 logging 的优势:
你可以控制消息的级别,过滤掉那些并不重要的消息。
你可决定输出到什么地方,既可以打印到标准输出流中,也可以保存到远程自己的服务器里
你也可以决定怎么输出,更加的定制化,有许多的重要性别级可共选择:debug、info、warning、error 以及 critical。通过赋予 logger 或者 handler 不同的级别,你就可以只输出错误消息到特定的记录文件中,或者在调试时只记录调试信息。
级别
对应的值
CRITICAL
50
ERROR
40
WARNING
30
INFO
20
DEBUG
10
NOTSET
0
5.2 日志格式
格式
作用
%(levelno)s
打印日志级别的数值
%(levelname)s
打印日志级别的名称
%(pathname)s
打印当前执行程序的路径,其实就是 sys.argv[0]
%(filename)s
打印当前执行程序名,python 如:login.py
%(funcName)s
打印日志的当前函数
%(lineno)d
打印日志的当前行号,在第几行打印的日志
%(asctime)s
打印日志的时间
%(thread)d
打印线程 ID
%(threadName)s
打印线程名称
%(process)d
打印进程 ID
%(message)s
打印日志信息
5.3 使用方法 案例一
先来看下面这四条简单的日志打印语句。
1 2 3 4 5 6 7 import logginglogger = logging.getLogger(__name__) logger.info("This is a log info" ) logger.debug("Debugging" ) logger.warning("Warning exists" ) logger.info("Finish" )
执行后可以发现只打印了其中的一条语句,这是因为受到了日志级别的影响。
日志级别越高,打印的日志可能越少;日志级别越低,打印的日志可能越多。
日志的默认级别为 Warning 级别,Warning 级别以下的日志都不会打印出来。
案例二
我们再来看一个案例,这次我们手动指定一个日志级别,并且指定输出的格式。
1 2 3 4 5 6 7 8 9 import logginglogging.basicConfig(level=logging.DEBUG, format ='时间:%(asctime)s - 名称:%(name)s - 级别:%(levelname)s - 信息:%(message)s' ) logger = logging.getLogger(__name__) logger.info("This is a log info" ) logger.debug("Debugging" ) logger.warning("Warning exists" ) logger.info("Finish" )
于是可以发现四条语句全部打印出来了,并且还是按照指定的格式输出日志。
案例三
除了打印到控制台之外,还能将日志写到文件中。
1 2 3 4 5 6 7 8 9 10 11 12 import logginglogging.basicConfig(level=logging.DEBUG, filename='output.log' , datefmt='%Y/%m/%d %H:%M:%S' , format ='时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 信息: %(message)s' ) logger = logging.getLogger(__name__) logger.info("This is a log info" ) logger.debug("Debugging" ) logger.warning("Warning exists" ) logger.info("Finish" )
执行该文件中,可以发现在控制台中不会有任何输出了,而是打印到了指定的文件中,并且也是按照我们指定的时间格式进行打印。
案例四
在上面的基础上,我们可以进一步升级,将 basicConfig 拆分成多条语句,这样实现起来会更加的灵活。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import logginglogger = logging.getLogger(__name__) logger.setLevel(level = logging.INFO) formatter = logging.Formatter('时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 信息: %(message)s' ) handler = logging.FileHandler("output1.log" ) handler.setFormatter(formatter) logger.addHandler(handler) logger.info("This is a log info" ) logger.debug("Debugging" ) logger.warning("Warning exists" ) logger.info("Finish" )
案例五
当然案例四这种写法除了更加灵活以外,也可以通过这种写法实现控制台和文件的同时输出,并且两者输出的格式也可以分别不同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import logginglogger = logging.getLogger(__name__) logger.setLevel(level = logging.INFO) handler1 = logging.FileHandler("output.log" ) handler2 = logging.StreamHandler() formatter1 = logging.Formatter('时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 行号: %(lineno)s - 信息: %(message)s' ) formatter2 = logging.Formatter('时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 信息: %(message)s' ) handler1.setFormatter(formatter1) handler2.setFormatter(formatter2) logger.addHandler(handler1) logger.addHandler(handler2) logger.info("This is a log info" ) logger.debug("Debugging" ) logger.warning("Warning exists" ) logger.info("Finish" )
5.4 Pytest 日志 Python 里提供日志功能的标准模块就是 logging,正常用法就是需要用 logging 模块创建 logger,然后为 logger 配置 handler 让日志 output 到哪里,当然你还需要创建 format 从而定制化日志信息格式(时间,代码位置等)。
但在 pytest 中你就无需做这些事了,只要在 pytest.ini 配置好 logging 信息即可。我们就拿上面的案例五举例,看看如何在 pytest.ini 设置从而达到相同的效果。
1 2 3 4 5 6 7 8 9 10 11 12 [pytest] disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True log_cli = 1 log_cli_level = INFOlog_cli_format = 时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 行号: %(lineno )s - 信息: %(message)slog_cli_date_format = %Y-%m-%d %H:%M:%Slog_file = output.loglog_file_level = INFOlog_file_date_format = %Y-%m-%d %H:%M:%Slog_file_format = 时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 行号: %(lineno )s - 信息: %(message)s
然后我们可以执行两个用例,来看看日志打印的效果如何。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import timeimport loggingimport pytestlogging.basicConfig(level=logging.DEBUG) def test_1 (): log = logging.getLogger("test_1" ) time.sleep(1 ) log.info("after 1 sec" ) time.sleep(1 ) log.info("after 2 sec" ) time.sleep(1 ) log.info("after 3 sec" ) assert 1 == 1 def test_2 (): log = logging.getLogger("test_2" ) time.sleep(1 ) log.info("after 1 sec" ) time.sleep(1 ) log.info("after 2 sec" ) time.sleep(1 ) log.info("after 3 sec" ) assert 1 , "failing for demo purposes"
执行后可以发现 pytest 中打印出的日志信息更加的详细了,不会像 print 语句那样随便,并且日志也成功的写入到 output.log 文件中。
6. 测试报告生成 6.1 Pytest-html
github 源码地址:https://github.com/pytest-dev/pytest-html
pytest 可以借助 pytest-html 插件生成测试报告,不用自己编写生成报告的代码。
与安装 Python 第三方库一样使用 pip 命令:
1 pip install -U pytest-html
生成报告命令
1 pytest --html=reportname.html
示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import osimport timeimport pytestproject_root = os.path.dirname(os.path.dirname(os.path.reealpath(__file__))) report_dir = os.path.join(project_root, 'report' ) print (report_dir)current_time = time.strftime("%Y-%m-%d_%H-%M-%S" ) report_abspath = os.path.join(report_dir, "HTMLReport_{}.html" .format (current_time)) print (report_abspath)tag = "prod" if __name__ == '__main__' : pytest.mian(['-v' , '-s' , '-m' , f"{tag} " , f'--html={report_abspath} ' , '--self-contained-html' ])
6.2 Allure 6.2.1 介绍
github 源码地址:https://github.com/allure-framework/
Allure 是一款轻量级并且非常灵活的开源测试报告框架,它支持绝大多数测试框架,它的特点如下:
从开发/质量保证的角度看,Allure 报告可以缩短常见缺陷的生命周期。
从管理者的角度看,Allure 提供了一个清晰的 “ 全局 “ 视野。
6.2.2 安装方法
打开终端,使用以下命令编辑 bash 配置文件:
如果该文件不存在,您可以使用 touch 命令创建一个:
在文本编辑器中,将以下行添加到文件的末尾:
1 PATH=”/Users/your_username/path_to_allure/bin:${PATH}” export PATH
将上述命令中的 “your_username” 替换为实际用户名,并将 “path_to_allure” 替换为实际 Allure 可执行文件的路径。
保存对 .bash_profile 文件的更改。如果使用的是 Mac 自带的文本编辑器,可以使用 Command + S 快捷键保存。然后关闭文本编辑器,并在终端输入以下命令,使更改生效:
在终端中输入以下命令:
如果安装成功,您将看到 Allure 的版本号显示在终端中。
6.2.3 Pytest 使用 Allure pytest 框架集成 Allure
Allure 使用文档:https://docs.qameta.io/allure/#\_python
allure-pytest 是 pytest 的一个插件,通过它我们可以生成 Allure 所需要的用于生成测试报告的数据,使 pytest 与上面下载的 Allure 框架关联起来。
安装 allure-pytest 插件方法:
1 pip install -U allure-pytest
使用方法
执行 pytest 命令,并指定 allure 报告目录:
1 pytest --alluredir=./allure_reports
1 allure serve allure_reports
1 allure generate allure_reports
案例
写一个简单的用例文件 test.py。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import pytestclass Test_错误密码 : def test_C001001 (self ): print ('\n用例C001001' ) assert 1 == 1 def test_C001002 (self ): print ('\n用例C001002' ) assert 2 == 2 def test_C001003 (self ): print ('\n用例C001003' ) assert 3 == 2
然后在终端通过 pytest 执行并指定生成测试报告的目录为 allure_reports。
1 python -m pytest -sv --alluredir=./allure_reports test.py
执行完后,在当前目录下就会出现一个 allure_reports 文件夹,其中存放着刚才生成的测试结果。
接着在当前目录下的终端执行生成 Allure 在线报告的命令,从而自动弹起测试报告页面。
1 allure serve allure_reports
当然,也可以在终端生成本地的测试报告。
1 allure generate allure_reports
执行后,在本地就会多出来一个 allure_report 的目录。
将 index.html 文件在浏览器打开,同样可以获取到上面同样的效果。
6.2.4 Allure 测试报告装饰器
使用方法
参数值
参数说明
@allure.epic()
epic 描述
BDD Style Marker 有 Epic、Feature、Story 三个层级的装饰器,Epic 是 User Story 逻辑上的集合
@allure.feature
模块名称
BDD Style Marker 比 Epic 低一级,可以代表模块
@allure.story
用户故事
BDD Style Marker 比 feature 低一级,代表用户故事(User Story)
@allure.title
用例标题
测试用例的 title
@allure.testcase()
测试用例的链接地址
可以直接给 jira 地址,可以把测试代码和测试用例 link 起来
@allure.issue()
缺陷
对应缺陷管理系统里面的链接
@allure.description()
用例描述
测试用例的描述
@allure.step()
操作步骤
测试用例的步骤
@allure.severity()
用例等级
blocker、critical、normal、minor、trivial
@allure.link()
链接
定义一个链接,在测试报告展现
@allure.attachment()
附件
为测试报告添加附件
示例
这次同样在 test.py 文件下编写一些简单的模块测试用例代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import pytestimport allure@pytest.mark.prod @allure.feature("购物车模块" ) class TestShopping (object ): @allure.story("测试添加购物车功能" ) def test_add_shopping (self ): user_login("张三" , "123456" ) with allure.step("第 2 步:浏览商品" ): print ("浏览商品" ) with allure.step("第 3 步:选择商品" ): allure.attach("python蟒蛇书" , "书名" ) print ("选择商品" ) with allure.step("第 4 步:添加购物车" ): allure.attach("添加购物车成功" , "期望结果" ) print ("添加购物车" ) assert True @allure.story("测试修改购物车功能" ) def test_edit_shopping (self ): print ("修改购物车" ) @pytest.mark.skipif(reason='本次不执行' ) @allure.story("测试删除购物车中的商品功能" ) def test_delete_shopping (self ): pass @allure.step("第 1 步:用户登录" ) def user_login (user, pwd ): print (user, pwd)
然后同样使用上面的命令去生成测试报告。
1 2 python -m pytest -sv --alluredir=./allure_reports test.py allure serve allure_reports
通过观察可以发现,这次的报告中多了一些不一样的数据,例如在 Test body 中打印出了用例执行的步骤。