HttpRunner3.x 源码解析-main_make生成用例文件
Posted 东方不败之鸭梨的测试笔记
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HttpRunner3.x 源码解析-main_make生成用例文件相关的知识,希望对你有一定的参考价值。
main_make
当终端输入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文件列表,它的类型是<class 'dict_keys'>
如:
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())
<class 'dict_keys'>
*pytest_files_format_list 输出的是dict_keys里单独的值如D:\\Project\\demo\\testcases\\login_test.py D:\\Project\\demo\\testcases\\query_test.py
format_pytest_with_black 格式化pytest文件
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)
__make()
传入目录或者文件列表,输出 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\\ntype(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\\ntype(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\\ntype(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)
make_testcase
该函数用来制作测试用例。
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 TestCaseref_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"TestCasetestcase_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对变量宣言。
""" 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"TestCasetestcase_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博客
make_testsuite
用来将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"]
在make_testsuite源码
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_dirtestsuite_dir")
logger.info(f"testcase_dicttestcase_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能够在不同系统上采用不同的分隔符
httprunner 3.x学习16 - 断言使用正则匹配(assert_regex_match)
前言
httprunner 3.x可以支持正则匹配断言,使用assert_regex_match方法
assert_regex_match
assert_regex_match 源码如下
def assert_regex_match(
self, jmes_path: Text, expected_value: Text, message: Text = ""
) -> "StepRequestValidation":
self.__step_context.validators.append(
{"regex_match": [jmes_path, expected_value, message]}
)
return self
校验方法是 regex_match ,于是找到httprunner/builtin/comparators.py
def regex_match(check_value: Text, expect_value: Any, message: Text = ""):
assert isinstance(expect_value, str), "expect_value should be Text type"
assert isinstance(check_value, str), "check_value should be Text type"
assert re.match(expect_value, check_value), message
断言结果返回的是re.match方法,传2个参数
- expect_value 正则表达式
- check_value 检查返回结果的字符串
使用示例
登录接口返回
# 作者-上海悠悠 QQ交流群:717225969
# blog地址 https://www.cnblogs.com/yoyoketang/
{
"code":0,
"msg":"login success!",
"username":"test1",
"token":"2a05f8e450d590f4ea3aba66294a26ec3fe8e0cf"
}
assert_regex_match 方法第一个参数是jmes_path,提取返回的body,比如我要正则匹配token是40位16进制
.assert_regex_match("body.token", "[0-9a-f]{40}")
yaml文件示例
# 作者-上海悠悠 QQ交流群:717225969
# blog地址 https://www.cnblogs.com/yoyoketang/
config:
name: login case
variables:
user: test
psw: "123456"
base_url: http://127.0.0.1:8000
export:
- token
teststeps:
-
name: step login
variables:
user: test1
psw: "123456"
request:
url: /api/v1/login
method: POST
json:
username: $user
password: $psw
extract:
token: content.token
validate:
- eq: [status_code, 200]
- regex_match: [body.token, "[0-9a-f]{40}"]
pytest脚本
# NOTE: Generated By HttpRunner v3.1.4
# FROM: testcases\\login_var.yml
# 作者-上海悠悠 QQ交流群:717225969
# blog地址 https://www.cnblogs.com/yoyoketang/
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase
class TestCaseLoginVar(HttpRunner):
config = (
Config("login case")
.variables(**{"user": "test", "psw": "123456"})
.base_url("http://127.0.0.1:8000")
.export(*["token"])
)
teststeps = [
Step(
RunRequest("step login")
.with_variables(**{"user": "test1", "psw": "123456"})
.post("/api/v1/login")
.with_json({"username": "$user", "password": "$psw"})
.extract()
.with_jmespath("body.token", "token")
.validate()
.assert_equal("status_code", 200)
.assert_regex_match("body.token", "[0-9a-f]{40}")
),
]
if __name__ == "__main__":
TestCaseLoginVar().test_start()
需注意的是正则匹配只能匹配字符串类型,不是字符串类型的可以用 jmespath 函数to_string()
转字符串
.assert_regex_match("to_string(body.code)", "0")
上海-悠悠 blog地址https://www.cnblogs.com/yoyoketang/
以上是关于HttpRunner3.x 源码解析-main_make生成用例文件的主要内容,如果未能解决你的问题,请参考以下文章
httprunner3.x详细教程七(三种方式实现参数化数据驱动)