HttpRunner ApiTestEngine (4) 测试用例中实现 Python 函数的调用

debugtalk · 2017年07月18日 · 最后由 yyy 回复于 2017年12月17日 · 100 次阅读

《测试用例中实现Python函数的定义》中,介绍了在YAML/JSON测试用例中实现Python函数定义的两种方法,以及它们各自适用的场景。

但是在YAML/JSON文本中要怎样实现函数的调用和传参呢?

variable_binds:
   - TOKEN: debugtalk
   - json: {}
   - random: ${gen_random_string(5)}
   - authorization: ${gen_md5($TOKEN, $json, $random)}

例如上面的例子(YAML 格式),gen_random_stringgen_md5都是已经定义好的函数,但${gen_random_string(5)}${gen_md5($TOKEN, $json, $random)}终究只是文本字符串,程序是如何将其解析为实际的函数和参数,并实现调用的呢?

本文将对此进行重点讲解。

函数的调用形式

Python语言中,函数的调用形式包含如下四种形式:

  • 无参数:func()
  • 顺序参数:func(a, b)
  • 字典参数:func(a=1, b=2)
  • 混合类型参数:func(1, 2, a=3, b=4)

之前在《探索优雅的测试用例描述方式》中介绍过,我们选择使用${}作为函数转义符,在YAML/JSON用例描述中调用已经定义好的函数。

于是,以上四种类型的函数定义在YAML/JSON中就会写成如下样子。

  • 无参数:${func()}
  • 顺序参数:${func(a, b)}
  • 字典参数:${func(a=1, b=2)}
  • 混合类型参数:${func(1, 2, a=3, b=4)}

还是之前的例子:

- test:
    name: create user which does not exist
    import_module_functions:
        - test.data.custom_functions
    variable_binds:
        - TOKEN: debugtalk
        - json: {"name": "user", "password": "123456"}
        - random: ${gen_random_string(5)}
        - authorization: ${gen_md5($TOKEN, $json, $random)}
    request:
        url: http://127.0.0.1:5000/api/users/1000
        method: POST
        headers:
            Content-Type: application/json
            authorization: $authorization
            random: $random
        json: $json
    validators:
        - {"check": "status_code", "comparator": "eq", "expected": 201}
        - {"check": "content.success", "comparator": "eq", "expected": true}

在这里面有一个variable_binds模块,之前已经出现过很多次,也一直都没有讲解。但是,本文也不打算进行讲解,该部分内容将在下一篇讲解参数的定义和引用时再详细展开。

当前我们只需要知道,在该用例描述中,${gen_random_string(5)}${gen_md5($TOKEN, $json, $random)}均实现了函数的传参和调用,而调用的函数正式之前我们定义的gen_random_stringgen_md5

这里应该比较好理解,因为函数调用形式与在Python脚本中完全相同。但难点在于,这些描述在YAML/JSON中都是文本字符串形式,ApiTestEngine在加载测试用例的时候,是怎么识别出函数并完成调用的呢?

具体地,这里可以拆分为三个需求点:

  • 如何在YAML/JSON文本中识别函数?
  • 如何将文本字符串的函数拆分为函数名称和参数?
  • 如何使用函数名称和参数实现对应函数的调用?

正则表达式的妙用

对于第一个需求点,我们之前已经做好了铺垫,设计了${}作为函数的转义符;而当初之所以这么设计,也是为了在加载测试用例时便于解析识别,因为我们可以通过使用正则表达式,非常准确地将函数从文本格式的测试用例中提取出来。

既然Python函数的调用形式是确定的,都是函数名(参数)的形式,那么使用正则表达式的分组匹配功能,我们就可以很好地实现函数名称与参数的匹配,也就实现了第二个需求点。

例如,我们可以采用如下正则表达式,来对YAML/JSON中的每一个值(Value)进行匹配性检查。

r"^\$\{(\w+)\((.*)\)\}$"
>>> import re
>>> regex = r"^\$\{(\w+)\((.*)\)\}$"
>>> string = "${func(3, 5)}"
>>> matched = re.match(regex, string)
>>> matched.group(1)
'func'
>>> matched.group(2)
'3, 5'
>>>
>>> string = "${func(a=1, b=2)}"
>>> matched = re.match(regex, string)
>>> matched.group(1)
'func'
>>> matched.group(2)
'a=1, b=2'

