HttpRunner3的變數是如何傳遞的

測試開發剛哥發表於2022-01-19

HttpRunner3的變數可以在測試類的用例配置中通過variables新增,也可以在測試步驟中使用extract()with_jmespath()提取出來放到變數x,再用$x傳遞給下一個介面使用,比如登入到下單流程的部分測試指令碼如下:

from httprunner import HttpRunner, Config, Step, RunRequest


class TestLoginPay(HttpRunner):
    config = (
        Config("登入到下單流程")
            .variables(
            **{
                "skuNum": "3"
            }
        )
            .base_url("http://127.0.0.1:5000")
    )

    teststeps = [
        Step(
            RunRequest("登入")
                .post("/login")
                .with_headers(**{"Content-Type": "application/json"})
                .with_json({"username": "dongfanger", "password": "123456"})
                .extract()
                .with_jmespath("body.token", "token")
                .validate()
                .assert_equal("status_code", 200)
        ),
        Step(
            RunRequest("搜尋商品")
                .get("searchSku?skuName=電子書")
                .with_headers(**{"token": "$token"})
                .extract()
                .with_jmespath("body.skuId", "skuId")
                .with_jmespath("body.price", "skuPrice")
                .validate()
                .assert_equal("status_code", 200)
        ),
    ]

首先來看用例配置的這段程式碼:

config = (
    Config("登入到下單流程")
        .variables(
        **{
            "skuNum": "3"
        }
    )
        .base_url("http://127.0.0.1:5000")
)

是怎麼實現的。Config的定義如下:

class Config(object):
    def __init__(self, name: Text):
        self.__name = name
        self.__variables = {}
        self.__base_url = ""
        self.__verify = False
        self.__export = []
        self.__weight = 1

        caller_frame = inspect.stack()[1]
        self.__path = caller_frame.filename

    @property
    def name(self) -> Text:
        return self.__name

    @property
    def path(self) -> Text:
        return self.__path

    @property
    def weight(self) -> int:
        return self.__weight

    def variables(self, **variables) -> "Config":
        self.__variables.update(variables)
        return self

    def base_url(self, base_url: Text) -> "Config":
        self.__base_url = base_url
        return self

    def verify(self, verify: bool) -> "Config":
        self.__verify = verify
        return self

    def export(self, *export_var_name: Text) -> "Config":
        self.__export.extend(export_var_name)
        return self

    def locust_weight(self, weight: int) -> "Config":
        self.__weight = weight
        return self

    def perform(self) -> TConfig:
        return TConfig(
            name=self.__name,
            base_url=self.__base_url,
            verify=self.__verify,
            variables=self.__variables,
            export=list(set(self.__export)),
            path=self.__path,
            weight=self.__weight,
        )

其中variables的定義是:

def variables(self, **variables) -> "Config":
    self.__variables.update(variables)
    return self

self.__variables = {}是個字典。為什麼要加個字首**呢?這個**換個模樣估計就看懂了:

def foo(**kwargs):
    print(kwargs)

>>> foo(x=1, y=2)
{'x': 1, 'y': 2}

>>> foo(**{'x': 1, 'y': 2})
{'x': 1, 'y': 2}

self.__variables.update(variables)裡面的upate方法是字典新增到字典裡,比如:

tinydict = {'Name': 'Zara', 'Age': 7}
tinydict2 = {'Sex': 'female' }

tinydict.update(tinydict2)  # {'Age': 7, 'Name': 'Zara', 'Sex': 'female'}

整個方法的意思就是從variables中取出variables字典,然後新增到self.__variables字典裡。

這個Config會在runner.py裡面的HttpRunner類初始化時載入到self.__config

def __init_tests__(self) -> NoReturn:
    self.__config = self.config.perform()
    self.__teststeps = []
    for step in self.teststeps:
        self.__teststeps.append(step.perform())

然後self.__config會在各個地方呼叫。比如在__run_step_request中把base_url拼裝起來。:

# prepare arguments
method = parsed_request_dict.pop("method")
url_path = parsed_request_dict.pop("url")
url = build_url(self.__config.base_url, url_path)
parsed_request_dict["verify"] = self.__config.verify
parsed_request_dict["json"] = parsed_request_dict.pop("req_json", {})

而接下來的程式碼是真把我看暈了。

第一個問題:config裡面的變數是怎麼用到測試步驟裡面的?

答案就是:

step.variables = merge_variables(step.variables, self.__config.variables)

通過merge_variables函式合併到了step.variables,step是下面這個類的例項:

class TStep(BaseModel):
    name: Name
    request: Union[TRequest, None] = None
    testcase: Union[Text, Callable, None] = None
    variables: VariablesMapping = {}
    setup_hooks: Hooks = []
    teardown_hooks: Hooks = []
    # used to extract request's response field
    extract: VariablesMapping = {}
    # used to export session variables from referenced testcase
    export: Export = []
    validators: Validators = Field([], alias="validate")
    validate_script: List[Text] = []

step.variables在run_testcase裡面賦值:

image-20220118225055198

  • 第一部分是把前面步驟提取的變數合併進來。
  • 第二部分是把用例配置裡面的變數合併進來,這就是第一個問題的答案。

第二個問題:變數是怎麼提取出來的?

先看看RequestWithOptionalArgs類的extract方法:

