《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》中,我们臆想了一种简洁优雅的用例描述方式,接下来,我们就从技术实现的角度,逐项进行深入讲解,将臆想变成现实。

本文先解决第一个问题,“如何在用例描述(YAML/JSON)中实现函数的定义和调用”。

在写作的过程中,发现要将其中的原理阐述清楚,要写的内容实在是太多,因此将问题再拆分为 “函数定义” 和 “函数调用” 两部分,本文只讲解 “函数定义” 部分的内容。

实现函数的定义

在之前,我们假设存在gen_random_string这样一个生成指定位数随机字符串的函数,以及gen_md5这样一个计算签名校验值的函数,我们不妨先尝试通过Python语言进行具体的实现。

import hashlib
import random
import string

def gen_random_string(str_len):
    return ''.join(
        random.choice(string.ascii_letters + string.digits) for _ in range(str_len))

def gen_md5(*args):
    return hashlib.md5("".join(args).encode('utf-8')).hexdigest()

gen_random_string(5) # => A2dEx

TOKEN = "debugtalk"
data = '{"name": "user", "password": "123456"}'
random = "A2dEx"
gen_md5(TOKEN, data, random) # => a83de0ff8d2e896dbd8efb81ba14e17d

熟悉Python语言的人对以上代码应该都不会有理解上的难度。可能部分新接触Python的同学对gen_md5函数的*args传参方式会比较陌生,我也简单地补充下基础知识。

Python中,函数参数共有四种,必选参数、默认参数、可变参数和关键字参数。

必选参数和默认参数大家应该都很熟悉,绝大多数编程语言里面都有类似的概念。

def func(x, y, a=1, b=2):
    return x + y + a + b

func(1, 2) # => 6
func(1, 2, b=3) # => 7

在上面例子中,xy是必选参数,ab是默认参数。除了显示地定义必选参数和默认参数,我们还可以通过使用可变参数和关键字参数的形式,实现更灵活的函数参数定义。

def func(*args, **kwargs):
    return sum(args) + sum(kwargs.values())

args = [1, 2]
kwargs = {'a':3, 'b':4}
func(*args, **kwargs) # => 10

args = []
kwargs = {'a':3, 'b':4, 'c': 5}
func(*args, **kwargs) # => 12

之所以说更灵活,是因为当使用可变参数和关键字参数时(func(*args, **kwargs)),我们在调用函数时就可以传入 0 个或任意多个必选参数和默认参数,所有必选参数将作为tuple/list的形式传给可变参数(args),并将所有默认参数作为dict的形式传给关键字参数(kwargs)。另外,可变参数和关键字参数也并不是要同时使用,只使用一种也是可以的。

在前面定义的gen_md5(*args)函数中,我们就可以将任意多个字符串传入,然后得到拼接字符串的MD5值。

现在再回到测试用例描述文件,由于是纯文本格式(YAML/JSON),我们没法直接写Python代码,那要怎样才能定义函数呢?

之前接触过一些函数式编程,所以我首先想到的是借助lambda实现匿名函数。如果对函数式编程不了解,可以看下我之前写过的一篇文章,《Python 的函数式编程 -- 从入门到⎡放弃⎦》

方法一:通过 lambda 实现函数定义

使用lambda有什么好处呢?

最简单直接的一点,通过lambda关键字,我们可以将函数写到一行里面。例如,同样是前面提到的gen_random_string函数和gen_md5函数,通过lambda的实现方式就是如下的形式。

gen_random_string = lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))
gen_md5 = lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8'))

gen_random_string(5) # => A2dEx

TOKEN = "debugtalk"
data = '{"name": "user", "password": "123456"}'
random = "A2dEx"
gen_md5(TOKEN, data, random) # => a83de0ff8d2e896dbd8efb81ba14e17d

可以看出,采用lambda定义的函数跟之前的函数功能完全一致,调用方式相同,运算结果也完全一样。

然后,我们在测试用例里面,通过新增一个function_binds模块,就可以将函数定义与函数名称绑定了。

- test:
    name: create user which does not exist
    function_binds:
        gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))"
        gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8'))
    variable_binds:
        - TOKEN: debugtalk
        - random: ${gen_random_string(5)}
        - json: {"name": "user", "password": "123456"}
        - authorization: ${gen_md5($TOKEN, $json, $random)}

可能有些同学还是无法理解,在上面YAML文件中,即使将函数定义与函数名称绑定了,但是加载YAML文件后,函数名称对应的值也只是一个字符串而已,这还是没法运行啊。

这就又要用到eval黑科技了。通过eval函数,可以执行字符串表达式,并返回表达式的值。

gen_random_string = "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))"

func = eval(gen_random_string)

func # => <function <lambda> at 0x10e19a398>
func(5) # => "A2dEx"

在上面的代码中,gen_random_stringlambda字符串表达式,通过eval执行后,就转换为一个函数对象,然后就可以像正常定义的函数一样调用了。

如果你看到这里还没有疑问,那么说明你肯定没有亲自实践。事实上,上面执行func(5)的时候并不会返回预期结果,而是会抛出如下异常。

