进行一系列优化,且删除所有demo用例仅保持正常的示例

This commit is contained in:
floraachy
2023-11-10 15:58:03 +08:00
parent e97fe7290f
commit 4943adf36b
53 changed files with 906 additions and 868 deletions
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# @Time : 2023/11/10 14:41
# @Author : floraachy
# @File : __init__.py
# @Software: PyCharm
# @Desc:
+126
View File
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# @Time : 2023/10/12 13:41
# @Author : chenyinhua
# @File : utils.py
# @Software: PyCharm
# @Desc:
# 标准库导入
import os
import time
# 第三方库导入
import allure
from loguru import logger
import requests # pip install requests
from requests_toolbelt import MultipartEncoder # pip install requests_toolbelt
class BaseRequest:
"""
Request操作封装
"""
TIMEOUT = 5
session = None
@classmethod
def get_session(cls):
"""
单例模式保证测试过程中使用的都是一个session对象;
requests.session可以自动处理cookies,做状态保持。
"""
if cls.session is None:
cls.session = requests.Session()
return cls.session
@classmethod
@allure.step("请求各项参数:{req_data}")
def send_request(cls, req_data):
"""
处理请求数据,转换成可用数据发送请求
:param req_data: 请求数据
:return: 响应对象
"""
try:
before = "\n" + "=" * 80 \
+ "\n-------------Start:请求前--------------------\n" \
f"标题: {req_data.get('title', None)}\n" \
f"请求路径: {req_data.get('url', None)}\n" \
f"请求方式: {req_data.get('method', None)}\n" \
f"请求头: {req_data.get('headers', None)}\n" \
f"请求Cookies: {req_data.get('cookies', None)}\n" \
f"请求关键字: {req_data.get('request_type', None)}\n" \
f"请求内容: {req_data.get('payload', None)}\n" \
f"请求文件: {req_data.get('file', None)}\n" \
+ "=" * 80
logger.info(before)
response_result = cls.send_api_request(
url=req_data.get("url"),
method=req_data.get("method").lower(),
request_type=req_data.get("request_type", None),
header=req_data.get("headers", None),
payload=req_data.get("payload", None),
files=req_data.get("files", None),
cookies=req_data.get("cookies", None)
)
after = "\n" + "=" * 80 \
+ "\n-------------Start:请求后--------------------\n" \
f"响应数据: {response_result.text}\n" \
f"响应码: {response_result.status_code}\n" \
f"响应耗时: {round(response_result.elapsed.total_seconds(), 2)} s || {round(response_result.elapsed.total_seconds() * 1000, 2)} ms\n" \
+ "=" * 80
logger.info(after)
return response_result
except requests.exceptions.RequestException as e:
logger.error(f"请求出错,{str(e)}")
raise ValueError(f"请求出错,{str(e)}")
@classmethod
def send_api_request(cls, url: str, method: str, request_type: str, header=None, payload=None,
files=None, cookies=None):
"""
发送请求
:param method: 请求方法
:param url: 请求url
:param request_type: 请求参数类型,可选值为paramsjsondata
:param payload: 请求数据,对于不同请求类型,可以为dictMultipartEncoder等
:param files: 请求上传的文件
:param header: 请求头
:param cookies: 请求cookies
:return: 返回res对象
"""
headers = header or {}
session = cls.get_session()
if request_type:
if request_type.lower() == 'params':
res = session.request(method=method, url=url, params=payload, headers=headers, cookies=cookies,
timeout=cls.TIMEOUT)
return res
elif request_type.lower() == 'data':
res = session.request(method=method, url=url, data=payload, headers=headers, cookies=cookies,
timeout=cls.TIMEOUT)
return res
elif request_type.lower() == 'json':
res = session.request(method=method, url=url, json=payload, headers=headers, cookies=cookies,
timeout=cls.TIMEOUT)
return res
elif request_type.lower() == 'file':
if files:
file_name = os.path.basename(files)
encoder = MultipartEncoder(fields={"file": (file_name, open(files, "rb"))},
boundary='------------------------' + str(time.time()))
headers['Content-Type'] = encoder.content_type
response = session.request(method=method, url=url, data=encoder.to_string(), headers=headers,
cookies=cookies, timeout=cls.TIMEOUT)
return response
else:
logger.error("上传的文件不能为空")
else:
logger.error("request_type可选关键字为params, json, data, file")
raise ValueError('request_type可选关键字为params, json, data, file')
else:
logger.error("request_type参数不能为空")
raise ValueError('request_type参数不能为空')
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
# @Time : 2023/6/7 16:37
# @Author : chenyinhua
# @File : case_data_analysis.py
# @Software: PyCharm
# @Desc: 分析用例数据是否符合规范
# 标准库导入
from typing import Text
# 第三方库导入
from config.models import TestCaseEnum, Method, RequestType, Severity
class CaseDataCheck:
"""
用例数据解析, 判断数据填写是否符合规范
"""
def __init__(self):
self.case_data = None
self.case_id = None
@property
def get_method(self) -> Text:
"""
处理method参数:校验method参数是否是指定枚举值中的一个
"""
return self.check_params_right(
Method,
self.case_data.get(TestCaseEnum.METHOD.value[0])
)
@property
def get_severity(self) -> Text:
"""
处理severity参数:如果Severity为空或者不传或者传错,视为NORMAL
"""
# 如果Severity为空或者不传或者传错,视为NORMAL
attr = self.case_data.get(TestCaseEnum.SEVERITY.value[0])
if attr is None or attr.upper() not in Severity._member_names_:
return "NORMAL"
else:
return attr.upper()
@property
def get_request_type(self):
"""
处理request_type参数:校验request_type参数是否是指定枚举值中的一个
"""
return self.check_params_right(
RequestType,
self.case_data.get(TestCaseEnum.REQUEST_TYPE.value[0])
)
def check_case_data_attr(self, attr: Text):
"""
检查用例中是否缺失某个参数
"""
assert attr in self.case_data.keys(), (
f"用例ID: {self.case_id} --> 缺少 {attr} 参数,请确认用例内容是否编写规范."
)
def check_params_exit(self):
"""
遍历一个枚举类中所有成员,并检查与每个成员对应的实例属性是否存在。
如果属性存在,则什么也不做,如果不存在,则抛出异常或执行其他操作
"""
for enum in list(TestCaseEnum._value2member_map_):
if enum[1]:
self.check_case_data_attr(enum[0])
def check_required_fields(self):
for field in TestCaseEnum:
if field.value[1]: # 判断是否为必填参数
field_name = field.value[0]
value = self.case_data[field_name]
if value is None or (isinstance(value, str) and len(value.strip()) == 0):
raise ValueError(f"用例ID: {self.case_id} --> {field_name}字段的值是:{value}, 该字段值是必填,请检查是否填写正确.")
def check_params_right(self, enum_name, attr):
"""
检查参数值是否正确,符合要求规范
"""
_member_names_ = enum_name._member_names_
assert attr.upper() in _member_names_, (
f"用例ID: {self.case_id} --> {enum_name}: {attr} 填写不正确,"
f"当前框架中只支持 {_member_names_} 类型."
f"如有疑问,请联系管理员."
)
return attr.upper()
def case_process(self, cases):
case_list = []
for key, values in cases.items():
# 公共配置中的数据,与用例数据不同,需要单独处理
if key == 'case_info':
for value in values:
self.case_data = value
self.case_id = value.get("id")
# 检查用例参数,需要必填的是否有值
self.check_required_fields()
# 检查用例参数值,是否都填写正确
self.check_params_exit()
case_data = {
'id': self.case_data.get(TestCaseEnum.ID.value[0]),
'title': self.case_data.get(TestCaseEnum.TITLE.value[0]),
'severity': self.get_severity,
'url': self.case_data.get(TestCaseEnum.URL.value[0]),
'run': self.case_data.get(TestCaseEnum.RUN.value[0]),
'method': self.get_method,
'headers': self.case_data.get(TestCaseEnum.HEADERS.value[0]),
'cookies': self.case_data.get(TestCaseEnum.COOKIES.value[0]),
'request_type': self.get_request_type,
'payload': self.case_data.get(TestCaseEnum.PAYLOAD.value[0]),
'files': self.case_data.get(TestCaseEnum.FILES.value[0]),
'extract': self.case_data.get(TestCaseEnum.EXTRACT.value[0]),
"assert_response": self.case_data.get(TestCaseEnum.ASSERT_RESPONSE.value[0]),
"assert_sql": self.case_data.get(TestCaseEnum.ASSERT_SQL.value[0]),
}
case_list.append(case_data)
return case_list
@@ -0,0 +1,260 @@
# -*- coding: utf-8 -*-
# @Time : 2023/6/7 10:07
# @Author : chenyinhua
# @File : case_fun_handle.py
# @Software: PyCharm
# @Desc:
# 标准库导入
import os
from string import Template
import datetime
import re
# 第三方库导入
from xpinyin import Pinyin # 纯 Python 编写的中文字符串拼音转换模块,不需要依赖外部程序和词库。
from loguru import logger
# 本地应用/模块导入
from common_utils.read_files_utils.excel_handle import ExcelHandle
from common_utils.read_files_utils.yaml_handle import YamlHandle
from common_utils.other_utils.files_handle import get_files, get_relative_path
from config.models import CaseFileType
from config.settings import CASE_FILE_TYPE
from config.path_config import DATA_DIR, CASE_TEMPLATE_DIR, AUTO_CASE_DIR
from config.global_vars import CUSTOM_MARKERS
from case_utils.requests_utils.case_data_analysis import CaseDataCheck
"""
主要步骤:
1. 从用例数据文件(EXCEL/YAML)中获取用例数据
2. 分析用例数据是否符合规范
3. 确认符合规范后,获取所有用例数据,自动生成测试用例方法(PY)
"""
def handle_excel_data(file):
"""
读取excel用例数据生成测试用例
"""
if os.path.isfile(file):
# 读取excel文件中的用例数据,存储到data中
data = ExcelHandle(file).read()
for index, v in enumerate(data):
excel_case = {}
"""
将excel读取到的用例数据,适配allure格式
表单名称以短横线隔开的情况下,左边部分作为allure_epic 右边部分作为allure_feature以及allure_story。
否则,表单名称全部作为allure_epicallure_featureallure_story
"""
if "-" in v["sheet_name"]:
excel_case["case_common"] = {
"allure_epic": v["sheet_name"].split("-")[0],
"allure_feature": v["sheet_name"].split("-")[1],
"allure_story": v["sheet_name"].split("-")[1]
}
else:
excel_case["case_common"] = {
"allure_epic": v["sheet_name"],
"allure_feature": v["sheet_name"],
"allure_story": v["sheet_name"]
}
# 处理excel用例数据
excel_case["case_info"] = v["data"]
# 检查用例数据是否符合规范
tested_case = CaseDataCheck().case_process(excel_case)
# 生成测试方法
"""
由于excel涉及到多个表单,每一个表单都会生成一个测试方法。因此会将表单名称的首字母拼接到测试方法上。
excel名称:test_demo
例如表单名称:"GitLink-登录模块""登录模块",都是取关键字"登录模块"首字母
测试文件:test_demo_dl.py
测试方法名称: TestDemoDl.test_demo_dl
"""
pin_yin = Pinyin()
_name = pin_yin.get_initials(excel_case["case_common"]["allure_feature"], "").lower()
if os.path.samefile(DATA_DIR, os.path.dirname(file)):
# 用例文件的直接父级目录是DATA_DIR,则直接在AUTO_CASE_DIR下生成测试用例方法
gen_case_file(
filename=os.path.splitext(os.path.basename(file))[0] + "_" + _name,
case_template_path=CASE_TEMPLATE_DIR,
case_common=excel_case["case_common"],
case_data=tested_case,
target_case_path=AUTO_CASE_DIR
)
else:
# 用例文件的直接父级目录不是DATA_DIR, 则保留其直接父级目录,再在AUTO_CASE_DIR下生成测试用例方法
gen_case_file(
filename=os.path.splitext(os.path.basename(file))[0] + "_" + _name,
case_template_path=CASE_TEMPLATE_DIR,
case_common=excel_case["case_common"],
case_data=tested_case,
target_case_path=os.path.join(AUTO_CASE_DIR,
get_relative_path(file_path=file, directory_path=DATA_DIR))
)
return True
else:
logger.error(f"{file}不是一个正确的文件路径!")
return False
def handle_yaml_data(file):
"""
读取yaml用例数据生成测试用例
"""
if os.path.isfile(file):
# 读取yaml/yml文件中的用例数据,存储到data中
yaml_data = YamlHandle(file).read_yaml
# 检查用例数据是否符合规范
tested_case = CaseDataCheck().case_process(yaml_data)
if os.path.samefile(DATA_DIR, os.path.dirname(file)):
gen_case_file(
# 用例文件的直接父级目录是DATA_DIR,则直接在AUTO_CASE_DIR下生成测试用例方法
filename=os.path.splitext(os.path.basename(file))[0],
case_template_path=CASE_TEMPLATE_DIR,
case_common=yaml_data["case_common"],
case_data=tested_case,
target_case_path=AUTO_CASE_DIR
)
else:
# 用例文件的直接父级目录不是DATA_DIR, 则保留其直接父级目录,再在AUTO_CASE_DIR下生成测试用例方法
os.makedirs(os.path.dirname(file), exist_ok=True)
gen_case_file(
filename=os.path.splitext(os.path.basename(file))[0],
case_template_path=CASE_TEMPLATE_DIR,
case_common=yaml_data["case_common"],
case_data=tested_case,
target_case_path=os.path.join(AUTO_CASE_DIR,
get_relative_path(file_path=file, directory_path=DATA_DIR))
)
return True
else:
logger.error(f"{file}不是一个正确的文件路径!")
return False
def generate_cases():
"""
根据配置文件,从指定类型文件中读取所有用例数据,并自动生成测试用例
"""
excel_files = []
yaml_files = []
if CASE_FILE_TYPE == CaseFileType.EXCEL.value:
# 在用例数据"DATA_DIR"目录中寻找后缀是xlsx, xls的文件
excel_files = get_files(target=DATA_DIR, start="test_", end=".xlsx") \
+ get_files(target=DATA_DIR, start="test_", end=".xls")
elif CASE_FILE_TYPE == CaseFileType.YAML.value:
# 在用例数据"DATA_DIR"目录中寻找后缀是yaml, yml的文件
yaml_files = get_files(target=DATA_DIR, start="test_", end=".yaml") \
+ get_files(target=DATA_DIR, start="test_", end=".yml")
elif CASE_FILE_TYPE == CaseFileType.ALL.value:
# 在用例数据"DATA_DIR"目录中寻找后缀是xlsx,xls, yaml, yml的文件
excel_files = get_files(target=DATA_DIR, start="test_", end=".xlsx") + get_files(target=DATA_DIR, start="test_",
end=".xls")
yaml_files = get_files(target=DATA_DIR, start="test_", end=".yaml") + get_files(target=DATA_DIR, start="test_",
end=".yml")
else:
logger.error(f"{CASE_FILE_TYPE}不在CaseFileType内,不能自动生成用例!")
# 自动生成测试用例
for file in excel_files:
handle_excel_data(file=file)
for file in yaml_files:
handle_yaml_data(file=file)
def gen_case_file(filename, case_template_path, case_common, case_data, target_case_path):
"""
根据测试用例文件(yaml/yml/xlsx/xls),以及事先定义的测试用例模板,实际用例数据,生成测试用例方法(.py)
:param filename: 测试用例文件(yaml/yml/xlsx/xls)的名称,用作生成测试用例类名,方法名
:param case_template_path: 测试用例模板的绝对路径
:param case_common: 用例公共参数
:param case_data: 实际用例数据
:param target_case_path: 测试用例方法(.py)的绝对路径
"""
# 如果自动生成用例的目录不存在则自动创建一个
if not os.path.exists(target_case_path):
"""
exist_ok=True 是一个可选参数,用于指定在目录已经存在的情况下是否忽略错误。
如果设置为 True,则不论目录是否已存在,os.makedirs 都不会报错;如果设置为 False(默认值),则在目录已存在时会引发 FileExistsError 异常。
"""
os.makedirs(target_case_path, exist_ok=True)
# 获取用例数据中的标记
case_markers = case_common.get("case_markers", []) or []
logger.trace(f"从用例中拿到的标记有:{case_markers} {type(case_markers)}")
# 先读取用例模板中每一行的内容
with open(file=case_template_path, mode="r", encoding="utf-8") as f:
case_template = f.readlines()
current_case_template = []
for line_num, content in enumerate(case_template):
current_case_template.append(content)
# 这里是预计往 @pytest.mark.parametrize( 这一行的上面插入标记
if content.strip().startswith('@pytest.mark.parametrize('):
# 往测试用例模板中插入自定义标记
logger.trace(f"获取到的case_markers{case_markers} {type(case_markers)}")
for case_marker in case_markers:
# 获取符合要求格式的自定义标记名称,并插入到测试模板中
marker = is_valid_marker(case_marker)
if marker and isinstance(marker, str):
# !! 注意这里的4个空格,必须要有4个空格!!
current_case_template.append(f" @pytest.mark.{marker}\n")
if marker and isinstance(marker, dict):
for k, v in marker.items():
# !! 注意这里的4个空格,必须要有4个空格!!
current_case_template.append(f" @pytest.mark.{k}('{v}')\n")
# 将用例数据的名称作为测试用例文件名称, 如test_login_demo
func_name = filename
# 方法名test_demo的类名是TestDemo
class_name = "".join([word.capitalize() for word in func_name.split("_")])
# 根据模板,生成测试用例方法
"""
string.Template是将一个string设置为模板,通过替换变量的方法,最终得到想要的string。
"""
current_template = ''.join(current_case_template)
my_case = Template(current_template).safe_substitute(
{
"allure_epic": case_common["allure_epic"],
"allure_feature": case_common["allure_feature"],
"allure_story": case_common["allure_story"],
"case_markers": case_markers,
"case_data": case_data,
"func_title": func_name,
"class_title": class_name,
"now": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
# 将测试用例方法写入py文件中
with open(os.path.join(target_case_path, func_name + '.py'), "w", encoding="utf-8") as fp:
fp.write(my_case)
def is_valid_marker(markers):
"""
检查标记名称是否合法:仅支持非数字/下划线开头,由数字,字母,下划线组成的标记名称
"""
pattern = r'^[a-zA-Z_][a-zA-Z0-9_]*$'
if isinstance(markers, str):
if re.match(pattern, markers):
# 将自定义标记放到CUSTOM_MARKERS, 方便后续统一注册
if markers not in ("skip", "skipif", "parametrize", "usefixtures", "xfail", "filterwarings"):
CUSTOM_MARKERS.append(markers)
# 返回合法有效的标记名称,用于添加到测试方法中
return markers
else:
logger.error(f"{markers} 格式不合法, 建议仅输入数字,字母,下划线组合,且不能以数字,下划线开头")
return False
elif isinstance(markers, dict):
if len(markers) == 1:
marker_name = list(markers.keys())[0]
if re.match(pattern, marker_name):
# 将自定义标记放到CUSTOM_MARKERS, 方便后续统一注册
if marker_name not in ("skip", "skipif", "parametrize", "usefixtures", "xfail", "filterwarings"):
CUSTOM_MARKERS.append(markers)
return markers
else:
logger.error(f"{markers} 格式不合法, 建议仅输入数字,字母,下划线组合,且不能以数字,下划线开头")
return None
else:
logger.error(f"{markers} 格式不合法, 只能存在一对键值对")
return None
else:
logger.error(f"{markers} 仅支持字符串或者字典格式")
return None
+397
View File
@@ -0,0 +1,397 @@
# -*- coding: utf-8 -*-
# @Time : 2023/9/21 17:20
# @Author : chenyinhua
# @File : data_handle.py
# @Software: PyCharm
# @Desc:
# 标准库导入
import random
import re, uuid
from datetime import datetime, date, timedelta
# 第三方库导入
from faker import Faker
from string import Template
class FakerData:
"""
测试数据生成类
"""
def __init__(self):
self.fk_zh = Faker(locale='zh_CN')
self.faker = Faker()
@classmethod
def generate_random_int(cls, *args) -> int:
"""
:return: 随机数
"""
# 检查是否传入了参数
if not args:
# 没有传参,就从5000内随机取一个整数返回
return random.randint(0, 5000)
# 排序参数并获取最小值和最大值
min_val = min(args)
max_val = max(args)
# 生成并返回随机整数
return random.randint(min_val, max_val)
def generate_phone(self, lan="en") -> int:
"""
:return: 随机生成手机号码
"""
if lan == "zh":
phone = self.fk_zh.phone_number()
else:
phone = self.faker.phone_number()
return phone
def generate_id_number(self, lan="en") -> int:
"""
:return: 随机生成身份证号码
"""
if lan == "zh":
id_number = self.fk_zh.ssn()
else:
id_number = self.faker.ssn()
return id_number
def generate_female_name(self, lan="en") -> str:
"""
:return: 女生姓名
"""
if lan == "zh":
female_name = self.fk_zh.name_female()
else:
female_name = self.faker.name_female()
return female_name
def generate_male_name(self, lan="en") -> str:
"""
:return: 男生姓名
"""
if lan == "zh":
male_name = self.fk_zh.name_male()
else:
male_name = self.faker.name_male()
return male_name
def generate_name(self, lan="en") -> str:
"""
:return: 人名
"""
if lan == "zh":
name = self.fk_zh.name()
else:
name = self.faker.name()
return name
def generate_email(self, lan="en") -> str:
"""
:return: 生成邮箱
"""
if lan == "zh":
email = self.fk_zh.email()
else:
email = self.faker.email()
return email
def generate_identifier(self, lan="en"):
"""
:return:生成随机标识,满足要求:长度为2~100, 只能包含数字,字母,下划线(_),中划线(-),英文句号(.),必须以数字和字母开头,不能以下划线/中划线/英文句号开头和结尾
"""
if lan == "zh":
fk = self.fk_zh
else:
fk = self.faker
while True:
identifier = fk.slug() # 生成随机的slug标识
if (
re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,98}[a-zA-Z0-9]$', identifier) and
not (identifier.startswith('_') or identifier.startswith('-') or identifier.startswith('.')) and
not (identifier.endswith('_') or identifier.endswith('.'))
):
return identifier
@classmethod
def generate_time(cls, fmt='%Y-%m-%d %H:%M:%S') -> str:
"""
计算当前时间
:return:
"""
now_time = datetime.now().strftime(fmt)
return now_time
@classmethod
def generate_today_date(cls):
"""获取今日0点整时间"""
_today = date.today().strftime("%Y-%m-%d") + " 00:00:00"
return str(_today)
@classmethod
def generate_time_after_week(cls):
"""获取一周后12点整的时间"""
_time_after_week = (date.today() + timedelta(days=+6)).strftime("%Y-%m-%d") + " 00:00:00"
return _time_after_week
@classmethod
def remove_special_characters(cls, target: str):
"""
移除字符串中的特殊字符。
在Python中用replace()函数操作指定字符
常用字符unicode的编码范围:
数字:\u0030-\u0039
汉字:\u4e00-\u9fa5
大写字母:\u0041-\u005a
小写字母:\u0061-\u007a
英文字母:\u0041-\u007a
"""
pattern = r'([^\u4e00-\u9fa5])'
result = re.sub(pattern, '', target)
return result
class DataHandle:
def __init__(self):
# 实例化FakerData类,避免反复实例,提高性能。
self.FakerDataClass = FakerData()
# 获取FakerData类所有自定义方法
self.method_list = [method for method in dir(FakerData) if
callable(getattr(FakerData, method)) and not method.startswith("__")]
# 将"[1,2,3]" 或者"{'k':'v'}" -> [1,2,3], {'k':'v'}
def eval_data(self, data):
"""
执行一个字符串表达式,并返回其表达式的值
"""
try:
if hasattr(eval(data), "__call__"):
return data
else:
return eval(data)
except Exception:
return data
def replace_and_store_placeholders(self, pattern, text):
"""
提取字符串中符合正则表达式的元素,同时用一个唯一的uuid来替换原有字符串
例如:
原字符串:user_id: ${user_id}, user_name: ${user_name}
替换后的原字符串:user_id: e1c6fc74-2f21-49a9-8d0c-de16650c6364, user_name: 50c74155-5cb5-4809-bc5d-277addf8c3e7
暂存的需要被处理的关键字或函数:{'e1c6fc74-2f21-49a9-8d0c-de16650c6364': {0: '${user_id}', 1: 'user_id'}, '50c74155-5cb5-4809-bc5d-277addf8c3e7': {0: '${user_name}', 1: 'user_name'}}
"""
placeholders = {}
def replace(match):
placeholder = str(uuid.uuid4()) # 使用uuid生成唯一的占位符
placeholders[placeholder] = {0: match.group(0), 1: match.group(1)} # 将提取到的字符串存储到字典中
return placeholder
# 使用正则表达式进行字符串匹配和替换,同时指定替换次数为 1
replaced_text = re.sub(pattern, replace, text, count=1)
while replaced_text != text:
text = replaced_text
replaced_text = re.sub(pattern, replace, text, count=1)
return replaced_text, placeholders
def data_handle(self, obj, source=None):
"""
递归处理字典、列表中的字符串,将${}占位符替换成source中的值
"""
func = {}
keys = {}
if not source or not isinstance(source, dict):
# print("source为空或者source不是字典格式,都将认为是:{}")
source = {}
# 如果进来的是字符串,先将各种类型的表达式处理完
if isinstance(obj, str):
# 先把python表达式找出来存着,这里会漏掉一些诸如1+1的表达式
pattern = r"\${([^}]+\))}" # 匹配以 "${" 开头、以 ")}" 结尾的字符串,并在括号内提取内容,括号内不能包含"}"字符
obj, func = self.replace_and_store_placeholders(pattern, obj)
# 再把关键字替换的找出来存着,这里会将1+1这样的表达式存起来
pattern = r'\$\{([^}]+)\}' # 定义匹配以"${"开头,"}"结尾的字符串的正则表达式
obj, keys = self.replace_and_store_placeholders(pattern, obj)
# 接着处理表达式和关键字替换,先进行关键字替换
keys = eval(Template(str(keys)).safe_substitute(source)) # 替换并转为字典
for key, value in keys.items(): # 遍历字典替换
obj = obj.replace(key, value[0])
# 再找一遍剩余的${}跟第一步的结果合并,提取漏掉的诸如1+1的表达式(在此认为关键字无法替换的都是表达式,最后表达式也无法处理的情况就报错或者原样返回)
obj, func_temp = self.replace_and_store_placeholders(pattern, obj)
func.update(func_temp)
# 进行函数调用替换
obj = self.invoke_funcs(obj, func)
# 把处理后的结果,eval一下
return self.eval_data(obj)
elif isinstance(obj, list):
for index, item in enumerate(obj):
obj[index] = self.data_handle(item, source)
return obj
elif isinstance(obj, dict):
for key, value in obj.items():
obj[key] = self.data_handle(value, source)
return obj
else:
return obj
def invoke_funcs(self, obj, funcs):
"""
调用方法,并将方法返回的结果替换到obj中去
"""
for key, funcs in funcs.items(): # 遍历方法字典调用并替换
func = funcs[1]
# print("invoke func : ", func)
try:
if "." in func:
if func.startswith("faker."):
# 英文的faker数据:self.faker = Faker()
faker = self.FakerDataClass.faker
obj = obj.replace(key, eval(func))
elif func.startswith("fk_zh."):
# 中文的faker数据: self.fk_zh = Faker(locale='zh_CN')
fk_zh = self.FakerDataClass.fk_zh
obj = obj.replace(key, eval(func))
else:
obj = obj.replace(key, str(eval(func)))
else:
func_parts = func.split('(')
func_name = func_parts[0]
func_args_str = ''.join(func_parts[1:])[:-1]
if func_name in self.method_list: # 证明是FakerData类方法
method = getattr(self.FakerDataClass, func_name)
res = eval(f"method({func_args_str})") # 尝试直接调用
obj = obj.replace(key, str(res))
else: # 不是FakerData类方法,但有可能是 1+1 这样的
obj = obj.replace(key, str(eval(func)))
except:
print("Warn: --------函数:%s 无法调用成功, 请检查是否存在该函数-------" % func)
obj = obj.replace(key, funcs[0])
pass
return obj
# 声明data_handle方法,这样外部就可以直接import data_handle来使用了
data_handle = DataHandle().data_handle
if __name__ == '__main__':
# 下面是测试代码
target = {
"user_id": 1,
"user_name": "flora",
"name": "test",
"age": 17
}
# 需要识别${python表达式},这里random方法是需要导入random包的
data_01 = "选择.gitignore: ${random.choice(['Ada', 'Actionscript', 'Ansible', 'Android', 'Agda'])},开源许可证: ${random.choice(['0BSD', 'AAL', 'AFL-1.1', '389-exception'])}"
# new = data_handle(data_01)
# print(new, type(new))
# 这个用到了target
data_02 = {
"age": "${generate_random_int()}.",
"message": "Hello, ${FakerData().generate_female_name()}! Your age is ${age}. Random number: ${FakerData().generate_random_int()}",
"nested_data": [
"This is ${name}'s data.",
{
"message": "Age: ${age}.",
"nested_list": [
"More data: ${generate_random_int()}",
]
}
]
}
# new = data_handle(data_02, target)
# print(new, type(new))
data_03 = "user_id: ${user_id}, user_name: ${user_name}"
# new = data_handle(data_03, target)
# print(new, type(new))
# 需要识别 字符串里面是python表达式的情况
data_04 = "[1,2,3,4]"
# new = data_handle(data_04)
# print(new, type(new))
data_05 = "1+1"
# new = data_handle(data_05)
# print(new, type(new))
data_06 = "[1, '1', [1, 2], {'name':'flora', 'age': '1'}]"
# new = data_handle(data_06)
# print(new, type(new))
# 需要识别自定义的函数,同时支持多种,下面两种写法有细微差别
data_07 = "Hello, ${generate_female_name()}! Random number: ${generate_random_int()}"
# new = data_handle(data_07)
# print(new, type(new))
data_08 = "Hello, ${generate_female_name()}! Random number: ${FakerData().generate_random_int()}"
# new = data_handle(data_08)
# print(new, type(new))
data_09 = "Hello, ${FakerData().generate_female_name()}! Random number: ${FakerData.generate_random_int()}"
# new = data_handle(data_09)
# print(new, type(new))
data_10 = {
"payload": {
"startTime": "${FakerData.generate_time('%Y-%m-%d')}",
"common2": "${faker.name()}", # 这里是使用类FakerData里面的实例属性faker
"url": "/api/accounts/${FakerData.generate_time('%Y-%m-%d')} / login.json",
"fragement": {
"startTime": "${FakerData.generate_time('%Y-%m-%d')}",
"common2": "${faker.name()}", # 这里是使用类FakerData里面的实例属性faker
"url": "/api/accounts/${FakerData.generate_time('%Y-%m-%d')} / login.json"
}
}
}
# new = data_handle(data_10)
# print(new, type(new))
data_11 = "/api/accounts/${FakerData.generate_time('%Y-%m-%d')}/login.json"
# new = data_handle(data_11)
# print(new, type(new))
# FakerData类中没有封装random_name这个方法,会无法处理
data_12 = '[[1,2,3,4],"${FakerData().random_name()}"]'
# new = data_handle(data_12)
# print(new, type(new))
# 下面这种写法不是字符串里面,不是正确格式的列表,${FakerData().generate_name()需要用引号包起来,因此无法正确处理成列表,最后返回的是str
data_13 = '[[1,2,3,4],${FakerData().generate_name()}]'
# new = data_handle(data_13)
# print(new, type(new))
data_15 = '[[1,2,3,4],${FakerData().generate_random_int()}]'
# new = data_handle(data_15)
# print(new, type(new))
# 导入其他方法,也可以直接使用
# from common_utils.time_handle import test_fun_a
# data = "${test_fun_a()}"
# new = data_handle(data)
# print(new, type(new))
# 支付方法传参使用
payload = {
"name": "${generate_name(lan='zh')}",
"repository_name": "${generate_name('zh')}",
"desc": '[[1,2,3,4],"${FakerData().generate_random_int()}"]',
"pre": '[[1,2,3,4],${FakerData().generate_name()}]'
}
# new = data_handle(payload)
# print(new, type(new))
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# @Time : 2023/6/28 14:35
# @Author : chenyinhua
# @File : extract_data_handle.py
# @Software: PyCharm
# @Desc: 提取数据的一些方法
# 标准库导入
import re
# 第三方库导入
from jsonpath import jsonpath
from loguru import logger
def json_extractor(obj: dict, expr: str = '.'):
"""
:param obj :json/dict类型数据
:param expr: 表达式, . 提取字典所有内容, $.test_api_case 提取一级字典case $.test_api_case.data 提取case字典下的data
:return result: 提取的结果,未提取到返回 None
"""
try:
result = jsonpath(obj, expr)[0]
logger.trace("\n======================================================\n" \
"-------------Startjson_extractor--------------------\n"
f"提取表达式为: {expr} \n"
f"提取值为: {result}\n"
"=====================================================")
except Exception as e:
logger.trace("\n======================================================\n" \
"-------------Endjson_extractor--------------------\n"
f"提取表达式为: {expr}\n"
f"提取数据为: {obj}\n"
f"错误信息为:{e}\n"
"=====================================================")
result = None
return result
def re_extract(obj: str, expr: str = '.'):
"""
:param obj : 字符串数据
:param expr: 正则表达式
:return result: 提取的结果,未提取到返回 None
"""
try:
result = re.findall(expr, obj)[0]
logger.trace("\n======================================================\n" \
"-------------Startre_extract--------------------\n"
f"提取表达式为: {expr}\n" \
f"提取值为: {result}\n" \
"=====================================================")
except Exception as e:
logger.trace(f"\n======================================================\n" \
"-------------Endre_extract--------------------\n"
f"提取表达式为: {expr}\n" \
f"提取数据为: {obj}\n" \
f"错误信息为:{e}\n" \
"=====================================================")
result = None
return result
@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 14:31
# @Author : chenyinhua
# @File : request_data_handle.py
# @Software: PyCharm
# @Desc: 处理request请求前后的用例数据
# 标准库导入
import json
import os
import http.cookiejar
# 第三方库导入
from requests import Response
from loguru import logger
# 本地应用/模块导入
from common_utils.other_utils.files_handle import get_file_field
from case_utils.requests_utils.base_request import BaseRequest
from case_utils.requests_utils.data_handle import data_handle
from case_utils.requests_utils.extract_data_handle import json_extractor, re_extract
from case_utils.report_utils.allure_handle import allure_step
from config.global_vars import GLOBAL_VARS
from config.path_config import FILES_DIR
# ---------------------------------------- 请求前的数据处理----------------------------------------#
class RequestPreDataHandle:
"""
请求前处理用例数据
"""
def __init__(self, request_data):
logger.trace(f"\n======================================================\n" \
"-------------Start:处理用例数据前--------------------\n"
f"用例标题(title): {type(request_data.get('title', None))} || {request_data.get('title', None)}\n" \
f"用例优先级(severity): {type(request_data.get('severity', None))} || {request_data.get('severity', None)}\n" \
f"请求域名(host): {type(request_data.get('host', None))} || {request_data.get('host', None)}\n" \
f"请求路径(url): {type(request_data.get('url', None))} || {request_data.get('url', None)}\n" \
f"请求方式(method): {type(request_data.get('method', None))} || {request_data.get('method', None)}\n" \
f"请求头(headers): {type(request_data.get('headers', None))} || {request_data.get('headers', None)}\n" \
f"请求cookies: {type(request_data.get('cookies', None))} || {request_data.get('cookies', None)}\n" \
f"请求类型(request_type): {type(request_data.get('request_type', None))} || {request_data.get('request_type', None)}\n" \
f"请求参数(payload): {type(request_data.get('payload', None))} || {request_data.get('payload', None)}\n" \
f"请求文件(files): {type(request_data.get('files', None))} || {request_data.get('files', None)}\n" \
f"后置提取参数(extract): {type(request_data.get('extract', None))} || {request_data.get('extract', None)}\n" \
f"响应断言(assert_response): {type(request_data.get('assert_response', None))} || {request_data.get('assert_response', None)}\n" \
f"数据库断言(assert_sql): {type(request_data.get('assert_sql', None))} || {request_data.get('assert_sql', None)}\n" \
"=====================================================")
self.request_data = request_data
def request_data_handle(self):
"""
针对用例数据进行处理,识别用例数据中的关键字${xxxx},使用全局变量进行替换或者执行关键字中的方法替换为具体值
"""
self.url_handle()
self.method_handle()
self.headers_handle()
self.cookies_handle()
self.payload_handle()
self.files_handle()
self.assert_handle()
logger.trace(f"\n======================================================\n" \
"-------------End:处理用例数据后--------------------\n"
f"用例标题(title): {type(self.request_data.get('title', None))} || {self.request_data.get('title', None)}\n" \
f"用例优先级(severity): {type(self.request_data.get('severity', None))} || {self.request_data.get('severity', None)}\n" \
f"请求路径(url): {type(self.request_data.get('url', None))} || {self.request_data.get('url', None)}\n" \
f"请求方式(method): {type(self.request_data.get('method', None))} || {self.request_data.get('method', None)}\n" \
f"请求头(headers): {type(self.request_data.get('headers', None))} || {self.request_data.get('headers', None)}\n" \
f"请求cookies: {type(self.request_data.get('cookies', None))} || {self.request_data.get('cookies', None)}\n" \
f"请求类型(request_type): {type(self.request_data.get('request_type', None))} || {self.request_data.get('request_type', None)}\n" \
f"请求参数(payload): {type(self.request_data.get('payload', None))} || {self.request_data.get('payload', None)}\n" \
f"请求文件(files): {type(self.request_data.get('files', None))} || {self.request_data.get('files', None)}\n" \
f"后置提取参数(extract): {type(self.request_data.get('extract', None))} || {self.request_data.get('extract', None)}\n" \
f"响应断言(assert_response): {type(self.request_data.get('assert_response', None))} || {self.request_data.get('assert_response', None)}\n" \
f"数据库断言(assert_sql): {type(self.request_data.get('assert_sql', None))} || {self.request_data.get('assert_sql', None)}\n" \
"=====================================================")
return self.request_data
def url_handle(self):
"""
用例数据中获取到的url(一般是不带host的,个别特殊的带有host,则不进行处理)
"""
# 检测url中是否存在需要替换的参数,如果存在则进行替换
url = data_handle(obj=self.request_data.get("url", None), source=GLOBAL_VARS)
# 进行url处理,最终得到full_url
host = GLOBAL_VARS.get("host", "")
# 从用例数据中获取url,如果键url不存在,则返回空字符串
# 如果url是以http开头的,则直接使用该url,不与host进行拼接
if url.lower().startswith("http"):
full_url = url
else:
# 如果host以/结尾 并且 url以/开头
if host.endswith("/") and url.startswith("/"):
full_url = host[0:len(host) - 1] + url
# 如果host以/结尾 并且 url不以/开头
elif host.endswith("/") and (not url.startswith("/")):
full_url = host + url
elif (not host.endswith("/")) and url.startswith("/"):
# 如果host不以/结尾 且 url以/开头,则将host和url拼接起来,组成新的url
full_url = host + url
else:
# 如果host不以/结尾 且 url不以/开头,则将host和url拼接起来的时候增加/,组成新的url
full_url = host + "/" + url
self.request_data["url"] = full_url
def method_handle(self):
# TODO 暂时不需要处理,后续有需要在处理
pass
def cookies_handle(self):
"""
requests模块中,cookies参数要求是Dict or CookieJar object
"""
cookies = self.request_data.get("cookies", None)
# 从用例数据中获取cookies 处理cookies
if cookies:
logger.debug(f"打印一下全局变量的值:{GLOBAL_VARS}")
# 通过全局变量替换cookies,得到的是一个str类型
cookies = data_handle(obj=cookies, source=GLOBAL_VARS)
try:
cookies = json.loads(cookies)
except Exception as e:
cookies = cookies
logger.debug(f"处理{cookies}报错了:{e}")
if isinstance(cookies, dict) or isinstance(cookies, http.cookiejar.CookieJar):
self.request_data["cookies"] = cookies
else:
logger.error(
f"cookies参数要求是Dict or CookieJar object 目前cookies类型是:{type(cookies)} cookies值是:{cookies}")
raise TypeError(
f"cookies参数要求是Dict or CookieJar object 目前cookies类型是:{type(cookies)} cookies值是:{cookies}")
def headers_handle(self):
"""
headers里面传cookies,要求cookies类型是str
"""
headers = self.request_data.get("headers", None)
# 从用例数据中获取header 处理header
if headers:
self.request_data["headers"] = data_handle(obj=headers, source=GLOBAL_VARS)
# 如果请求头中有cookies,需要进行单独处理
if self.request_data["headers"].get("cookies", None):
cookies = self.request_data["headers"]["cookies"]
if isinstance(cookies, dict):
# 如果是字典类型,就转成字符串
self.request_data["headers"]["cookies"] = json.dumps(cookies)
else:
self.request_data["headers"]["cookies"] = cookies
def payload_handle(self):
# 处理请求参数payload
payload = self.request_data.get("payload", None)
if payload:
self.request_data["payload"] = data_handle(obj=payload, source=GLOBAL_VARS)
def files_handle(self):
"""
格式:接口中文件参数的名称:"文件路径地址"/["文件地址1", "文件地址2"]
例如:{"file": "demo_test_demo.py"}
或者
{"file": ["test_demo_01.py", "test_demo_02.py"]}
"""
# 处理请求参数files参数
files = self.request_data.get("files", None)
if files:
for k, v in files.items():
# ------------------ 单文件 ------------------
# 处理文件绝对路径
file_path = os.path.join(FILES_DIR, v)
# 单文件上传需要是字典{'file': (filename, file_content)}
self.request_data["files"] = {k: get_file_field(file_path)}
logger.debug(f"处理完成后的files:{self.request_data['files']}")
def assert_handle(self):
# 处理响应断言参数
assert_response = self.request_data.get("assert_response", None)
if assert_response:
self.request_data["assert_response"] = data_handle(obj=assert_response, source=GLOBAL_VARS)
# 由于数据库断言里面的变量需要请求响应后进行提取,因此目前不进行处理
# ---------------------------------------- 进行请求,请求后的参数提取处理----------------------------------------#
class RequestHandle:
"""
进行请求,请求后的参数提取处理
"""
def __init__(self, case_data):
self.case_data = case_data
def http_request(self):
"""
发送请求并进行后置参数提取操作
"""
response = BaseRequest.send_request(self.case_data)
allure_step(f"响应数据: {response.text}")
allure_step(f"响应码: {response.status_code}")
allure_step(
f"响应耗时: {round(response.elapsed.total_seconds(), 2)} s || {round(response.elapsed.total_seconds() * 1000, 2)} ms")
# 处理数据库断言 - 从全局变量中获取最新值,替换数据库断言中的参数
if self.case_data.get('assert_sql', None):
self.case_data["assert_sql"] = data_handle(obj=self.case_data["assert_sql"], source=GLOBAL_VARS)
# 处理请求里面的files,使得日志以及allure中写入的是文件,而不是文件二进制内容
if self.case_data.get('files', None):
files = self.case_data["files"]
if isinstance(files, dict):
dict_values = list(files.values())[0]
_file = os.path.join(FILES_DIR, dict_values[0])
logger.info(
f"\n请求文件(files): {type(_file)} || {_file}\n")
return response
# ---------------------------------------- 请求后的参数提取处理----------------------------------------#
def after_request_extract(response: Response, extract):
"""
从响应数据中提取请求后的参数,并保存到全局变量中
:param response: request 响应对象
:param extract: 需要提取的参数字典 '{"k1": "$.data"}''{"k1": "data:(.*?)$"}'
:return:
"""
logger.debug(f"\n======================================================\n" \
"-------------Start:从响应数据中提取后置参数保存到全局变量--------------------\n"
f"后置提取参数(原): {extract}\n" \
"=====================================================")
result = {}
if extract:
if response_type(response) == "json":
# 如果响应数据是json格式,则将按照json方式对后置提取参数进行处理
res = response.json()
for k, v in extract.items():
result[k] = json_extractor(res, v)
else:
# 如果响应数据是str格式,则将按照str方式对后置提取参数进行处理
res = response.text
for k, v in extract.items():
result[k] = re_extract(res, v)
logger.debug(f"\n======================================================\n" \
"-------------End:从响应数据中提取后置参数保存到全局变量--------------------\n"
f"后置提取参数(新): {result}\n" \
"=====================================================")
# 将提取到的变量保存在全局变量中
if result:
for k, v in result.items():
GLOBAL_VARS[k] = v
return result
def response_type(response: Response) -> str:
"""
:param response: requests 返回
:return: 返回响应数据类型 json或者str
"""
try:
response.json()
return "json"
except:
return "str"