338 lines
18 KiB
Python
338 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
# @Time : 2023/6/7 10:07
|
||
# @Author : chenyinhua
|
||
# @File : case_fun_generate.py
|
||
# @Software: PyCharm
|
||
# @Desc:
|
||
# 标准库导入
|
||
import os
|
||
from string import Template
|
||
from datetime import datetime, timezone
|
||
import re
|
||
# 第三方库导入
|
||
from loguru import logger
|
||
# 本地应用/模块导入
|
||
from common_utils.files_utils.files_handle import load_yaml_file, get_files, get_relative_path
|
||
from settings import CASE_FILE_TYPE, CUSTOM_MARKERS, AUTO_CASE_DIR, INTERFACE_DIR
|
||
from custom_utils.case_generate_utils.case_data_analysis import CaseDataCheck, CaseCheckException
|
||
|
||
"""
|
||
主要步骤:
|
||
1. 从用例数据文件(EXCEL/YAML)中获取用例数据
|
||
2. 分析用例数据是否符合规范
|
||
3. 确认符合规范后,获取所有用例数据,自动生成测试用例方法(PY)
|
||
"""
|
||
|
||
CASE_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "case_template.txt")
|
||
CONFTEST_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "conftest_template.txt")
|
||
|
||
|
||
def __load_yaml_data(file):
|
||
"""
|
||
读取yaml用例数据生成测试用例
|
||
"""
|
||
if os.path.isfile(file):
|
||
try:
|
||
# 读取yaml/yml文件中的用例数据,存储到data中
|
||
yaml_data = load_yaml_file(file)
|
||
logger.trace(f"需要处理的文件:{file}")
|
||
except Exception as e:
|
||
logger.error(f"读取YAML文件 {file} 失败: {str(e)}")
|
||
raise
|
||
|
||
if os.path.samefile(INTERFACE_DIR, os.path.dirname(file)):
|
||
"""# os.path.samefile是 Python 中 os.path 模块提供的一个函数,用于检查两个文件路径是否指向同一个文件。"""
|
||
if os.path.basename(file) == "init_data.yaml" or os.path.basename(file) == "init_data.yml":
|
||
"""识别到init_data.yaml或者init_data.yml文件,自动生成conftest.py文件"""
|
||
os.makedirs(os.path.dirname(file), exist_ok=True)
|
||
logger.trace(f"识别到init_data.yaml或者init_data.yml文件,自动生成conftest.py文件")
|
||
generate_conftest_file(
|
||
template_path=CONFTEST_TEMPLATE_DIR,
|
||
init_data=yaml_data,
|
||
target_path=AUTO_CASE_DIR
|
||
)
|
||
elif os.path.basename(file).startswith("test"):
|
||
try:
|
||
# 检查用例数据是否符合规范
|
||
tested_case = CaseDataCheck().case_process(yaml_data)
|
||
gen_case_file(
|
||
# 用例文件的直接父级目录是INTERFACE_DIR,则直接在AUTO_CASE_DIR下生成测试用例方法
|
||
filename=os.path.splitext(os.path.basename(file))[0],
|
||
case_template_path=CASE_TEMPLATE_DIR,
|
||
config=yaml_data["config"],
|
||
common_dependence=yaml_data.get("common_dependence", None),
|
||
case_data=tested_case,
|
||
target_case_path=AUTO_CASE_DIR
|
||
)
|
||
except CaseCheckException as e:
|
||
logger.error(f"用例检查失败:{str(e)}")
|
||
raise # 继续向上传递异常
|
||
else:
|
||
logger.error(f"{file}不是以init_data或者test开头的文件")
|
||
else:
|
||
# 用例文件的直接父级目录不是INTERFACE_DIR, 则保留其直接父级目录,再在AUTO_CASE_DIR下生成测试用例方法
|
||
if os.path.basename(file) == "init_data.yaml" or os.path.basename(file) == "init_data.yml":
|
||
"""识别到init_data.yaml或者init_data.yml文件,自动生成conftest.py文件"""
|
||
os.makedirs(os.path.dirname(file), exist_ok=True)
|
||
logger.trace(f"识别到init_data.yaml或者init_data.yml文件,自动生成conftest.py文件")
|
||
generate_conftest_file(
|
||
template_path=CONFTEST_TEMPLATE_DIR,
|
||
init_data=yaml_data,
|
||
target_path=os.path.join(AUTO_CASE_DIR,
|
||
get_relative_path(file_path=file, directory_path=INTERFACE_DIR))
|
||
)
|
||
|
||
elif os.path.basename(file).startswith("test"):
|
||
# 检查用例数据是否符合规范
|
||
tested_case = CaseDataCheck().case_process(yaml_data)
|
||
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,
|
||
config=yaml_data["config"],
|
||
common_dependence=yaml_data.get("common_dependence", None),
|
||
case_data=tested_case,
|
||
target_case_path=os.path.join(AUTO_CASE_DIR,
|
||
get_relative_path(file_path=file, directory_path=INTERFACE_DIR))
|
||
)
|
||
else:
|
||
logger.error(f"{file}不是以init_data或者test开头的文件")
|
||
return True
|
||
else:
|
||
logger.error(f"{file}不是一个正确的文件路径!")
|
||
return False
|
||
|
||
|
||
def generate_cases():
|
||
"""
|
||
根据配置文件,从指定类型文件中读取所有用例数据,并自动生成测试用例
|
||
"""
|
||
yaml_files = []
|
||
try:
|
||
if CASE_FILE_TYPE == 1:
|
||
# 在用例数据"INTERFACE_DIR"目录中寻找后缀是yaml, yml的文件
|
||
yaml_files = get_files(target=INTERFACE_DIR, start="test_", end=".yaml") \
|
||
+ get_files(target=INTERFACE_DIR, start="test_", end=".yml") \
|
||
+ get_files(target=INTERFACE_DIR, start="init_data", end=".yml") \
|
||
+ get_files(target=INTERFACE_DIR, start="init_data", end=".yaml")
|
||
else:
|
||
logger.error(f"{CASE_FILE_TYPE}不在CaseFileType内,不能自动生成用例!")
|
||
# 自动生成测试用例
|
||
for file in yaml_files:
|
||
try:
|
||
logger.trace(f"正在处理文件: {file}")
|
||
__load_yaml_data(file=file)
|
||
except Exception as e:
|
||
logger.error(f"自动生成测试用例时发生错误, 用例文件:{file} | 错误信息: {str(e)}")
|
||
except Exception as e:
|
||
logger.error(f"获取yaml文件列表时发生错误: {str(e)}")
|
||
|
||
|
||
def generate_conftest_file(init_data, template_path, target_path):
|
||
"""
|
||
识别到目录中的init_data.yaml或者init_data.yml文件,自动生成conftest.py文件
|
||
:param init_data: 需要初始化的数据
|
||
:param template_path: 模板的绝对路径
|
||
:param target_path: conftest.py的需要生成的目录
|
||
"""
|
||
try:
|
||
# 如果自动生成用例的目录不存在则自动创建一个
|
||
"""
|
||
exist_ok=True 是一个可选参数,用于指定在目录已经存在的情况下是否忽略错误。
|
||
如果设置为 True,则不论目录是否已存在,os.makedirs 都不会报错;如果设置为 False(默认值),则在目录已存在时会引发 FileExistsError 异常。
|
||
"""
|
||
os.makedirs(target_path, exist_ok=True)
|
||
# 先读取用例模板中每一行的内容
|
||
with open(file=template_path, mode="r", encoding="utf-8") as f:
|
||
current_template = ''.join(f.readlines())
|
||
|
||
# 根据模板,生成测试用例方法
|
||
"""
|
||
string.Template是将一个string设置为模板,通过替换变量的方法,最终得到想要的string。
|
||
"""
|
||
conftest_content = Template(current_template).safe_substitute(
|
||
{
|
||
"init_data": init_data,
|
||
}
|
||
)
|
||
# 基于模板生成conftest.py文件
|
||
filepath = os.path.join(target_path, 'conftest.py')
|
||
with open(filepath, "w", encoding="utf-8") as fp:
|
||
fp.write(conftest_content)
|
||
logger.trace(f"conftest.py文件创建成功:{filepath}")
|
||
except Exception as e:
|
||
logger.error(f"生成conftest.py文件时发生错误: {e}")
|
||
|
||
|
||
def gen_case_file(filename, case_template_path, config, common_dependence, case_data, target_case_path):
|
||
"""
|
||
根据测试用例文件(yaml/yml),以及事先定义的测试用例模板,实际用例数据,生成测试用例方法(.py)
|
||
:param filename: 测试用例文件(yaml/yml)的名称,用作生成测试用例类名,方法名
|
||
:param case_template_path: 测试用例模板的绝对路径
|
||
:param config: 用例公共参数
|
||
:param common_dependence: 用例公共依赖
|
||
:param case_data: 实际用例数据
|
||
:param target_case_path: 测试用例方法(.py)的需要生成的目录
|
||
"""
|
||
logger.trace(f"开始处理用例: {filename}")
|
||
try:
|
||
# 验证必要的配置项
|
||
if not config:
|
||
raise ValueError(f"用例 {filename} 缺少config配置")
|
||
required_fields = ['epic', 'feature', 'story']
|
||
missing_fields = [field for field in required_fields if field not in config]
|
||
if missing_fields:
|
||
raise ValueError(f"用例 {filename} 的config中缺少必要字段: {', '.join(missing_fields)}")
|
||
# 如果自动生成用例的目录不存在则自动创建一个
|
||
if not os.path.exists(target_case_path):
|
||
"""
|
||
exist_ok=True 是一个可选参数,用于指定在目录已经存在的情况下是否忽略错误。
|
||
如果设置为 True,则不论目录是否已存在,os.makedirs 都不会报错;如果设置为 False(默认值),则在目录已存在时会引发 FileExistsError 异常。
|
||
"""
|
||
os.makedirs(target_case_path, exist_ok=True)
|
||
# 获取用例数据中的标记
|
||
pytest_markers = config.get("pytest_markers", []) or []
|
||
logger.trace(f"用例 {filename} 的标记: {pytest_markers}")
|
||
|
||
try:
|
||
# 读取用例模板
|
||
with open(file=case_template_path, mode="r", encoding="utf-8") as f:
|
||
case_template = f.readlines()
|
||
except Exception as e:
|
||
logger.error(f"读取模板文件 {case_template_path} 失败: {str(e)}")
|
||
raise
|
||
|
||
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"获取到的pytest_markers:{pytest_markers}, {type(pytest_markers)}")
|
||
for case_marker in pytest_markers:
|
||
# 获取符合要求格式的自定义标记名称,并插入到测试模板中
|
||
marker = is_valid_marker(case_marker)
|
||
if marker and isinstance(marker, str):
|
||
# !! 注意这里的4个空格,必须要有4个空格!!
|
||
# current_case_template.append(f" @pytest.mark.{marker}\n")
|
||
# 由于在测试用例中移除了测试类,直接使用测试方法,因此此处不需要空格了,可以直接顶格写
|
||
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")
|
||
# 由于在测试用例中移除了测试类,直接使用测试方法,因此此处不需要空格了,可以直接顶格写
|
||
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("_")])
|
||
|
||
if common_dependence:
|
||
# 如果存在公共依赖,则对公共依赖进行处理: 识别到模板中的关键字“公共依赖”,往下一行插入pytest的fixture,作用域级别是class
|
||
keyword = "公共依赖"
|
||
indices = [i for i, x in enumerate(current_case_template) if re.search(keyword, x, re.IGNORECASE)]
|
||
common_dependence_template = ['common_dependence = ${common_dependence}\n', '\n', '\n',
|
||
'@pytest.fixture(scope="class", autouse=True)\n',
|
||
'def background():\n',
|
||
' dependence_results = dependence_handler.case_dependence_handle(case_dependence=common_dependence.get("setup"), db_info=GLOBAL_VARS["db_info"])\n',
|
||
' GLOBAL_VARS.update(dependence_results if dependence_results else {})\n',
|
||
' yield\n',
|
||
' dependence_results = dependence_handler.case_dependence_handle(case_dependence=common_dependence.get("teardown"), db_info=GLOBAL_VARS["db_info"])\n',
|
||
' GLOBAL_VARS.update(dependence_results if dependence_results else {})\n',
|
||
'\n', '\n']
|
||
|
||
for idx in reversed(indices):
|
||
current_case_template[idx + 1:idx + 1] = common_dependence_template
|
||
# 根据模板,生成测试用例方法
|
||
"""
|
||
string.Template是将一个string设置为模板,通过替换变量的方法,最终得到想要的string。
|
||
"""
|
||
current_template = ''.join(current_case_template)
|
||
my_case = Template(current_template).safe_substitute(
|
||
{
|
||
"epic": config["epic"],
|
||
"feature": config["feature"],
|
||
"story": config["story"],
|
||
"pytest_markers": pytest_markers,
|
||
"common_dependence": common_dependence,
|
||
"case_data": case_data,
|
||
"func_title": func_name,
|
||
# 在测试用例中移除了测试类,直接使用测试方法
|
||
# "class_title": class_name,
|
||
"now": datetime.now(tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||
}
|
||
)
|
||
else:
|
||
# 根据模板,生成测试用例方法
|
||
"""
|
||
string.Template是将一个string设置为模板,通过替换变量的方法,最终得到想要的string。
|
||
"""
|
||
current_template = ''.join(current_case_template)
|
||
my_case = Template(current_template).safe_substitute(
|
||
{
|
||
"epic": config["epic"],
|
||
"feature": config["feature"],
|
||
"story": config["story"],
|
||
"pytest_markers": pytest_markers,
|
||
"case_data": case_data,
|
||
"func_title": func_name,
|
||
# 在测试用例中移除了测试类,直接使用测试方法
|
||
# "class_title": class_name,
|
||
"now": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
}
|
||
)
|
||
|
||
try:
|
||
# 将测试用例方法写入py文件中
|
||
output_file = os.path.join(target_case_path, func_name + '.py')
|
||
with open(output_file, "w", encoding="utf-8") as fp:
|
||
fp.write(my_case)
|
||
logger.trace(f"成功生成测试用例文件: {output_file}")
|
||
except Exception as e:
|
||
logger.error(f"写入测试用例文件 {func_name}.py 失败: {str(e)}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"处理用例 {filename} 时发生错误: {str(e)}\n用例数据: {case_data}")
|
||
|
||
|
||
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
|
||
elif "repeat" in markers:
|
||
if re.match(r'repeat\(\d+\)', markers):
|
||
return markers
|
||
else:
|
||
logger.error(f"{markers} 格式不合法, 正确格式参考:repeat(2)")
|
||
return False
|
||
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
|