可以看出,通过如上正则表达式,如果满足匹配条件,那么matched.group(1)就是函数的名称,matched.group(2)就是函数的参数。

思路是完全可行的,不过我们在匹配参数部分的时候是采用.*的形式,也就是任意字符匹配,匹配的方式不是很严谨。考虑到正常的函数参数部分可能使用到的字符,我们可以采用如下更严谨的正则表达式。

r"^\$\{(\w+)\(([\$\w =,]*)\)\}$"

这里限定了五种可能用到的字符,\w代表任意字母或数字,= ,代表的是等号、空格和逗号,这些都是参数中可能用到的。而\$符号,大家应该还记得,这也是我们设计采用的变量转义符,$var将不再代表的是普遍的字符串,而是var变量的值。

有了这个基础,实现如下is_functon函数,就可以判断某个字符串是否为函数调用。

function_regexp = re.compile(r"^\$\{(\w+)\(([\$\w =,]*)\)\}$")

def is_functon(content):
    matched = function_regexp.match(content)
    return True if matched else False

不过这里还有一个问题。通过上面的正则表达式,是可以将函数名称和参数部分拆分开了,但是在参数部分,还没法区分具体的参数类型。

例如,在前面的例子中,从${func(3, 5)}解析出来的参数为3, 5,从${func(a=1, b=2)}解析出来的参数为a=1, b=2,我们通过肉眼可以识别出这分别对应着顺序参数和字典参数两种类型,但是程序就没法自动识别了,毕竟对于程序来说它们都只是字符串而已。

所以,这里还需要再做一步操作,就是将参数字符串解析为对程序友好的形式。

什么叫对程序友好的形式呢?这里就又要用到上一篇文章讲到的可变参数和关键字参数形式了,也就是func(*args, **kwargs)的形式。

试想,如果我们可以将所有顺序参数都转换为args列表,将所有字典参数都转换为kwargs字典,那么对于任意函数类型,我们都可以采用func(*args, **kwargs)的调用形式。

于是,问题就转换为,如何将参数部分转换为argskwargs两部分。

这就比较简单了。因为在函数的参数部分,顺序参数必须位于字典参数前面,并且以逗号间隔;而字典参数呢,总是以key=value的形式出现,并且也以逗号间隔。

那么我们就可以利用参数部分的这个特征,来进行字符串的处理。处理算法如下:

  • 采用逗号作为分隔符将字符串进行拆分;
  • 对每一部分进行判断,如果不包含等号,那么就是顺序参数,将其加入(append)到args列表;
  • 如果包含等号,那么就是字典参数,采用等号作为分隔符进行进一步拆分得到key-value键值对,然后再加入到kwargs字典。

对应的Python代码实现如下:

def parse_function(content):
    function_meta = {
        "args": [],
        "kwargs": {}
    }
    matched = function_regexp.match(content)
    function_meta["func_name"] = matched.group(1)

    args_str = matched.group(2).replace(" ", "")
    if args_str == "":
        return function_meta

    args_list = args_str.split(',')
    for arg in args_list:
        if '=' in arg:
            key, value = arg.split('=')
            function_meta["kwargs"][key] = parse_string_value(value)
        else:
            function_meta["args"].append(parse_string_value(arg))

    return function_meta

可以看出,通过parse_function函数,可以将一个函数调用的字符串转换为函数的结构体。

例如,${func(1, 2, a=3, b=4)}字符串,经过parse_function转换后,就可以得到该函数的名称和参数信息:

function_meta = {
    'func_name': 'func',
    'args': [1, 2],
    'kwargs': {'a':3, 'b':4}
}

这也就彻底解决了第二个需求点。

实现函数的调用

在此基础上,我们再看第三个需求点,如何使用函数名称和参数实现对应函数的调用,其实也就很简单了。

上一篇文章中,我们实现了对函数的定义,并且将所有定义好的函数都添加到了一个字典当中,假如字典名称为custom_functions_dict,那么根据以上的函数信息(function_meta),就可以采用如下方式进行调用。

