htttprunnerV3源码——V2.x适配

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.pymain_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_argscompat.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],
    }
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容