>>> func(5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <lambda>
  File "<string>", line 1, in <genexpr>
NameError: global name 'random' is not defined

这是因为,我们在定义的lambda函数中,用到了random库,而在lambda表达式中,我们并没有import random

这下麻烦了,很多时候我们的函数都要用到标准库或者第三方库,而在调用这些库函数之前,我们必须得先import。想来想去,这个import的操作都没法塞到lambda表达式中。

为了解决这个依赖库的问题,我想到两种方式。

第一种方式,在加载YAML/JSON用例之前,先统一将测试用例依赖的所有库都import一遍。这个想法很快就被否决了,因为这必须要在ApiTestEngine框架里面去添加这部分代码,而且每个项目的依赖库不一样,需要import的库也不一样,总不能为了解决这个问题,在框架初始化部分将所有的库都import吧?而且为了适配不同项目来改动测试框架的代码,也不是通用测试框架应有的做法。

然后我想到了第二种方式,就是在测试用例里面,通过新增一个requires模块,罗列出当前测试用例所有需要引用的库,然后在加载用例的时候通过代码动态地进行导入依赖库。

- test:
    name: create user which does not exist
    requires:
        - random
        - string
        - hashlib
    function_binds:
        gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))"
        gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8'))
    variable_binds:
        - TOKEN: debugtalk
        - random: ${gen_random_string(5)}
        - json: {"name": "user", "password": "123456"}
        - authorization: ${gen_md5($TOKEN, $json, $random)}

动态地导入依赖库?其实也没有多玄乎,Python本身也支持这种特性。如果你看到这里感觉无法理解,那么我再补充点基础知识。

Python中执行import时,实际上等价于执行__import__函数。

例如,import random等价于如下语句:

random = __import__('random', globals(), locals(), [], -1)

其中,__import__的函数定义为__import__(name[, globals[, locals[, fromlist[, level]]]]),第一个参数为库的名称,后面的参数暂不用管(可直接查看官方文档)。

由于后面的参数都有默认值,通常情况下我们采用默认值即可,因此我们也可以简化为如下形式:

random = __import__('random')

执行这个语句的有什么效果呢?

可能这也是大多数Python初学者都忽略的一个知识点。在Python运行环境中,有一个全局的环境变量,当我们定义一个函数,或者引入一个依赖库时,实际上就是将其对象添加到了全局的环境变量中。

这个全局的环境变量就是globals(),它是一个字典类型的数据结构。要验证以上知识点,我们可以在Python的交互终端中进行如下实验。

$ python
>>>
>>> globals()
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>}
>>>
>>> import random
>>>
>>> globals()
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'random': <module 'random' from '/Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/random.py'>}

可以看出,在执行import random命令后,globals()中就新增了random函数的引用。

因此,导入random依赖库时,我们采用如下的写法也是等价的。

module_name = random
globals()[module_name] = __import__(module_name)

更进一步,__import__作为Python的底层函数,其实是不推荐直接调用的。要实现同样的功能,推荐使用importlib.import_module。替换后就变成了如下形式:

module_name = random
globals()[module_name] = importlib.import_module(module_name)

如果理解了以上的知识点,那么再给我们一个依赖库名称(字符串形式)的列表时,我们就可以实现动态的导入(import)了。

def import_requires(modules):
   """ import required modules dynamicly
   """
   for module_name in modules:
       globals()[module_name] = importlib.import_module(module_name)

在实现了定义lambda函数的function_binds和导入依赖库的requires模块之后,我们就可以在YAML/JSON中灵活地描述测试用例了。

还是之前的例子,完整的测试用例描述形式就为如下样子。

- test:
    name: create user which does not exist
    requires:
        - random
        - string
        - hashlib
    function_binds:
        gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))"
        gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()"
    variable_binds:
        - TOKEN: debugtalk
        - random: ${gen_random_string(5)}
        - data: '{"name": "user", "password": "123456"}'
        - authorization: ${gen_md5($TOKEN, $data, $random)}
    request:
        url: http://127.0.0.1:5000/api/users/1000
        method: POST
        headers:
            Content-Type: application/json
            authorization: $authorization
            random: $random
        data: $data
    validators:
        - {"check": "status_code", "comparator": "eq", "expected": 201}
        - {"check": "content.success", "comparator": "eq", "expected": true}

现在我们可以在YAML/JSON文本中⎡灵活⎦地定义函数,实现各种功能了。

可是,这真的是我们期望的样子么?

开始的时候,我们想在自动化测试中将测试数据代码实现进行分离,于是我们引入了YAML/JSON格式的用例形式;为了在YAML/JSON文本格式中实现签名校验等计算功能,我们又引入了function_binds模块,并通过lambda定义函数并与函数名进行绑定;再然后,为了解决定义函数中的依赖库问题,我们又引入了requires模块,动态地加载指定的依赖库。

而且即使是这样,这种方式也有一定的局限性,当函数较复杂的时候,我们很难将函数内容转换为lambda表达式;虽然理论上所有的函数都能转换为lamda表达式,但是实现的难度会非常高。