func_name = function_meta['func_name']
args = function_meta['args']
kwargs = function_meta['kwargs']
custom_functions_dict[func_name]](*args, **kwargs)

具体的,在ApiTestEngine中对应的Python代码片段如下:

def get_eval_value(self, data):
   """ evaluate data recursively, each variable in data will be evaluated.
   """
   if isinstance(data, (list, tuple)):
       return [self.get_eval_value(item) for item in data]

   if isinstance(data, dict):
       evaluated_data = {}
       for key, value in data.items():
           evaluated_data[key] = self.get_eval_value(value)

       return evaluated_data

   if isinstance(data, (int, float)):
       return data

   # data is in string format here
   data = "" if data is None else data.strip()
   if utils.is_variable(data):
       # variable marker: $var
       variable_name = utils.parse_variable(data)
       value = self.testcase_variables_mapping.get(variable_name)
       if value is None:
           raise exception.ParamsError(
               "%s is not defined in bind variables!" % variable_name)
       return value

   elif utils.is_functon(data):
       # function marker: ${func(1, 2, a=3, b=4)}
       fuction_meta = utils.parse_function(data)
       func_name = fuction_meta['func_name']
       args = fuction_meta.get('args', [])
       kwargs = fuction_meta.get('kwargs', {})
       args = self.get_eval_value(args)
       kwargs = self.get_eval_value(kwargs)
       return self.testcase_config["functions"][func_name](*args, **kwargs)
   else:
       return data

这里还用到了递归的概念,当参数是变量(例如gen_md5($TOKEN, $json, $random)),或者为列表、字典等嵌套类型时,也可以实现正常的解析。

总结

到此为止,我们就解决了测试用例(YAML/JSON)中实现Python函数定义和调用的问题。

还记得《探索优雅的测试用例描述方式》末尾提到的用例模板引擎技术实现的三大块内容么?

  • 如何在用例描述(YAML/JSON)中实现函数的定义和调用
  • 如何在用例描述中实现参数的定义和引用,包括用例内部和用例集之间
  • 如何在用例描述中实现预期结果的描述和测试结果的校验

第一块内容总算是讲完了,下一篇文章将开始讲解如何在用例描述中实现参数的定义和引用的问题。

相关文章

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 15 条回复 时间 点赞
1楼 已删除

能先整理下你的评论么?个人对排版有洁癖,实在是受不了这样的。

高产啊。先点赞,回去再研究

debugtalk 回复

不好意思 因为是 html 代码 自己显示成这样的 我还是删了吧 确实太难看

高手,相当于自己手工实现了 “反射”,膝盖已经献上,都不好意思说自己会 py 了😅

jinglebell 回复

原来这是反射?我自己都不知道😂

debugtalk 回复

至少,我觉得这和反射的本质基本一样。建议有空还是可以了解下 java 的,也能帮助测试做不少事情,两个语言的特性真的很贴切它们各自的 logo,蛇(不受太多束缚的土豪)和咖啡(流程规范一大堆的资本家)

jinglebell 回复

好的,谢谢啦

如何在用例描述中实现参数的定义和引用的问题。这些内容什么时候分享呀、期待。。。

终结者 回复

这个。。主要是框架一直在升级,文档很难跟上啊,我尽力哈😅

debugtalk 回复

小白,最近跟着你的文章分享在学习、没有讲解的部分看代码看晕了。。。

你好,请问我自定义一个 python module,应该放到什么位置呢?放到 yml 相同目录吗?

匿名 #5 · 2017年12月07日

后续啥时候上啊 看完了好想知道后面 就算不详细也说个大概啊

现在的框架进化得已经跟最开始差异非常大了,现在要回过头去写也比较费时,估计暂时不会写这块儿了。

匿名 #15 · 2017年12月08日
debugtalk 回复

好可惜啊。。。好不容易能找到一个跟着文档一起成长的项目 😭
感觉测试在这方面特别缺乏公开的实践项目,这方面往往是纯开发的比较多

debugtalk 回复

可以考虑把现在的框架写一下吗? 上面这几篇文章确实写的很好👍👍👍

yuan 开源啦~接口自动化测试平台 中提及了此贴 07月10日 01:31
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册