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对变量宣言。

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"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详细教程七(三种方式实现参数化数据驱动)

httprunner 3.x学习16

httprunner 3.x学习2

04-Httprunner-变量优先级

httprunner 3.x学习16 - 断言使用正则匹配(assert_regex_match)

httprunner 3.X学习