apiAutoFramework

接口自动化框架搭建总结

1 框架设计思路

目标:参考HttpRunner框架,约定大于配置,测试人员只需要维护Yaml文件即可进行接口自动化测试,在Yaml文件中进行参数化、接口前置处理、接口后置处理、断言等。

核心功能点

  • 支持对多项目、多环境进行配置
  • Yaml文件的读取进行封装
  • Requests请求进行封装
  • 单个Yaml文件中支持单接口、多接口场景
  • 支持参数池,接口请求发送之前参数调用参数池进行值替换
  • 支持自定义参数池
  • 支持hook,接口请求发送前后支持调用hook函数
  • 支持allure报告
  • 支持运行日志记录
  • 支持Yaml文件规范性检查
  • 支持邮件发送测试结果
  • 支持Swagger文档转为Yaml用例
  • 支持SQL前后置,运行sql进行断言,从sql结果中取参数,运行sql
  • 支持多种断言方式
  • 控制是否运行
  • 控制睡眠时间

2 第三方依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 单元测试框架
pytest==8.0.2
# allure报告
allure-pytest==2.13.5
allure-python-commons==2.13.5
# 生成常见假数据
Faker==24.8.0
# jsonpath提取
jsonpath==0.82.2
# yaml文件读取
PyYAML==6.0.1
# 连接PostgreSQL
psycopg2==2.9.9
# 发送接口请求
requests==2.27.1
# 包管理工具
pip==24.0

3 知识点

3.1 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
class YamlUtil:
def __init__(self, file_path):
self.file_path = file_path

