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 pytest
import requests

# 断言字符串
class 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 pytest

def 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 pytest

def 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 语法规则

  1. 区分大小写,注释使用 #
  2. 使用缩进表示层级,不能使用 tab 键缩进,只能用空格
  3. 缩进没有数量的,只要是前面对齐就行

2.1.2 数据组成

  • Map 对象(键值对):键:(空格) 值
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 requests

class 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 yaml

class YamlUtil:
#通过init方法把yaml文件传入到这个类
def __init__(self, yaml_file):
self.yaml_file = yaml_file

#读取yaml文件,对yaml反序列化,即将yaml格式转换成dict格式
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 yaml

class YamlUtil:
#通过init方法把yaml文件传入到这个类
def __init__(self, yaml_file):
self.yaml_file = yaml_file

#读取yaml文件,对yaml反序列化,即将yaml格式转换成dict格式
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 pytest
import yaml_util
import requests

class 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 pytest
import yaml_util
import requests

class 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}
#用例三:错误用例(code应该会返回99992402才对)
-
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 pytest

class 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 pytest

class 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 pytest

class 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 pytest

data = ["张三", "李四"]

@pytest.fixture(params=data)
def user(request):
return request.param # 拿到params接受的数据

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 pytest

user_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 pytest

user_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 pytest

test_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 支持外部文件实现数据驱动的思路:

  1. JSON/YAML 文件实现数据驱动
    • 创建一个读取 JSON 文件和 YAML 文件的方法
    • 实现自动读取 JSON 文件和 YAML 文件,并把 JSON 文件和 YAML 文件中的数据提取出来,并按照 pytest.mark.parametrize 可接受的方式将数据返回。
  2. 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 pytest
from openpyxl import load_workbook

class 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)

测试数据.xlsx

输出结果

不管选择哪个数据源,总体的思路都是不变的,都是先通过代码写一个工具来获取到数据源中的数据,然后将这个数据传到 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 requests

# 方法A
def send_request(url):
return requests.get(url).status_code

# 方法B
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 pytest
from 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 requests

class 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 pytest
from api_person_auth import Api_Person_Auth

testdata = []
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):
# 使用parametrize参数化请求参数、状态码和返回信息
@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() # 实例化
# 使用Mock替代真实服务
# mocker.patch第一个参数传模拟对象的具体路径
auth.api_person_auth = mocker.patch('api_person_auth.Api_Person_Auth.api_person_auth', return_value=expect['resultmsg'])

# 模拟返回msg
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 logging
logger = 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 logging

logging.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 logging

logging.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 logging

logger = logging.getLogger(__name__) # 1.谁来处理

logger.setLevel(level = logging.INFO) # 2.可选,设置级别

formatter = logging.Formatter('时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 信息: %(message)s') # 3.格式是什么
handler = logging.FileHandler("output1.log") # 4.输出到哪里

handler.setFormatter(formatter) # 5.把格式绑定到输出
logger.addHandler(handler) # 6.把输出绑定到logger

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 logging

logger = 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 = INFO
log_cli_format = 时间: %(asctime)s - 名称: %(name)s - 级别: %(levelname)s - 行号: %(lineno)s - 信息: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

log_file = output.log
log_file_level = INFO
log_file_date_format = %Y-%m-%d %H:%M:%S
log_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 time
import logging
import pytest

logging.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 os
import time
import pytest

project_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 配置文件:

1
open -t ~/.bash_profile

如果该文件不存在,您可以使用 touch 命令创建一个:

1
touch ~/.bash_profile

在文本编辑器中,将以下行添加到文件的末尾:

1
PATH=”/Users/your_username/path_to_allure/bin:${PATH}” export PATH

将上述命令中的 “your_username” 替换为实际用户名,并将 “path_to_allure” 替换为实际 Allure 可执行文件的路径。

  • 保存配置,并使其生效

保存对 .bash_profile 文件的更改。如果使用的是 Mac 自带的文本编辑器,可以使用 Command + S 快捷键保存。然后关闭文本编辑器,并在终端输入以下命令,使更改生效:

1
source ~/.bash_profile
  • 验证安装情况

在终端中输入以下命令:

1
allure --version

如果安装成功,您将看到 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
  • 在线生成 allure 报告:
1
allure serve allure_reports
  • 生成本地 allure 报告:
1
allure generate allure_reports

案例

写一个简单的用例文件 test.py。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest

class 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

Overview - 总览

Categories - 类别

Suites - 测试套

Graphs - 图表

Timeline - 时间刻度

Behaviors - 功能

Packages - 包

当然,也可以在终端生成本地的测试报告。

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 pytest
import allure

@pytest.mark.prod
@allure.feature("购物车模块") # 用feature说明产品需求
class TestShopping(object):
@allure.story("测试添加购物车功能") # 用story说明用户场景
def test_add_shopping(self):
user_login("张三", "123456")

with allure.step("第 2 步:浏览商品"): # step的参数将会打印到测试报告中
print("浏览商品")

with allure.step("第 3 步:选择商品"):
allure.attach("python蟒蛇书", "书名") # attach可以添加一些附加信息
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 中打印出了用例执行的步骤。