def extract(self) -> StepRequestExtraction:
    return StepRequestExtraction(self.__step_context)
class StepRequestExtraction(object):
    def __init__(self, step_context: TStep):
        self.__step_context = step_context

    def with_jmespath(self, jmes_path: Text, var_name: Text) -> "StepRequestExtraction":
        self.__step_context.extract[var_name] = jmes_path
        return self

    # def with_regex(self):
    #     # TODO: extract response html with regex
    #     pass
    #
    # def with_jsonpath(self):
    #     # TODO: extract response json with jsonpath
    #     pass

    def validate(self) -> StepRequestValidation:
        return StepRequestValidation(self.__step_context)

    def perform(self) -> TStep:
        return self.__step_context

這就是在測試指令碼中用到的extract()和with_jmespath()。

可以看到作者這裡寫了TODO支援正規表示式和JsonPath表示式。

然後把變數名和JmesPath表示式存入了self.__step_context.extract中,這會用在:

image-20220118210810367

從而傳入另外這個ResponseObject類的extract方法:

image-20220118210915771

然後self._search_jmespath根據表示式把值找到:

def _search_jmespath(self, expr: Text) -> Any:
    resp_obj_meta = {
        "status_code": self.status_code,
        "headers": self.headers,
        "cookies": self.cookies,
        "body": self.body,
    }
    if not expr.startswith(tuple(resp_obj_meta.keys())):
        return expr
    
    try:
        check_value = jmespath.search(expr, resp_obj_meta)
    except JMESPathError as ex:
        logger.error(
            f"failed to search with jmespath\n"
            f"expression: {expr}\n"
            f"data: {resp_obj_meta}\n"
            f"exception: {ex}"
        )
        raise

    return check_value

存入extract_mapping中:

image-20220118225535963

再存入step_data.export_vars

image-20220118225443190

然後在__run_step中返回:

image-20220118211524342

最後通過extracted_variables存入到self.__session_variables中:

image-20220118211617259

self.__session_variables是runner.py模組中HttpRunne類的屬性,可以理解為一個session級別的變數池。

第三個問題:為什麼用$就能直接使用變數?

在run_testcase方法中有一段程式碼,解析變數:

# parse variables
step.variables = parse_variables_mapping(
    step.variables, self.__project_meta.functions
)

parse_variables_mapping的定義如下:

def parse_variables_mapping(
    variables_mapping: VariablesMapping, functions_mapping: FunctionsMapping = None
) -> VariablesMapping:

    parsed_variables: VariablesMapping = {}

    while len(parsed_variables) != len(variables_mapping):
        for var_name in variables_mapping:

            if var_name in parsed_variables:
                continue

            var_value = variables_mapping[var_name]
            variables = extract_variables(var_value)

            # check if reference variable itself
            if var_name in variables:
                # e.g.
                # variables_mapping = {"token": "abc$token"}
                # variables_mapping = {"key": ["$key", 2]}
                raise exceptions.VariableNotFound(var_name)

            # check if reference variable not in variables_mapping
            not_defined_variables = [
                v_name for v_name in variables if v_name not in variables_mapping
            ]
            if not_defined_variables:
                # e.g. {"varA": "123$varB", "varB": "456$varC"}
                # e.g. {"varC": "${sum_two($a, $b)}"}
                raise exceptions.VariableNotFound(not_defined_variables)

            try:
                parsed_value = parse_data(
                    var_value, parsed_variables, functions_mapping
                )
            except exceptions.VariableNotFound:
                continue

            parsed_variables[var_name] = parsed_value

    return parsed_variables

非常的複雜。其中有個函式extract_variables:

def extract_variables(content: Any) -> Set:
    """ extract all variables in content recursively.
    """
    if isinstance(content, (list, set, tuple)):
        variables = set()
        for item in content:
            variables = variables | extract_variables(item)
        return variables

    elif isinstance(content, dict):
        variables = set()
        for key, value in content.items():
            variables = variables | extract_variables(value)
        return variables

    elif isinstance(content, str):
        return set(regex_findall_variables(content))

    return set()

裡面有個regex_findall_variables函式:

def regex_findall_variables(raw_string: Text) -> List[Text]:
    try:
        match_start_position = raw_string.index("$", 0)
    except ValueError:
        return []

    vars_list = []
    while match_start_position < len(raw_string):

        # Notice: notation priority
        # $$ > $var

        # search $$
        dollar_match = dolloar_regex_compile.match(raw_string, match_start_position)
        if dollar_match:
            match_start_position = dollar_match.end()
            continue

        # search variable like ${var} or $var
        var_match = variable_regex_compile.match(raw_string, match_start_position)
        if var_match:
            var_name = var_match.group(1) or var_match.group(2)
            vars_list.append(var_name)
            match_start_position = var_match.end()
            continue

        curr_position = match_start_position
        try:
            # find next $ location
            match_start_position = raw_string.index("$", curr_position + 1)
        except ValueError:
            # break while loop
            break

    return vars_list

在這個函式中看到了$符號的身影,就是在這裡解析的了。而整個解析過程那是相當的複雜,沒有用現成的包,而是作者自己實現的。並且還有個長達548行的parser_test.py測試程式碼,要說清楚,估計得另外再寫一篇專項文章了。

相關文章