# 写入yaml,覆盖的方式
def write_yaml(self, data):
with open(self.file_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(data, f)

# 写入yaml,追加的方式
def append_yaml(self, data):
with open(self.file_path, 'a', encoding='utf-8') as f:
yaml.safe_dump(data, f)

# 读取yaml
def read_yaml(self):
with open(self.file_path, 'r', encoding='utf-8') as f:
result = yaml.safe_load(f)
return result

# 清空yaml
def clear_yaml(self):
with open(self.file_path, 'w', encoding='utf-8') as f:
f.truncate()

3.2 接口请求封装

  • 保持会话,在不同的接口请求之间保持会话的一致
  • 不管是get还是post方法,在requests库中最终都调用了request方法。所以这里使用request方法,以**kwargs关键字参数来接受一个字典,思路就是对Yaml文件中的接口请求信息进行处理,将处理好的接口请求信息组装成一个字典,然后调用request方法进行接口请求的发送。

request1

request2

1
2
3
4
5
6
7
8
# 封装的代码
class RequestUtil:
def __init__(self):
# 保持会话
self.session = requests.session()

def send_request(self, **kwargs):
return self.session.request(**kwargs)

3.3 文件上传处理

  • 如果Yaml中的键包含files,那么就需要进行文件上传处理

  • Yaml文件中的file是文件上传接口中的请求参数名称,若该接口中名称不是file则需要在Yaml中进行对应的更换调整

  • 支持多文件同时上传,单个/多个文件路径都写在列表中

  • 文件上传的处理包括了单字段发送单个文件、单字段发送多个文件、字典形式发送、元组列表形式发送等

  • 框架中采用的时单字段发送多个文件,使用的元组列表形式如下:

1
2
3
4
5
6
7
[
("field1" , ("filename1", open("filePath1", "rb"))),
("field1" , ("filename2", open("filePath2", "rb"), "image/png")),
("field1" , open("filePath3", "rb")),
("field1" , open("filePath4", "rb").read())
]
# field1是接口发送时的参数,下方代码案例中使用的参数是file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-
epic: 会议管理
feature: 创建会议
story: 用户在创建会议时支持上传附件信息
title: 上传doc文件
requests:
url: ${host}/mapi/attachment/upload
method: post
headers:
Admin-Token: ${Admin-Token}
data:
json:
files:
file: ['files/test.doc', 'files/1.png']
extract_key:
attachId: $.body.id
assert_expression:
code: 200
info: 成功
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
40
41
class RequestUtil:
def __init__(self):
# 保持会话
self.session = requests.session()

def send_request(self, **kwargs):
# 针对文件上传做处理
if 'files' in kwargs.keys():
# MIME类型字典,根据文件扩展名设置正确的MIME类型
mime_types = {
'png': 'image/png',
'jpg': 'image/jpg',
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'ppt': 'application/vnd.ms-powerpoint',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'mp4': 'video/mp4',
}
file_tuple = []
# 进行文件上传处理
# file_data是一个字典,其中的键k是'file',是接口要求传的参数名称,若接口中是其他名称,那么yaml文件中也要跟着改
file_data = kwargs['files']
# k是file,v是一个数组
for k, v in file_data.items():
# v是由多个文件路径组成的列表
# 遍历列表,取出每一个文件路径,f就是每一个文件的路径
for f in v:
# 获取文件名称
f_name = f.split("/")[1]
# 获取文件后缀名
f_suffix = f.split(".")[1]
content_type = mime_types.get(f_suffix, 'application/octet-stream')
# 组装文件,文件名称,文件路径,文件类型,其他参数
simple_file = (k, (f_name, open(PathUtil(os.path.abspath(f)).resource_path(), 'rb'), content_type, {'Expires': '0'}))
# 将组装的文件加入到数组中(针对有多个文件同时上传的情况)
file_tuple.append(simple_file)
# 多文件上传时,文件数据以[(),(),()...]的格式进行请求
kwargs['files'] = file_tuple
return self.session.request(**kwargs)

3.4 Yaml文件编写

  • config:配置文件

    • variables:变量,自定义函数/参数替换后存入参数池
  • teststeps:测试步骤,可以是单个步骤(单用例),页可以是多个步骤

  • epic:项目名称

  • feature:接口所属模块

  • story:接口名称

  • title:用例名称

  • requests:接口请求信息

    • url:接口路径
    • method:接口访问方式
    • headers:接口请求的header信息,字典格式
    • params:URL中的参数
    • data:接口请求的data信息
    • json:接口请求的json信息
    • files:文件上传的文件路径,需要把路径写在列表里面
  • setup_hooks:前置钩子,在请求发送之前执行

  • teardown_hooks:后置钩子,在请求发送之后执行

  • headers_extract:从接口响应header中提取参数并放入参数池,字典格式

  • extract:从接口响应信息中提取参数并放入参数池,使用jsonpath格式

  • validate:断言,字典格式

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
40
41
config: 
variables:
teststeps:
-
epic: 智慧安防系统
feature: 登录
story: 用户登录
title: 账号密码正确时进行登录
requests:
url: ${host}/school-safety/system/api/auth/sys-user/login
method: post
headers:
data:
json:
username: "admin"
password: ${__MD5(hyxk@2024)}
captchaVerification: "PfcH6mgr8tpXuMWFjvW6YVaqrswIuwmWI5dsVZSg7sGpWtDCUbHuDEXl3cFB1+VvCC/rAkSwK8Fad52FSuncVg=="
setup_hooks:
teardown_hooks:
headers_extract:
token: token
assert_expression:
message: 成功
-
epic: 智慧安防系统
feature: 登录
story: 用户操作
title: 获取后台用户信息
requests:
url: ${host}/school-safety/system/api/sys-user/current-user
method: get
headers:
token: ${token}
data:
json:
files:
file: ['files/test.pdf']
extract:
id: $.data.id
validate:
message: 成功

3.5 自定义函数

  • Yamlrequests信息中,如果包含有${__()}格式的字符串,则说明该字符串是调用一个方法
  • 传入一个json格式的字符串,通过正则表达式,从字符串中找到所有符合格式的函数名称,如${__getNum(1,2)}则匹配的结果就是[(getNum,'1,2')]
  • 通过反射来获取对象属性,使用getattr(obj, 'value')函数获取对象的属性,这里设置objhook函数的代码编写所在类的实例化对象,value为方法名称,获取到对象属性后,利用()来执行该方法,若存在参数,则通过,来进行字符串分割,分割后是一个参数组成的元组,使用*args可变参数来接受这个元组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 传入用例数据,json格式的字符串,传入的是用例的requests中的内容
def function_help(self, case_info):
# 定义正则表达式
pattern = r'\S*\$\{__(.*?)\((.*?)\)\}\S*'
# match是一个列表,列表中是多个元组,元组中第一个元素是方法名称,第二个元素是参数字符串(以逗号作为分割)
match = re.findall(pattern, case_info)
# 针对每一个函数助手,进行参数替换
for m in match:
# 如果没有参数
if m[1] == '':
case_info = case_info.replace("${__" + m[0] + "(" + ")" + "}", str(getattr(FunctionHelpUtil(), m[0])()))
# 如果有参数
else:
case_info = case_info.replace("${__" + m[0] + "(" + m[1] + ")" + "}", str(getattr(FunctionHelpUtil(), m[0])(*m[1].split(','))))
return case_info

3.6 前置参数替换

  • Yamlrequests信息中,如果包含有${}格式的字符串,则说明该字符串需要进行参数处理
  • 传入json格式的字符串,定义正则表达式来提取出参数名称,提取出来的结果是一个列表
  • 先从环境变量中找是否有该参数,如果有,则从环境变量中取值,如果没有就从参数池中进行取值,参数池中也没有的话就不进行替换
  • 将替换后的字符串进行返回,进入下一流程处理
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
# 参数化替换
# 传入用例数据,json格式的字符串,传入的是用例的requests中的内容
def parameter_replace(self, case_info):
# 定义正则表达式
pattern = r'\S*\$\{(.*?)\}\S*'
match = re.findall(pattern, case_info)
# 循环每个参数,分别进行替换
for m in match:
# 如果参数池中没有找到该参数,那么就原样替换
para_value = "${" + m + "}"
# 读取环境变量
env_data = EnvUtil().get_run_env()
# 如果能在环境变量中找到,那么就从环境变量中取值
if m in env_data.keys():
para_value = env_data[m]
# 没找到就在参数池中取值
else:
logger.info("环境变量中没有该参数,在参数池中进行取值:" + m)
# 读取参数池
pl_obj = YamlUtil(PathUtil(os.path.join('config', 'parameter_pool.yaml')).resource_path()).read_yaml()
try:
# 获取对应的参数数据
para_value = pl_obj[m]
except Exception as e:
logger.info("参数池中没有该参数:" + m)
# 执行替换
case_info = case_info.replace("${" + m + "}", str(para_value))
return case_info

3.7 后置参数提取

  • Yaml的信息中,如果包含有extract_key关键字,则说明需要进行接口响应数据的提取
  • jsonpath解析后的数据是数组,所以要用下标0来取值
  • 将提取的数据存入参数池中
1
2
3
4
5
6
7
8
9
10
# 响应数据处理
# 传入的参数包括;用例中的响应提取数据(字典)extract_key、接口请求的响应数据response_json_data
def response_extract(self, extract_key, response_json_data):
for k, v in extract_key.items():
# 用封装的yaml文件读取工具写入参数池,写入的数据是一个字典,k作为字典的键,jsonpath表达式解析出来后的数据作为值,因为jsonpath解析后的数据是数组,所以要用下标0
try:
write_value = jsonpath.jsonpath(response_json_data, v)[0]
YamlUtil(PathUtil(os.path.join('config', 'parameter_pool.yaml')).resource_path()).append_yaml({k:write_value})
except Exception as e:
logger.info("响应数据写入参数池失败")

3.8 找到项目下的Yaml文件、转为pytest用例

  • 存在多个项目的情况下,可指定运行某一个项目,每个项目都是一个目录,目录名称就是项目名称,项目名称目录下可嵌套多级目录,最后一级是Yaml文件
  • 使用glob模块递归查询data下面的项目目录下的所有以.yaml为后缀的文件
  • 利用反射setattrpytest测试类中注入方法,方法名以test_+Yaml文件名进行命名,方法的内容就是test_case方法中的内容
1
2
3
4
5
6
7
8
9
10
11
12
# 传入要运行的项目文件名称
def get_file_name(case_dir):
# 使用glob模块的递归搜索功能获取所有.yaml文件的完整路径
yaml_files = glob.glob(os.path.join(PathUtil('data'+'\\'+case_dir).resource_path(), '**', '*.yaml'), recursive=True)
for yaml_path in yaml_files:
# yaml的文件名称
yaml_file = os.path.splitext(os.path.basename(yaml_path))[0]
# 如果类中存在同名方法,那么就先进行删除
if hasattr(TestBase, 'test_'+yaml_file):
delattr(TestBase, 'test_'+yaml_file)
# 向类中注入方法
setattr(TestBase, 'test_'+yaml_file, create_case(yaml_path))

3.9 处理Yaml文件中多个接口

  • 利用pytest中的@pytest.mark.parametrize来进行参数化,get_case_info返回的是由多个字典格式的接口数据组成的列表,由参数化来取得每一个接口的字典数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 传入yaml文件路径,获取该yaml里面的全部信息
def get_case_info(yaml_path):
# 读取yaml文件里面的信息
case_info = YamlUtil(yaml_path).read_yaml()
return case_info

# 利用parametrize进行参数化,定义要通过反射注入的方法,在方法中执行数据处理和请求
def create_case(yaml_path):
@pytest.mark.parametrize('case_info', get_case_info(yaml_path))
def test_case(self, case_info):
# allure报告信息
allure.dynamic.epic(case_info['epic'])
allure.dynamic.feature(case_info['feature'])
allure.dynamic.story(case_info['story'])
allure.dynamic.title(case_info['title'])
# 进行用例处理
CaseInfoHandle().case_core(case_info)

return test_case

3.10 用例格式

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
config:
variables:
password: macro123
username: admin
teststeps:
-
func_name: test_login
mark:
skip:
skipif:
epic: mall商城
feature: 登录
story: 登录
title: 正常账号密码登录
requests:
url: ${host}/admin/login
method: post
headers:
params:
data:
json:
password: ${password}
username: ${username}
setup_hooks: ${__setup(requests_info)}
teardown_hooks:
extract:
token: ['jsonpath', '$.data.token']
validate:
code: 200
massage: 成功

1.1 json转换

1
2
3
4
# 将字典转为json格式字符串
json.dumps(dict)
# 将json字符串转为python对象
json.loads(json_data)

1.2 jsonpath

1
2
# jsonpath执行后返回的是数组
jsonpath(obj, expression)

1.3 反射

1
2
3
4
# 通过attr获取到对象的属性/方法,即有了方法名称就可以执行到对应的方法
getattr(obj, attr)
# 向对象中注入类/方法
setattr(obj, attr)
------------- End -------------