为了不写代码而人为引入了更多更复杂的概念和技术,这已经不再符合我们的初衷了。于是,我开始重新寻找新的实现方式。

方法二:自定义函数模块并进行导入

让我们再回归基础概念,当我们调用一个函数的时候,究竟发生了什么?

简单的说,不管是调用一个函数,还是引用一个变量,都会在当前的运行环境上下文(context)中寻找已经定义好的函数或变量。而在Python中,当我们加载一个模块(module)的时候,就会将该模块中的所有函数、变量、类等对象加载进当前的运行环境上下文。

如果单纯地看这个解释还不清楚,想必大家应该都见过如下案例的形式。假设moduleA模块包含如下定义:

# moduleA

def hello(name):
    return "hello, %s" % name

varA = "I am varA"

那么,我们就可以通过如下方式导入moduleA模块中所有内容,并且直接调用。

from moduleA import *

print(hello("debugtalk")) # => hello, debugtalk
print(varA) # => I am varA

明确这一点后,既然我们之前都可以动态地导入(import)依赖库,那么我们不妨再进一步,我们同样也可以动态地导入已经定义好的函数啊。

只要我们先在一个Python模块文件中定义好测试用例所需的函数,然后在运行测试用例的时候设法将模块中的所有函数导入即可。

于是,问题就转换为,如何在YAML/JSON中实现from moduleA import *机制。

经过摸索,我发现了Pythonvars函数,这也是PythonBuilt-in Functions之一。

对于vars,官方的定义如下:

Return the __dict__ attribute for a module, class, instance, or any other object with a __dict__ attribute.

简言之,就是vars()可以将模块(module)、类(class)、实例(instance)或者任意对象的所有属性(包括但不限于定义的方法和变量),以字典的形式返回。

还是前面举例的moduelA,相信大家看完下面这个例子就清晰了。

>>> import moduleA
>>> vars(moduleA)
>>> {'hello': <function hello at 0x1072fcd90>, 'varA': 'I am varA'}

掌握了这一层理论基础,我们就可以继续改造我们的测试框架了。

我采取的做法是,在测试用例中新增一个import_module_functions模块,里面可填写多个模块的路径。而测试用例中所有需要使用的函数,都定义在对应路径的模块中。

我们再回到之前的案例,在测试用例中需要用到gen_random_stringgen_md5这两个函数函数,那么就可以将其定义在一个模块中,假设模块名称为custom_functions.py,相对于项目根目录的路径为test/data/custom_functions.py

import hashlib
import random
import string

def gen_random_string(str_len):
    return ''.join(
        random.choice(string.ascii_letters + string.digits) for _ in range(str_len))

def gen_md5(*args):
    return hashlib.md5("".join(args).encode('utf-8')).hexdigest()

需要注意的是,这里的模块文件可以放置在系统的任意路径下,但是一定要保证它可作为Python的模块进行访问,也就是说在该文件的所有父目录中,都包含__init__.py文件。这是Python的语法要求,如不理解可查看官方文档。

然后,在YAML/JSON测试用例描述的import_module_functions栏目中,我们就可以写为test.data.custom_functions

新的用例描述形式就变成了如下样子。

- 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}

现在函数已经定义好了,那是怎样实现动态加载的呢?

首先,还是借助于importlib.import_module,实现模块的导入。

imported = importlib.import_module(module_name)

然后,借助于vars函数,可以获取得到模块的所有属性,也就是其中定义的方法、变量等对象。

vars(imported)

不过,由于我们只需要定义的函数,因此我们还可以通过进行过滤,只获取模块中的所有方法对象。当然,这一步不是必须的。

imported_functions_dict = dict(filter(is_function, vars(imported).items()))

其中,is_function是一个检测指定对象是否为方法的函数,实现形式如下:

import types

def is_function(tup):
    """ Takes (name, object) tuple, returns True if it is a function.
    """
    name, item = tup
    return isinstance(item, types.FunctionType)

通过以上代码,就实现了从指定外部模块加载所有方法的功能。完整的代码如下:


def import_module_functions(self, modules, level="testcase"):
   """ import modules and bind all functions within the context
   """
   for module_name in modules:
       imported = importlib.import_module(module_name)
       imported_functions_dict = dict(filter(is_function, vars(imported).items()))
       self.__update_context_config(level, "functions", imported_functions_dict)

结合到实际项目,我们就可以采取这种协作模式:

可以看出,这也算是软件工程和实际项目中的一种权衡之计,但好处在于能充分发挥各岗位角色人员的职能,有助于接口测试自动化工作的顺利开展。

总结

本文介绍了在YAML/JSON测试用例中实现Python函数定义的两种方法:

到现在为止,我们已经清楚了如何在YAML/JSON测试用例中实现函数的定义,但是在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)}终究只是文本字符串,程序是如何将其解析为真实的函数和参数,并实现调用的呢?

下篇文章再详细讲解。

相关文章


↙↙↙阅读原文可查看相关链接,并与作者交流