当终端输入httprunner make 目录/文件名,则调用main_make来生成py文件格式的测试用例
对于tests_path中的路径,首先进行路径兼容。
如果不是绝对路径,则转换为绝对路径。
然后调用__make()函数,将测试用例文件转为可运行的pytest文件。
def main_make(tests_paths: List[Text]):
if not tests_paths:
return []
ga_client.track_event("ConvertTests", "hmake")
for tests_path in tests_paths:
tests_path = ensure_path_sep(tests_path)#路径兼容
if not os.path.isabs(tests_path): #不是绝对路径
tests_path = os.path.join(os.getcwd(), tests_path) #转换为绝对路径
try:
__make(tests_path)
except exceptions.MyBaseError as ex:
logger.error(ex)
sys.exit(1)
#格式化pytest文件
pytest_files_format_list = pytest_files_made_cache_mapping.keys()
format_pytest_with_black(*pytest_files_format_list)
return list(pytest_files_run_set)
pytest_files_format_list返回py文件列表,它的类型是
如:
dict_keys(['D:\\Project\\demo\\testcases\\login_test.py'])
dict.keys()方法是Python的字典方法,它将字典中的所有键组成一个可迭代序列并返回。
(12条消息) Python dict keys方法:获取字典中键的序列_TCatTime的博客-CSDN博客
>>> test_dict = {'Xi\'an':'Shaanxi', 'Yinchuan':'Ningxia'}
>>> test_dict
{"Xi'an": 'Shaanxi', 'Yinchuan': 'Ningxia'}
>>> test_dict.keys()
dict_keys(["Xi'an", 'Yinchuan'])
>>> type(test_dict.keys())
*pytest_files_format_list 输出的是dict_keys里单独的值如D:\Project\demo\testcases\login_test.py D:\Project\demo\testcases\query_test.py
def format_pytest_with_black(*python_paths: Text):
logger.info("format pytest cases with black ...")
try:
if is_support_multiprocessing() or len(python_paths) <= 1:
subprocess.run(["black", *python_paths])
else:
logger.warning(
"this system does not support multiprocessing well, format files one by one ..."
)
[subprocess.run(["black", path]) for path in python_paths]
except subprocess.CalledProcessError as ex:
capture_exception(ex)
logger.error(ex)
sys.exit(1)
except OSError:
err_msg = """
missing dependency tool: black
install black manually and try again:
$ pip install black
"""
logger.error(err_msg)
sys.exit(1)
首先判断系统是否支持多线程 或者需要格式化的文件只有1个,如果是的话,调用
subprocess.run(["black", *python_paths]),否则,就分别拿出路径中的文件run
这个函数最终返回的,是整合后的py文件列表
return list(pytest_files_run_set)
传入目录或者文件列表,输出 pytest_files_run_set,它是py文件的集合。
def __make(tests_path: Text):
""" make testcase(s) with testcase/testsuite/folder absolute path
generated pytest file path will be cached in pytest_files_made_cache_mapping
Args:
tests_path: should be in absolute path
"""
logger.info(f"make path: {tests_path}")
test_files = []
if os.path.isdir(tests_path):#如果是目录,则先load
files_list = load_folder_files(tests_path)
test_files.extend(files_list)
elif os.path.isfile(tests_path): #如果是文件,直接加入test_files
test_files.append(tests_path)
else:
raise exceptions.TestcaseNotFound(f"Invalid tests path: {tests_path}")
for test_file in test_files:
if test_file.lower().endswith("_test.py"):#文件以_test.py结尾,说明是py文件,不用转换,直接加入py文件集
pytest_files_run_set.add(test_file)
continue
try:
test_content = load_test_file(test_file) #加载测试用例文件,返回的是json/yaml文件中的内容
except (exceptions.FileNotFound, exceptions.FileFormatError) as ex:
logger.warning(f"Invalid test file: {test_file}\n{type(ex).__name__}: {ex}")
continue
if not isinstance(test_content, Dict):#判断内容是否为dict
logger.warning(
f"Invalid test file: {test_file}\n"
f"reason: test content not in dict format."
)
continue
# api in v2 format, convert to v3 testcase
if "request" in test_content and "name" in test_content:#httprunner2版本用例转为3版本用例
test_content = ensure_testcase_v3_api(test_content)
if "config" not in test_content:#config为必须配置。
logger.warning(
f"Invalid testcase/testsuite file: {test_file}\n"
f"reason: missing config part."
)
continue
elif not isinstance(test_content["config"], Dict):#校验config配置,不是dict说明是错误的
logger.warning(
f"Invalid testcase/testsuite file: {test_file}\n"
f"reason: config should be dict type, got {test_content['config']}"
)
continue
# ensure path absolute
test_content.setdefault("config", {})["path"] = test_file #设置一个path
# testcase
if "teststeps" in test_content:
try:
testcase_pytest_path = make_testcase(test_content)#maketestcase
pytest_files_run_set.add(testcase_pytest_path) #将结果存入py文件集合。
except exceptions.TestCaseFormatError as ex:
logger.warning(
f"Invalid testcase file: {test_file}\n{type(ex).__name__}: {ex}"
)
continue
# testsuite
elif "testcases" in test_content: #在测试步骤中调用了其他文件,则调用make_testsuite
try:
make_testsuite(test_content)
except exceptions.TestSuiteFormatError as ex:
logger.warning(
f"Invalid testsuite file: {test_file}\n{type(ex).__name__}: {ex}"
)
continue
# invalid format
else:
logger.warning(
f"Invalid test file: {test_file}\n"
f"reason: file content is neither testcase nor testsuite"
)
__make函数内容实现的是传入目录或者文件列表,输出 pytest_files_run_set,它是py文件的集合。
""" save generated pytest files to run, except referenced testcase """ pytest_files_run_set: Set = set()可以看到它是一个集合,保存产生的pytest文件,但是未包含那些引用的用例文件。
解析yaml/json文件时,如果有teststep,则执行如下两行制作py文件
testcase_pytest_path = make_testcase(test_content)#maketestcase pytest_files_run_set.add(testcase_pytest_path) #将结果存入py文件集合。如果有测试套件,则执行如下制作py文件(结果未存入pytest_files_run_set)
make_testsuite(test_content)
该函数用来制作测试用例。
def make_testcase(testcase: Dict, dir_path: Text = None):
"""convert valid testcase dict to pytest file path"""
# ensure compatibility with testcase format v2
testcase = ensure_testcase_v3(testcase)
# validate testcase format
load_testcase(testcase)#校验用例格式
testcase_abs_path = __ensure_absolute(testcase["config"]["path"])#获取用例绝对路径
logger.info(f"start to make testcase: {testcase_abs_path}")
testcase_python_abs_path, testcase_cls_name = convert_testcase_path(
testcase_abs_path
)#获取pytest文件的路径
if dir_path:
testcase_python_abs_path = os.path.join(
dir_path, os.path.basename(testcase_python_abs_path)
)#输出pytest文件的路径
global pytest_files_made_cache_mapping #全局变量
if testcase_python_abs_path in pytest_files_made_cache_mapping:
return testcase_python_abs_path #如果该pytest文件已经在缓存中,则直接返回路径
config = testcase["config"]
config["path"] = convert_relative_project_root_dir(testcase_python_abs_path)
config["variables"] = convert_variables(
config.get("variables", {}), testcase_abs_path
)#config的变量
# prepare reference testcase
imports_list = []
teststeps = testcase["teststeps"]#摘出每个步骤的teststep
for teststep in teststeps:
if not teststep.get("testcase"):#如果步骤中有testcase关键字,则会把它引用的文件给读出来。
continue
# make ref testcase pytest file
ref_testcase_path = __ensure_absolute(teststep["testcase"])
test_content = load_test_file(ref_testcase_path)
if not isinstance(test_content, Dict):
raise exceptions.TestCaseFormatError(f"Invalid teststep: {teststep}")
# api in v2 format, convert to v3 testcase
if "request" in test_content and "name" in test_content:
test_content = ensure_testcase_v3_api(test_content)
test_content.setdefault("config", {})["path"] = ref_testcase_path
ref_testcase_python_abs_path = make_testcase(test_content)
# override testcase export
ref_testcase_export: List = test_content["config"].get("export", [])
#如果有导出关键字,改为列表
if ref_testcase_export:
step_export: List = teststep.setdefault("export", [])
step_export.extend(ref_testcase_export)
teststep["export"] = list(set(step_export))
# prepare ref testcase class name
ref_testcase_cls_name = pytest_files_made_cache_mapping[
ref_testcase_python_abs_path
]
teststep["testcase"] = ref_testcase_cls_name
# prepare import ref testcase
ref_testcase_python_relative_path = convert_relative_project_root_dir(
ref_testcase_python_abs_path
)
ref_module_name, _ = os.path.splitext(ref_testcase_python_relative_path)
ref_module_name = ref_module_name.replace(os.sep, ".")
import_expr = f"from {ref_module_name} import TestCase{ref_testcase_cls_name} as {ref_testcase_cls_name}"
if import_expr not in imports_list:
imports_list.append(import_expr)
testcase_path = convert_relative_project_root_dir(testcase_abs_path)
# current file compared to ProjectRootDir
diff_levels = len(testcase_path.split(os.sep))#判断用例文件时第几级
data = {
"version": __version__,
"testcase_path": testcase_path,
"diff_levels": diff_levels,
"class_name": f"TestCase{testcase_cls_name}",
"imports_list": imports_list,
"config_chain_style": make_config_chain_style(config),
"parameters": config.get("parameters"),
"teststeps_chain_style": [
make_teststep_chain_style(step) for step in teststeps
],
}
content = __TEMPLATE__.render(data)#返回pytest文件内容 这里用的是jinja2的模板渲染数据
# ensure new file's directory exists
dir_path = os.path.dirname(testcase_python_abs_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
with open(testcase_python_abs_path, "w", encoding="utf-8") as f:
f.write(content) #写入py文件
pytest_files_made_cache_mapping[testcase_python_abs_path] = testcase_cls_name
__ensure_testcase_module(testcase_python_abs_path) #确保pytest文件在模块里--给模块加一个init文件
logger.info(f"generated testcase: {testcase_python_abs_path}")
return testcase_python_abs_path
在函数内,需要对全局变量进行变更,或者重定义时,需要用global对变量宣言。
python中global的用法 (baidu.com)
""" cache converted pytest files, avoid duplicate making """ pytest_files_made_cache_mapping: Dict[Text, Text] = {}缓存pytest文件,避免重复制作
data = {
"version": __version__,
"testcase_path": testcase_path,
"diff_levels": diff_levels,
"class_name": f"TestCase{testcase_cls_name}",
"imports_list": imports_list,
"config_chain_style": make_config_chain_style(config),
"parameters": config.get("parameters"),
"teststeps_chain_style": [
make_teststep_chain_style(step) for step in teststeps
],
}
content = __TEMPLATE__.render(data)#返回pytest文件内容
__TEMPLATE__ = jinja2.Template()是一个模板,传入data后,调用render函数,将data数据渲染进模板(15条消息) Jinja2 模板用法_jinja2怎么设置查找全局的templates_格洛米爱学习的博客-CSDN博客
用来将testsuite转换为pytest文件,testsuite是指有testcases关键字的测试文件。
这里还发现了源码的问题,就是testcases下面写的extract和validate不生效,而是直接用的testcase用例的extract和validate。
config:
name: "查询用户信息"
base_url: "https://api.pity.fun"
testcases:
- name: 登录成功
testcase: ./testcases/login.yml
extract:
token: body.data.token
validate:
- eq: [ "status_code", 200 ]
- eq: [ body.code,0 ]
- eq: [ body.msg,"哈哈哈" ]
-
name: 查询用户信息
testcase: ./testcases/query_custom.yml
validate:
- eq: ["status_code", 200]
- eq: [body.code,0]
- eq: [body.msg,"aaaa"]
def make_testsuite(testsuite: Dict):
"""convert valid testsuite dict to pytest folder with testcases"""
# validate testsuite format
load_testsuite(testsuite)
print("testsuite")
print(testsuite)
testsuite_config = testsuite["config"]
testsuite_path = testsuite_config["path"]
testsuite_variables = convert_variables(
testsuite_config.get("variables", {}), testsuite_path
)
logger.info(f"start to make testsuite: {testsuite_path}")
# create directory with testsuite file name, put its testcases under this directory
testsuite_path = ensure_file_abs_path_valid(testsuite_path)
testsuite_dir, file_suffix = os.path.splitext(testsuite_path)
# demo_testsuite.yml => demo_testsuite_yml
testsuite_dir = f"{testsuite_dir}_{file_suffix.lstrip('.')}"
for testcase in testsuite["testcases"]:
# get referenced testcase content
testcase_file = testcase["testcase"]
testcase_path = __ensure_absolute(testcase_file)
testcase_dict = load_test_file(testcase_path)
testcase_dict.setdefault("config", {})
testcase_dict["config"]["path"] = testcase_path
# override testcase name
testcase_dict["config"]["name"] = testcase["name"]
# override base_url
base_url = testsuite_config.get("base_url") or testcase.get("base_url")
if base_url:
testcase_dict["config"]["base_url"] = base_url
# override verify
if "verify" in testsuite_config:
testcase_dict["config"]["verify"] = testsuite_config["verify"]
# override variables
# testsuite testcase variables > testsuite config variables
#testcase里的变量优先级>testsuite的config中的变量
testcase_variables = convert_variables(
testcase.get("variables", {}), testcase_path
)
testcase_variables = merge_variables(testcase_variables, testsuite_variables)
# testsuite testcase variables > testcase config variables
testcase_dict["config"]["variables"] = convert_variables(
testcase_dict["config"].get("variables", {}), testcase_path
)
testcase_dict["config"]["variables"].update(testcase_variables)
# override weight
if "weight" in testcase:
testcase_dict["config"]["weight"] = testcase["weight"]
logger.info(f"testsuite_dir{testsuite_dir}")
logger.info(f"testcase_dict{testcase_dict}")
# 将testcase中的内容和testsuite的目录传给make_testcase,生成pytest文件
testcase_pytest_path = make_testcase(testcase_dict, testsuite_dir)
pytest_files_run_set.add(testcase_pytest_path)
从源码中可以看到,并没有对testcase中的export和validate进行改写,所以testsuite中的export和validate没有被用到。
解决:
#如果testsuite中存在validate,则用testsuie中的 overright validate
if "validate" in testcase:
testcase_dict['teststeps'][0]['validate'] = testcase["validate"]
#override export
if "extract" in testcase:
testcase_dict["teststeps"][0]["extract"] = testcase["extract"]
需要注意的是,testsuite里引用的testcase文件,必须只能有一个步骤。
运行程序可以看到,这里导出和断言,用的是试套件下的内容,解决了问题。
ensure_path_sep(tests_path)#路径兼容os.path.isabs(tests_path) 判断是否为一个绝对路径os.getcwd() 获取当前目录os.path.join(os.getcwd(), tests_path) 拼接路径os.path.isdir 判断是否为目录os.path.isfile 判断是否为一个文件os.path.splitext("D:\Project\demo\testcases\demo_testcase_ref.yml") 获取文件后缀os.path.dirname(testcase_python_abs_path) 获取绝对路径文件的目录os.path.exists 判断目录是否存在os.makedirs(dir_path) 创建目录if not os.path.exists(dir_path): os.makedirs(dir_path)文件写入
with open(testcase_python_abs_path, "w", encoding="utf-8") as f: f.write(content)打印前加f,可以在字符串里引用变量
logger.info(f"generated testcase: {testcase_python_abs_path}")os.sep用于系统路径中的分隔符
Windows系统上,文件的路径分隔符是 '\'
Linux系统上,文件的路径分隔符是 '/'
苹果Mac OS系统中是 ':'
Python 为满足跨平台的要求,使用os.sep能够在不同系统上采用不同的分隔符