httprunner升级到v3版本后,用例格式和v2.x版本不一样了。在v3版本中,作者推荐大家回归到直接使用码编写用例,而不是写yml/Json文件,并且在v3版本中去除了测试用例分层的api层,统一为testcase。
但是v3版本依然保留了对yml/json用例的支持,并且对v2.x的用例也做了一定的适配,本篇文章主要内容就是httprunner v3版本源码中对v2.x的适配。
v2.x命令参数适配
由于底层测试框架从之前的unittest
更换为了pytest
,所以v3版本的启动命令参数和之前的不同了,为此,v3首先对启动命令做了低版本适配。
httprunner run/hrun
命令的入口在cli.py
的main_run
函数,该函数首先对命令参数进行了低版本适配:
# cli.py
def main_run(extra_args) -> enum.IntEnum:
capture_message("start to run")
# hrun v2.x命令参数的适配
extra_args = ensure_cli_args(extra_args)
...
ensure_cli_args
是compat.py
中的函数,对httprunnerV2.x中的--failfast
、--report-file
、--save-tests
命令参数进行了适配:
# compat.py
def ensure_cli_args(args: List) -> List:
""" ensure compatibility with deprecated cli args in v2"""
if "--failfast" in args:
# 删除--failfast参数,不再处理
args.pop(args.index("--failfast"))
if "--report-file" in args:
# --report-file参数改为--html --self-contained-html
index = args.index("--report-file")
args[index] = "--html"
args.append("--self-contained-html")
if "--save-tests" in args:
# 生成测试报告摘要
args.pop(args.index("--save-tests"))
_generate_conftest_for_summary(args)
return args
前两个很好理解,--failfast
参数在v3版本中被弃用了;--report-file
参数换成了pytest框架支持的参数--html --self-contained-html
;而对--save-tests
参数的适配则做了较多处理。
--save-tests参数
在v2.x版本中,执行测试指定--save-tests
参数,即可将运行过程中的中间数据
保存为日志文件,日志文件保存在测试项目根目录的 logs
文件夹,生成的文件有如下三个(XXX为测试用例名称):
-
XXX.loaded.json
:测试用例加载后的数据结构内容,加载包括测试用例文件(YAML/JSON)、debugtalk.py、.env 等所有项目文件 -
XXX.parsed.json
:测试用例解析后的数据结构内容,解析内容包括测试用例引用(API/testcase)、变量计算和替换、base_url 拼接等 -
XXX.summary.json
:测试报告生成前的数据结构内容
在v3版本中,默认会将运行过程数据以{UUID}.run.log
文件形式保存到logs
目录下,如果指定--save-tests
参数,则会在logs
目录下生成all.summary.json
(测试路径是用例目录)或XXX.summary.json
(测试路径是单个用例文件)。
生成conftest.py
在_generate_conftest_for_summary
函数中,通过在测试目录下生成pytest的conftest.py
文件来生成summary.json文件
# compat.py
def _generate_conftest_for_summary(args: List):
# 从args获取一个路径参数赋值给test_path变量,没有路径参数则结束执行
for arg in args:
if os.path.exists(arg):
test_path = arg
# FIXME: several test paths maybe specified -> 多个路径参数只取第一个
break
else:
sys.exit(1)
conftest_content = '''此处省略conftest.py文件内容...'''
# 通过test_path得出测试项目根目录、conftest.py文件路径(根目录下)、logs目录路径(根目录下)
...
if os.path.isdir(test_path):
# 如果测试路径是目录,在logs目录下生成all.summary.json
file_folder_path = os.path.join(logs_dir_path, test_path_relative_path)
dump_file_name = "all.summary.json"
else:
# 测试路径是文件,在父目录下生成 {文件名}.summary.json
file_relative_folder_path, test_file = os.path.split(test_path_relative_path)
file_folder_path = os.path.join(logs_dir_path, file_relative_folder_path)
test_file_name, _ = os.path.splitext(test_file)
dump_file_name = f"{test_file_name}.summary.json"
summary_path = os.path.join(file_folder_path, dump_file_name)
# 将报告路径传入conftest.py的session_fixture
conftest_content = conftest_content.replace("{{SUMMARY_PATH_PLACEHOLDER}}", summary_path)
# 生成conftest.py文件,写入文件内容conftest_content
...
在上述代码中,根据传入的测试路径,可以得出以下路径:
测试路径 | 项目根目录 | logs目录 | conftest.py路径 | summary.json路径 |
---|---|---|---|---|
D:\test\demo.yml | D:\test\ | D:\test\logs\ | D:\test\conftest.py | D:\test\logs\demo.summary.json |
D:\testsuite\ | D:\testsuite\ | D:\testsuite\logs\ | D:\testsuite\conftest.py | D:\testsuite\logs\all.summary.json |
summary.json文件由conftest.py创建,pytest运行时,会自动识别项目根目录下的conftest.py文件。
上述代码生成的conftest.py
文件内容如下:
session_fixture
函数设置了@pytest.fixture(scope="session", autouse=True)
,表示在执行测试前后自动运行一次。
conftest中fixtrue
的执行时机通过yield
关键字区分,在yield之前的代码会在执行测试前运行,在yield之后的代码会在测试完成后运行。
以下代码在执行测试前记录开始时间,测试完成后生成指定的summary.json文件。
# 此处省略import
...
@pytest.fixture(scope="session", autouse=True)
def session_fixture(request):
"""setup and teardown each task"""
logger.info(f"start running testcases ...")
start_at = time.time()
yield
logger.info(f"task finished, generate task summary for --save-tests")
summary = {
"success": True,
"stat": {
"testcases": {"total": 0, "success": 0, "fail": 0},
"teststeps": {"total": 0, "failures": 0, "successes": 0},
},
"time": {"start_at": start_at, "duration": time.time() - start_at},
"platform": get_platform(),
"details": [],
}
for item in request.node.items:
testcase_summary = item.instance.get_summary()
summary["success"] &= testcase_summary.success
summary["stat"]["testcases"]["total"] += 1
summary["stat"]["teststeps"]["total"] += len(testcase_summary.step_datas)
if testcase_summary.success:
summary["stat"]["testcases"]["success"] += 1
summary["stat"]["teststeps"]["successes"] += len(
testcase_summary.step_datas
)
else:
summary["stat"]["testcases"]["fail"] += 1
summary["stat"]["teststeps"]["successes"] += (
len(testcase_summary.step_datas) - 1
)
summary["stat"]["teststeps"]["failures"] += 1
testcase_summary_json = testcase_summary.dict()
testcase_summary_json["records"] = testcase_summary_json.pop("step_datas")
summary["details"].append(testcase_summary_json)
summary_path = "E:\Projects\Python\httprunner\examples\postman_echo\logs\request_methods\hardcode.summary.json"
summary_dir = os.path.dirname(summary_path)
os.makedirs(summary_dir, exist_ok=True)
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=4, ensure_ascii=False, cls=ExtendJSONEncoder)
logger.info(f"generated task summary: {summary_path}")
v2.x用例格式转v3格式
httprunner执行测试需要将httprunner用例转换为pytest用例,在这之前会将v2.x的用例内容转换为v3的内容格式。
make.py
是主要负责生成pytest用例的模块,核心函数是make_testcase
,该函数负责将httprunner用例转换为pytest用例。
# make.py
def make_testcase(testcase: Dict, dir_path: Text = None) -> Text:
"""convert valid testcase dict to pytest file path"""
# V2.x用例格式转V3格式
testcase = ensure_testcase_v3(testcase)
...
teststeps = testcase["teststeps"]
for teststep in teststeps:
...
# V2.x的api格式转换为V3的testcase格式
if "request" in test_content and "name" in test_content:
test_content = ensure_testcase_v3_api(test_content)
...
...
在make_testcase函数中,首先确保用例内容符合v3版本的格式:
# compat.py
def ensure_testcase_v3(test_content: Dict) -> Dict:
v3_content = {"config": test_content["config"], "teststeps": []}
# 如果用例中不存在测试步骤或测试步骤不是数组类型,则结束测试
if "teststeps" not in test_content:
sys.exit(1)
if not isinstance(test_content["teststeps"], list):
sys.exit(1)
for step in test_content["teststeps"]:
teststep = {}
if "request" in step:
# 将step的request对象属性重新排序
teststep["request"] = _sort_request_by_custom_order(step.pop("request"))
elif "api" in step:
# V2.x的api字段名换成testcase,V3不再使用测试用例分层
teststep["testcase"] = step.pop("api")
elif "testcase" in step:
teststep["testcase"] = step.pop("testcase")
else:
raise exceptions.TestCaseFormatError(f"Invalid teststep: {step}")
# 将step的name、variables、setup_hooks、extract等属性更新到teststep
teststep.update(_ensure_step_attachment(step))
# teststep对象属性重新排序
teststep = _sort_step_by_custom_order(teststep)
v3_content["teststeps"].append(teststep)
return v3_content
内容格式处理完后,还要将v2.x的api层转换为testcase:
# compat.py
def ensure_testcase_v3_api(api_content: Dict) -> Dict:
logger.info("convert api in v2 to testcase format v3")
teststep = {
# request属性字段重新排序,内容不变
"request": _sort_request_by_custom_order(api_content["request"]),
}
teststep.update(_ensure_step_attachment(api_content))
teststep = _sort_step_by_custom_order(teststep)
config = {"name": api_content["name"]}
extract_variable_names: List = list(teststep.get("extract", {}).keys())
if extract_variable_names:
config["export"] = extract_variable_names
return {
"config": config,
"teststeps": [teststep],
}