理想国

我要看尽这世间繁华


  • 首页

  • 归档

  • 标签

  • 分类

  • 关于

  • 搜索

ApiTestEngine 集成 Locust 实现更好的性能测试体验

发表于 2017-08-27 | 更新于 2019-04-03 | 分类于 Development , 测试框架

ApiTestEngine不是接口测试框架么,也能实现性能测试?

是的,你没有看错,ApiTestEngine集成了Locust性能测试框架,只需一份测试用例,就能同时实现接口自动化测试和接口性能测试,在不改变Locust任何特性的情况下,甚至比Locust本身更易用。

如果你还没有接触过Locust这款性能测试工具,那么这篇文章可能不适合你。但我还是强烈推荐你了解一下这款工具。简单地说,Locust是一款采用Python语言编写实现的开源性能测试工具,简洁、轻量、高效,并发机制基于gevent协程,可以实现单机模拟生成较高的并发压力。关于Locust的特性介绍和使用教程,我之前已经写过不少,你们可以在我的博客中找到对应文章。

如果你对实现的过程没有兴趣,可以直接跳转到文章底部,看最终实现效果章节。

灵感来源

在当前市面上的测试工具中,接口测试和性能测试基本上是两个泾渭分明的领域。这也意味着,针对同一个系统的服务端接口,我们要对其实现接口自动化测试和接口性能测试时,通常都是采用不同的工具,分别维护两份测试脚本或用例。

之前我也是这么做的。但是在做了一段时间后我就在想,不管是接口功能测试,还是接口性能测试,核心都是要模拟对接口发起请求,然后对接口响应内容进行解析和校验;唯一的差异在于,接口性能测试存在并发的概念,相当于模拟了大量用户同时在做接口测试。

既然如此,那接口自动化测试用例和接口性能测试脚本理应可以合并为一套,这样就可以避免重复的脚本开发工作了。

在开发ApiTestEngine的过程中,之前的文章也说过,ApiTestEngine完全基于Python-Requests库实现HTTP的请求处理,可以在编写接口测试用例时复用到Python-Requests的所有功能特性。而之前在学习Locust的源码时,发现Locust在实现HTTP请求的时候,也完全是基于Python-Requests库。

在这一层关系的基础上,我提出一个大胆的设想,能否通过一些方式或手段,可以使ApiTestEngine中编写的YAML/JSON格式的接口测试用例,也能直接让Locust直接调用呢?

灵感初探

想法有了以后,就开始探索实现的方法了。

首先,我们可以看下Locust的脚本形式。如下例子是一个比较简单的场景(截取自官网首页)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from locust import HttpLocust, TaskSet, task

class WebsiteTasks(TaskSet):
def on_start(self):
self.client.post("/login", {
"username": "test_user",
"password": ""
})

@task
def index(self):
self.client.get("/")

@task
def about(self):
self.client.get("/about/")

class WebsiteUser(HttpLocust):
task_set = WebsiteTasks
min_wait = 5000
max_wait = 15000

在Locust的脚本中,我们会在TaskSet子类中描述单个用户的行为,每一个带有@task装饰器的方法都对应着一个HTTP请求场景。而Locust的一个很大特点就是,所有的测试用例脚本都是Python文件,因此我们可以采用Python实现各种复杂的场景。

等等!模拟单个用户请求,而且还是纯粹的Python语言,我们不是在接口测试中已经实现的功能么?

例如,下面的代码就是从单元测试中截取的测试用例。

1
2
3
4
5
def test_run_testset(self):
testcase_file_path = os.path.join(
os.getcwd(), 'examples/quickstart-demo-rev-3.yml')
testsets = utils.load_testcases_by_path(testcase_file_path)
results = self.test_runner.run_testset(testsets[0])

test_runner.run_testset是已经在ApiTestEngine中实现的方法,作用是传入测试用例(YAML/JSON)的路径,然后就可以加载测试用例,运行整个测试场景。并且,由于我们在测试用例YAML/JSON中已经描述了validators,即接口的校验部分,因此我们也无需再对接口响应结果进行校验描述了。

接下来,实现方式就非常简单了。

我们只需要制作一个locustfile.py的模板文件,内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#coding: utf-8
import zmq
import os
from locust import HttpLocust, TaskSet, task
from ate import utils, runner

class WebPageTasks(TaskSet):
def on_start(self):
self.test_runner = runner.Runner(self.client)
self.testset = self.locust.testset

@task
def test_specified_scenario(self):
self.test_runner.run_testset(self.testset)

class WebPageUser(HttpLocust):
host = ''
task_set = WebPageTasks
min_wait = 1000
max_wait = 5000

testcase_file_path = os.path.join(os.getcwd(), 'skypixel.yml')
testsets = utils.load_testcases_by_path(testcase_file_path)
testset = testsets[0]

可以看出,整个文件中,只有测试用例文件的路径是与具体测试场景相关的,其它内容全都可以不变。

于是,针对不同的测试场景,我们只需要将testcase_file_path替换为接口测试用例文件的路径,即可实现对应场景的接口性能测试。

1
2
3
➜  ApiTestEngine git:(master) ✗ locust -f locustfile.py
[2017-08-27 11:30:01,829] bogon/INFO/locust.main: Starting web monitor at *:8089
[2017-08-27 11:30:01,831] bogon/INFO/locust.main: Starting Locust 0.8a2

后面的操作就完全是Locust的内容了,使用方式完全一样。

优化1:自动生成locustfile

通过前面的探索实践,我们基本上就实现了一份测试用例同时兼具接口自动化测试和接口性能测试的功能。

然而,在使用上还不够便捷,主要有两点:

  • 需要手工修改模板文件中的testcase_file_path路径;
  • locustfile.py模板文件的路径必须放在ApiTestEngine的项目根目录下。

于是,我产生了让ApiTestEngine框架本身自动生成locustfile.py文件的想法。

在实现这个想法的过程中,我想过两种方式。

第一种,通过分析Locust的源码,可以看到Locust在main.py中具有一个load_locustfile方法,可以加载Python格式的文件,并提取出其中的locust_classes(也就是Locust的子类);后续,就是将locust_classes作为参数传给Locust的Runner了。

若采用这种思路,我们就可以实现一个类似load_locustfile的方法,将YAML/JSON文件中的内容动态生成locust_classes,然后再传给Locust的Runner。这里面会涉及到动态地创建类和添加方法,好处是不需要生成locustfile.py中间文件,并且可以实现最大的灵活性,但缺点在于需要改变Locust的源码,即重新实现Locust的main.py中的多个函数。虽然难度不会太大,但考虑到后续需要与Locust的更新保持一致,具有一定的维护工作量,便放弃了该种方案。

第二种,就是生成locustfile.py这样一个中间文件,然后将文件路径传给Locust。这样的好处在于我们可以不改变Locust的任何地方,直接对其进行使用。与Locust的传统使用方式差异在于,之前我们是在Terminal中通过参数启动Locust,而现在我们是在ApiTestEngine框架中通过Python代码启动Locust。

具体地,我在setup.py的entry_points中新增了一个命令locusts,并绑定了对应的程序入口。

1
2
3
4
5
6
entry_points={
'console_scripts': [
'ate=ate.cli:main_ate',
'locusts=ate.cli:main_locust'
]
}

在ate/cli.py中新增了main_locust函数,作为locusts命令的入口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def main_locust():
""" Performance test with locust: parse command line options and run commands.
"""
try:
from locust.main import main
except ImportError:
print("Locust is not installed, exit.")
exit(1)

sys.argv[0] = 'locust'
if len(sys.argv) == 1:
sys.argv.extend(["-h"])

if sys.argv[1] in ["-h", "--help", "-V", "--version"]:
main()
sys.exit(0)

try:
testcase_index = sys.argv.index('-f') + 1
assert testcase_index < len(sys.argv)
except (ValueError, AssertionError):
print("Testcase file is not specified, exit.")
sys.exit(1)

testcase_file_path = sys.argv[testcase_index]
sys.argv[testcase_index] = parse_locustfile(testcase_file_path)
main()

若你执行locusts -V或locusts -h,会发现效果与locust的特性完全一致。

1
2
3
$ locusts -V
[2017-08-27 12:41:27,740] bogon/INFO/stdout: Locust 0.8a2
[2017-08-27 12:41:27,740] bogon/INFO/stdout:

事实上,通过上面的代码(main_locust)也可以看出,locusts命令只是对locust进行了一层封装,用法基本等价。唯一的差异在于,当-f参数指定的是YAML/JSON格式的用例文件时,会先转换为Python格式的locustfile.py,然后再传给locust。

至于解析函数parse_locustfile,实现起来也很简单。我们只需要在框架中保存一份locustfile.py的模板文件(ate/locustfile_template),并将testcase_file_path采用占位符代替。然后,在解析函数中,就可以读取整个模板文件,将其中的占位符替换为YAML/JSON用例文件的实际路径,然后再保存为locustfile.py,并返回其路径即可。

具体的代码就不贴了,有兴趣的话可自行查看。

通过这一轮优化,ApiTestEngine就继承了Locust的全部功能,并且可以直接指定YAML/JSON格式的文件启动Locust执行性能测试。

1
2
3
$ locusts -f examples/first-testcase.yml
[2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089
[2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2

优化2:一键启动多个locust实例

经过第一轮优化后,本来应该是告一段落了,因为此时ApiTestEngine已经可以非常便捷地实现接口自动化测试和接口性能测试的切换了。

直到有一天,在TesterHome论坛讨论Locust的一个回复中,@keithmork说了这么一句话。

期待有一天ApiTestEngine的热度超过Locust本身

看到这句话时我真的不禁泪流满面。虽然我也是一直在用心维护ApiTestEngine,却从未有过这样的奢望。

但反过来细想,为啥不能有这样的想法呢?当前ApiTestEngine已经继承了Locust的所有功能,在不影响Locust已有特性的同时,还可以采用YAML/JSON格式来编写维护测试用例,并实现了一份测试用例可同时用于接口自动化和接口性能测试的目的。

这些特性都是Locust所不曾拥有的,而对于使用者来说的确也都是比较实用的功能。

于是,新的目标在内心深处萌芽了,那就是在ApiTestEngine中通过对Locust更好的封装,让Locust的使用者体验更爽。

然后,我又想到了自己之前做的一个开源项目,debugtalk/stormer。当时做这个项目的初衷在于,当我们使用Locust进行压测时,要想使用压测机所有CPU的性能,就需要采用master-slave模式。因为Locust默认是单进程运行的,只能运行在压测机的一个CPU核上;而通过采用master-slave模式,启动多个slave,就可以让不同的slave运行在不同的CPU核上,从而充分发挥压测机多核处理器的性能。

而在实际使用Locust的时候,每次只能手动启动master,并依次手动启动多个slave。若遇到测试脚本调整的情况,就需要逐一结束Locust的所有进程,然后再重复之前的启动步骤。如果有使用过Locust的同学,应该对此痛苦的经历都有比较深的体会。当时也是基于这一痛点,我开发了debugtalk/stormer,目的就是可以一次性启动或销毁多个Locust实例。这个脚本做出来后,自己用得甚爽,也得到了Github上一些朋友的青睐。

既然现在要提升ApiTestEngine针对Locust的使用便捷性,那么这个特性毫无疑问也应该加进去。就此,debugtalk/stormer项目便被废弃,正式合并到debugtalk/ApiTestEngine。

想法明确后,实现起来也挺简单的。

原则还是保持不变,那就是不改变Locust本身的特性,只在传参的时候在中间层进行操作。

具体地,我们可以新增一个--full-speed参数。当不指定该参数时,使用方式跟之前完全相同;而指定--full-speed参数后,就可以采用多进程的方式启动多个实例(实例个数等于压测机的处理器核数)。

1
2
3
4
5
6
7
def main_locust():
# do original work

if "--full-speed" in sys.argv:
locusts.run_locusts_at_full_speed(sys.argv)
else:
locusts.main()

具体实现逻辑在ate/locusts.py中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import multiprocessing
from locust.main import main

def start_master(sys_argv):
sys_argv.append("--master")
sys.argv = sys_argv
main()

def start_slave(sys_argv):
sys_argv.extend(["--slave"])
sys.argv = sys_argv
main()

def run_locusts_at_full_speed(sys_argv):
sys_argv.pop(sys_argv.index("--full-speed"))
slaves_num = multiprocessing.cpu_count()

processes = []
for _ in range(slaves_num):
p_slave = multiprocessing.Process(target=start_slave, args=(sys_argv,))
p_slave.daemon = True
p_slave.start()
processes.append(p_slave)

try:
start_master(sys_argv)
except KeyboardInterrupt:
sys.exit(0)

由此可见,关键点也就是使用了multiprocessing.Process,在不同的进程中分别调用Locust的main()函数,实现逻辑十分简单。

最终实现效果

经过前面的优化,采用ApiTestEngine执行性能测试时,使用就十分便捷了。

安装ApiTestEngine后,系统中就具有了locusts命令,使用方式跟Locust框架的locust几乎完全相同,我们完全可以使用locusts命令代替原生的locust命令。

例如,下面的命令执行效果与locust完全一致。

1
2
3
4
5
$ locusts -V
$ locusts -h
$ locusts -f locustfile.py
$ locusts -f locustfile.py --master -P 8088
$ locusts -f locustfile.py --slave &

差异在于,locusts具有更加丰富的功能。

在ApiTestEngine中编写的YAML/JSON格式的接口测试用例文件,直接运行就可以启动Locust运行性能测试。

1
2
3
$ locusts -f examples/first-testcase.yml
[2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089
[2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2

加上--full-speed参数,就可以同时启动多个Locust实例(实例个数等于处理器核数),充分发挥压测机多核处理器的性能。

1
2
3
4
5
6
7
8
9
10
11
$ locusts -f examples/first-testcase.yml --full-speed -P 8088
[2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8088
[2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2
[2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2
[2017-08-26 23:51:47,080] bogon/INFO/locust.main: Starting Locust 0.8a2
[2017-08-26 23:51:47,083] bogon/INFO/locust.main: Starting Locust 0.8a2
[2017-08-26 23:51:47,084] bogon/INFO/locust.runners: Client 'bogon_656e0af8e968a8533d379dd252422ad3' reported as ready. Currently 1 clients ready to swarm.
[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_09f73850252ee4ec739ed77d3c4c6dba' reported as ready. Currently 2 clients ready to swarm.
[2017-08-26 23:51:47,084] bogon/INFO/locust.main: Starting Locust 0.8a2
[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_869f7ed671b1a9952b56610f01e2006f' reported as ready. Currently 3 clients ready to swarm.
[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm.

后续,ApiTestEngine将持续进行优化,欢迎大家多多反馈改进建议。

Enjoy!

ApiTestEngine QuickStart

发表于 2017-08-20 | 更新于 2019-04-03 | 分类于 Development , 测试框架

Introduction to Sample Interface Service

Along with this project, I devised a sample interface service, and you can use it to familiarize how to play with ApiTestEngine.

This sample service mainly has two parts:

  • Authorization, each request of other APIs should sign with some header fields and get token first.
  • RESTful APIs for user management, you can do CRUD manipulation on users.

As you see, it is very similar to the mainstream production systems. Therefore once you are familiar with handling this demo service, you can master most test scenarios in your project.

Launch Sample Interface Service

The demo service is a flask server, we can launch it in this way.

1
2
3
4
$ export FLASK_APP=tests/api_server.py
$ flask run
* Serving Flask app "tests.api_server"
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Now the sample interface service is running, and we can move on to the next step.

Capture HTTP request and response

Before we write testcases, we should know the details of the API. It is a good choice to use a web debugging proxy tool like Charles Proxy to capture the HTTP traffic.

For example, the image below illustrates getting token from the sample service first, and then creating one user successfully.

After thorough understanding of the APIs, we can now begin to write testcases.

Write the first test case

Open your favorite text editor and you can write test cases like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: POST
headers:
user_agent: iOS/10.3
device_sn: 9TN6O2Bn1vzfybF
os_platform: ios
app_version: 2.8.6
json:
sign: 19067cf712265eb5426db8d3664026c1ccea02b9

- test:
name: create user which does not exist
request:
url: http://127.0.0.1:5000/api/users/1000
method: POST
headers:
device_sn: 9TN6O2Bn1vzfybF
token: F8prvGryC5beBr4g
json:
name: "user1"
password: "123456"
validators:
- {"check": "status_code", "comparator": "eq", "expected": 201}
- {"check": "content.success", "comparator": "eq", "expected": true}

As you see, each API request is described in a test block. And in the request field, it describes the detail of HTTP request, includes url, method, headers and data, which are in line with the captured traffic.

You may wonder why we use the json field other than data. That’s because the post data is in JSON format, when we use json to indicate the post data, we do not have to specify Content-Type to be application/json in request headers or dump data before request.

Have you recalled some familiar scenes?

Yes! That’s what we did in requests.request! Since ApiTestEngine takes full reuse of Requests, it inherits all powerful features of Requests, and we can handle HTTP request as the way we do before.

Run test cases

Suppose the test case file is named as quickstart-demo-rev-0.yml and is located in examples folder, then we can run it in this way.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ate examples/demo-rev-0.yml
Running tests...
----------------------------------------------------------------------
get token ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 48 ms, response_length: 46 bytes
OK (0.049669)s
create user which does not exist ... INFO:root: Start to POST http://127.0.0.1:5000/api/users/1000
ERROR:root: Failed to POST http://127.0.0.1:5000/api/users/1000! exception msg: 403 Client Error: FORBIDDEN for url: http://127.0.0.1:5000/api/users/1000
ERROR (0.006471)s
----------------------------------------------------------------------
Ran 2 tests in 0.056s

FAILED
(Errors=1)

Oops! The second test case failed with 403 status code.

That is because we request with the same data as we captured in Charles Proxy, while the token is generated dynamically, thus the recorded data can not be be used twice directly.

Optimize test case: correlation

To fix this problem, we should correlate token field in the second API test case, which is also called correlation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: POST
headers:
user_agent: iOS/10.3
device_sn: 9TN6O2Bn1vzfybF
os_platform: ios
app_version: 2.8.6
json:
sign: 19067cf712265eb5426db8d3664026c1ccea02b9
extractors:
- token: content.token
validators:
- {"check": "status_code", "comparator": "eq", "expected": 200}
- {"check": "content.token", "comparator": "len_eq", "expected": 16}

- test:
name: create user which does not exist
request:
url: http://127.0.0.1:5000/api/users/1000
method: POST
headers:
device_sn: 9TN6O2Bn1vzfybF
token: $token
json:
name: "user1"
password: "123456"
validators:
- {"check": "status_code", "comparator": "eq", "expected": 201}
- {"check": "content.success", "comparator": "eq", "expected": true}

As you see, the token field is no longer hardcoded, instead it is extracted from the first API request with extractors mechanism. In the meanwhile, it is assigned to token variable, which can be referenced by the subsequent API requests.

Now we save the test cases to quickstart-demo-rev-1.yml and rerun it, and we will find that both API requests to be successful.

Optimize test case: parameterization

Let’s look back to our test set quickstart-demo-rev-1.yml, and we can see the device_sn field is still hardcoded. This may be quite different from the actual scenarios.

In actual scenarios, each user’s device_sn is different, so we should parameterize the request parameters, which is also called parameterization. In the meanwhile, the sign field is calculated with other header fields, thus it may change significantly if any header field changes slightly.

However, the test cases are only YAML documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine Python scripts with YAML test cases in ApiTestEngine.

To achieve this goal, we can utilize import_module_functions and variables mechanisms.

To be specific, we can create a Python file (examples/utils.py) and implement the related algorithm in it. Since we want to import this file, so we should put a __init__.py in this folder to make it as a Python module.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import hashlib
import hmac
import random
import string

SECRET_KEY = "DebugTalk"

def get_sign(*args):
content = ''.join(args).encode('ascii')
sign_key = SECRET_KEY.encode('ascii')
sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest()
return sign

def gen_random_string(str_len):
random_char_list = []
for _ in range(str_len):
random_char = random.choice(string.ascii_letters + string.digits)
random_char_list.append(random_char)

random_string = ''.join(random_char_list)
return random_string

And then, we can revise our demo test case and reference the functions. Suppose the revised file named quickstart-demo-rev-2.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
- test:
name: get token
import_module_functions:
- examples.utils
variables:
- user_agent: 'iOS/10.3'
- device_sn: ${gen_random_string(15)}
- os_platform: 'ios'
- app_version: '2.8.6'
request:
url: http://127.0.0.1:5000/api/get-token
method: POST
headers:
user_agent: $user_agent
device_sn: $device_sn
os_platform: $os_platform
app_version: $app_version
json:
sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)}
extractors:
- token: content.token
validators:
- {"check": "status_code", "comparator": "eq", "expected": 200}
- {"check": "content.token", "comparator": "len_eq", "expected": 16}

- test:
name: create user which does not exist
request:
url: http://127.0.0.1:5000/api/users/1000
method: POST
headers:
device_sn: $device_sn
token: $token
json:
name: "user1"
password: "123456"
validators:
- {"check": "status_code", "comparator": "eq", "expected": 201}
- {"check": "content.success", "comparator": "eq", "expected": true}

In this revised test case, we firstly import module functions in import_module_functions block by specifying the Python module path, which is relative to the current working directory.

To make fields like device_sn can be used more than once, we also bind values to variables in variables block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it.

When we want to reference a variable in the test case, we can do this with a escape character $. For example, $user_agent will not be taken as a normal string, and ApiTestEngine will consider it as a variable named user_agent, search and return its binding value.

When we want to reference a function, we shall use another escape character ${}. Any content in ${} will be considered as function calling, so we should guarantee that we call functions in the right way. At the same time, variables can also be referenced as parameters of function.

Optimize test case: overall config block

There is still one issue unsolved.

The device_sn field is defined in the first API test case, thus it may be impossible to reference it in other test cases. Context separation is a well-designed mechanism, and we should obey this good practice.

To handle this case, overall config block is supported in ApiTestEngine. If we define variables or import functions in config block, these variables and functions will become global and can be referenced in the whole test set.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# examples/quickstart-demo-rev-3.yml
- config:
name: "smoketest for CRUD users."
import_module_functions:
- examples.utils
variables:
- device_sn: ${gen_random_string(15)}
request:
base_url: http://127.0.0.1:5000
headers:
device_sn: $device_sn

- test:
name: get token
variables:
- user_agent: 'iOS/10.3'
- os_platform: 'ios'
- app_version: '2.8.6'
request:
url: /api/get-token
method: POST
headers:
user_agent: $user_agent
os_platform: $os_platform
app_version: $app_version
json:
sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)}
extractors:
- token: content.token
validators:
- {"check": "status_code", "comparator": "eq", "expected": 200}
- {"check": "content.token", "comparator": "len_eq", "expected": 16}

- test:
name: create user which does not exist
request:
url: /api/users/1000
method: POST
headers:
token: $token
json:
name: "user1"
password: "123456"
validators:
- {"check": "status_code", "comparator": "eq", "expected": 201}
- {"check": "content.success", "comparator": "eq", "expected": true}

As you see, we import public Python modules and variables in config block. Also, we can set base_url in config block, thereby we can only specify relative path in each API request url. Besides, we can also set common fields in config request, such as device_sn in headers.

Until now, the test cases are finished and each detail is handled properly.

Run test cases and generate report

Finally, let’s run test set quickstart-demo-rev-4.yml once more.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ ate examples/quickstart-demo-rev-4.yml
Running tests...
----------------------------------------------------------------------
get token ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 33 ms, response_length: 46 bytes
OK (0.037027)s
create user which does not exist ... INFO:root: Start to POST http://127.0.0.1:5000/api/users/1000
INFO:root: status_code: 201, response_time: 15 ms, response_length: 54 bytes
OK (0.016414)s
----------------------------------------------------------------------
Ran 2 tests in 0.054s
OK

Generating HTML reports...
Template is not specified, load default template instead.
Reports generated: /Users/Leo/MyProjects/ApiTestEngine/reports/quickstart-demo-rev-0/2017-08-01-16-51-51.html

Great! The test case runs successfully and generates a HTML test report.

Further more

This is just a starting point, see the advanced guide for the advanced features.

  • templating
  • data extraction and validation
  • comparator

How to install a package from Github that has other github dependencies ?

发表于 2017-08-05 | 更新于 2019-04-03 | 分类于 Development

最近在开发ApiTestEngine时遇到一个安装包依赖的问题,耗费了不少时间寻找解决方案,考虑到还算比较有普遍性,因此总结形成这篇文章。

从 pip install 说起

先不那么简单地描述下背景。

ApiTestEngine作为一款接口测试工具,需要具有灵活的命令行调用方式,因此最好能在系统中进行安装并注册为一个CLI命令。

在Python中,安装依赖库的最佳方式是采用pip,例如安装Locust时,就可以采用如下命令搞定。

1
2
3
4
5
$ pip install locustio
Collecting locustio
Using cached locustio-0.7.5.tar.gz
[...]
Successfully installed locustio-0.7.5

但要想采用pip install SomePackage的方式,前提是SomePackage已经托管在PyPI。关于PyPI,可以理解为Python语言的第三方库的仓库索引,当前绝大多数流行的Python第三方库都托管在PyPI上。

但是,这里存在一个问题。在PyPI当中,所有的包都是由其作者自行上传的。如果作者比较懒,那么可能托管在PyPI上的最新版本相较于最新代码就会比较滞后。

Locust就是一个典型的例子。从上面的安装过程可以看出,我们采用pip install locustio安装的Locust版本是v0.7.5,而在Locust的Github仓库中,v0.7.5已经是一年之前的版本了。也是因为这个原因,之前在我的博客里面介绍Locust的图表展示功能后,已经有不下5个人向我咨询为啥他们看不到这个图表模块。这是因为Locust的图表模块是在今年(2017)年初时添加的功能,master分支的代码版本也已经升级到v0.8a2了,但PyPI上的版本却一直没有更新。

而要想使用到项目最新的功能,就只能采用源码进行安装。

大多数编程语言在使用源码进行安装时,都需要先将源码下载到本地,然后通过命令进行编译,例如Linux中常见的make && make install。对于Python项目来说,也可以采用类似的模式,先将项目clone到本地,然后进入到项目的根目录,执行python setup.py install。

1
2
3
4
5
$ git clone https://github.com/locustio/locust.git
$ cd locust
$ python setup.py install
[...]
Finished processing dependencies for locustio==0.8a2

不过,要想采用这种方式进行安装也是有前提的,那就是项目必须已经实现了基于setuptools的安装方式,并在项目的根目录下存在setup.py。

可以看出,这种安装方式还是比较繁琐的,需要好几步才能完成安装。而且,对于大多数使用者来说,他们并不需要阅读项目源码,因此clone操作也实属多余。

可喜的是,pip不仅支持安装PyPI上的包,也可以直接通过项目的git地址进行安装。还是以Locust项目为例,我们通过pip命令也可以实现一条命令安装Github项目源码。

1
2
3
4
$ pip install git+https://github.com/locustio/locust.git@master#egg=locustio
Collecting locustio from git+https://github.com/locustio/locust.git@master#egg=locustio
[...]
Successfully installed locustio-0.8a2

对于项目地址来说,完整的描述应该是:

1
pip install vcs+protocol://repo_url/#egg=pkg&subdirectory=pkg_dir

这里的vcs也不仅限于git,svn和hg也是一样的,而protocol除了采用SSH形式的项目地址,也可以采用HTTPS的地址,在此不再展开。

通过这种方式,我们就总是可以使用到项目的最新功能特性了。当然,前提条件也是一样的,需要项目中已经实现了setup.py。

考虑到ApiTestEngine还处于频繁的新特性开发阶段,因此这种途径无疑是让用户安装使用最新代码的最佳方式。

问题缘由

在ApiTestEngine中,存在测试结果报告展示这一部分的功能,而这部分的功能是需要依赖于另外一个托管在GitHub上的项目,PyUnitReport。

于是,问题就变为:如何构造ApiTestEngine项目的setup.py,可以实现用户在安装ApiTestEngine时自动安装PyUnitReport依赖。

对于这个需求,已经确定可行的办法:先通过pip安装依赖的库(PyUnitReport),然后再安装当前项目(ApiTestEngine)。

1
2
$ pip install git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport
$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine

这种方式虽然可行,但是需要执行两条命令,显然不是我们想要的效果。

经过搜索,发现针对该需求,可以在setuptools.setup()中通过install_requires和dependency_links这两个配置项组合实现。

具体地,配置方式如下:

1
2
3
4
5
6
7
8
9
10
11
install_requires=[
"requests",
"flask",
"PyYAML",
"coveralls",
"coverage",
"PyUnitReport"
],
dependency_links=[
"git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport"
],

这里有一点需要格外注意,那就是指定的依赖包如果存在于PyPI,那么只需要在install_requires中指定包名和版本号即可(不指定版本号时,默认安装最新版本);而对于以仓库URL地址存在的依赖包,那么不仅需要在dependency_links中指定,同时也要在install_requires中指定。

然后,就可以直接通过ApiTestEngine项目的git地址一键进行安装了。

1
$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine

虽然在寻找解决办法的过程中,看到大家都在说dependency_links由于安全性的问题,即将被弃用,而且在setuptools的官方文章中的确也没有看到dependency_links的描述。

1
DEPRECATION: Dependency Links processing has been deprecated and will be removed in a future release.

不过在我本地的macOS系统上尝试发现,该种方式的确是可行的,因此就采用这种方式进行发布了。

但是当我后续在Linux服务器上安装时,却无法成功,总是在安装PyUnitReport依赖库的时候报错:

1
2
3
4
5
$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine
[...]
Collecting PyUnitReport (from ApiTestEngine)
Could not find a version that satisfies the requirement PyUnitReport (from ApiTestEngine) (from versions: )
No matching distribution found for PyUnitReport (from ApiTestEngine)

另外,同时也有多个用户反馈了同样的问题,这才发现这种方式在Linux和Windows下是不行的。

然后,再次经过大量的搜索,却始终没有特别明确的答案,搞得我也在怀疑,dependency_links到底是不是真的已经弃用了,但是就算是弃用了,也应该有新的替代方案啊,但也并没有找到。

这个问题就这么放了差不多一个星期的样子。

解决方案

今天周末在家,想来想去,不解决始终不爽,虽然只是多执行一条命令的问题。

于是又是经过大量搜索,幸运的是终于从pypa/pip的issues中找到一条issue,作者是Dominik Neise,他详细描述了他遇到的问题和尝试过的方法,看到他的描述我真是惊呆了,跟我的情况完全一模一样不说,连尝试的思路也完全一致。

然后,在下面的回复中,看到了Gary Wu和kbuilds的解答,总算是找到了问题的原因和解决方案。

问题在于,在dependency_links中指定仓库URL地址的时候,在指定egg信息时,pip还同时需要一个版本号(version number),并且以短横线-分隔,然后执行的时候再加上--process-dependency-links参数。

回到之前的dependency_links,我们应该写成如下形式。

1
2
3
dependency_links=[
"git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0"
]

在这里,短横线-后面我并没有填写PyUnitReport实际的版本号,因为经过尝试发现,这里填写任意数值都是成功的,因此我就填写为0了,省得后续在升级PyUnitReport以后还要来修改这个地方。

然后,就可以通过如下命令进行安装了。

1
$ pip install --process-dependency-links git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine

至此,问题总算解决了。

后记

那么,dependency_links到底是不是要废弃了呢?

从pip的GitHub项目中看到这么一个issue,--process-dependency-links之前废弃了一段时间,但是又给加回来了,因为当前还没有更好的可替代的方案。因此,在出现替代方案之前,dependency_links应该是最好的方式了吧。

最后再感叹下,老外提问时描述问题的专业性和细致程度真是令人佩服,大家可以再仔细看下这个issue好好感受下。

阅读更多

  • http://setuptools.readthedocs.io/en/latest/setuptools.html#dependencies-that-aren-t-in-pypi
  • https://pip.pypa.io/en/stable/reference/pip_install/
  • https://github.com/pypa/pip/issues/3610
  • https://github.com/pypa/pip/issues/4187

解决 Jenkins 中无法展示 HTML 样式的问题

发表于 2017-07-24 | 更新于 2019-04-03 | 分类于 Development

问题描述

对于测试报告来说,除了内容的简洁精炼,样式的美观也很重要。常用的做法是,采用HTML格式的文档,并搭配CSS和JS,实现自定义的样式和动画效果(例如展开、折叠等)。

在Jenkins中要展示HTML文档,通常采用的方式有两种:

  • 使用HTML Publisher Plugin;
  • 使用Files to archive功能,在Build Artifacts中显示HTML文档链接。

第一种方式配合插件,可以通过图形化操作实现简易配置,并且展示效果也不错;而第二种方式的优势在于使用Jenkins自带的功能,不依赖插件也能实现基本的需求。

然而,不管是采用哪种方式,都有可能会遇到一种情况,就是展示出来的HTML报告样式全无。在浏览器的Network中查看资源加载情况,会发现相关的CSS和JS都没法正常加载。

1
2
3
4
Refused to load the stylesheet 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css' because it violates the following Content Security Policy directive: "style-src 'self'".
Refused to apply inline style because it violates the following Content Security Policy directive: "style-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-0EZqoz+oBhx7gF4nvY2bSqoGyy4zLjNF+SDQXGp/ZrY='), or a nonce ('nonce-...') is required to enable inline execution.
Blocked script execution in 'http://10.13.0.146:8888/job/SkyPixel-SmokeTest/34/artifact/reports/SkyPixel-smoketest/34.html' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
Refused to load the stylesheet 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css' because it violates the following Content Security Policy directive: "style-src 'self'".

问题分析

出现该现象的原因在于Jenkins中配置的CSP(Content Security Policy)。

简单地说,这是Jenkins的一个安全策略,默认会设置为一个非常严格的权限集,以防止Jenkins用户在workspace、/userContent、archived artifacts中受到恶意HTML/JS文件的攻击。

默认地,该权限集会设置为:

1
sandbox; default-src 'none'; img-src 'self'; style-src 'self';

在该配置下,只允许加载:

  • Jenkins服务器上托管的CSS文件
  • Jenkins服务器上托管的图片文件

而如下形式的内容都会被禁止:

  • JavaScript
  • plugins (object/embed)
  • HTML中的内联样式表(Inline style sheets),以及引用的外站CSS文件
  • HTML中的内联图片(Inline image definitions),以及外站引用的图片文件
  • frames
  • web fonts
  • XHR/AJAX
  • etc.

可以看出,这个限制非常严格,在此限制下也就不难理解为什么我们的HTML没法正常展示样式了。

解决方案

临时解决方案

要解决该问题,方式也比较简单,就是修改Content Security Policy的默认配置。

修改方式为,进入Manage Jenkins->Script console,输入如下命令并进行执行。

1
System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")

当看到如下结果后,则说明配置修改已经生效。

1
2
Result
Result:

再次进行构建,新生成的HTML就可以正常展示样式了。需要说明的是,该操作对之前构建生成的HTML报告无效。

永久解决方案

不过,该方法还存在一个问题:该配置只是临时生效,当重启Jenkins后,Content Security Policy又会恢复为默认值,从而HTML样式又没法展示了。

当前,Jenkins官方还没有相应的解决方法,我们只能在每次启动或重启Jenkins时,重新修改该安全策略。

如果手工地来重复这项工作,也是可行,但并不是一个好的解决方案。

回到刚才的Script console,会发现我们执行的命令其实就是一段Groovy代码;那么,如果我们可以实现在Jenkins每次启动时自动地执行该Groovy代码,那么也就同样能解决我们的问题了。

好在Jenkins已经有相应的插件:

  • Startup Trigger: 可实现在Jenkins节点(master/slave)启动时触发构建;
  • Groovy plugin: 可实现直接执行Groovy代码。

搜索安装startup-trigger-plugin和Groovy插件后,我们就可以进行配置了。

配置方式如下:

  • 新建一个job,该job专门用于Jenkins启动时执行的配置命令;
  • 在Build Triggers模块下,勾选Build when job nodes start;
  • 在Build模块下,Add build step->Execute system Groovy script,在Groovy Script中输入配置命令,System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")。

需要注意的是,添加构建步骤的时候,应该选择Execute system Groovy script,而不是Execute Groovy script。关于这两者之间的差异,简单地说,Groovy Script相当于是运行在master/slave系统JVM环境中,而system groovy script,则是运行在Jenkins master的JVM环境中,与前面提到的Jenkins Script Console功能相同。如需了解更多信息,可查看Groovy plugin的详细说明。

至此,我们就彻底解决HTML样式展示异常的问题了。

但还有一点需要格外注意,在本文的演示中,我们修改CSP(Content Security Policy)配置时关闭了的所有安全保护策略,即将hudson.model.DirectoryBrowserSupport.CSP设置为空,其实这是存在很大的安全隐患的。

正确的做法,我们应该是结合项目的实际情况,选择对应的安全策略。例如,如果我们需要开启脚本文件加载,但是只限于Jenkins服务器上托管的CSS文件,那么就可以采用如下配置。

1
System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "sandbox; style-src 'self';")

除此之外,CSP可以实现非常精细的权限配置,详细配置可参考Content Security Policy Reference。

阅读更多

  • Configuring Content Security Policy
  • Content Security Policy Reference

ApiTestEngine 演进之路(4)测试用例中实现 Python 函数的调用

发表于 2017-07-17 | 更新于 2019-04-03 | 分类于 Development , 测试框架

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

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

1
2
3
4
5
variables:
- TOKEN: debugtalk
- json: {}
- random: ${gen_random_string(5)}
- authorization: ${gen_md5($TOKEN, $json, $random)}

例如上面的例子(YAML格式),gen_random_string和gen_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)}

还是之前的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- test:
name: create user which does not exist
import_module_functions:
- tests.data.custom_functions
variables:
- 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}

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

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

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

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

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

正则表达式的妙用

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

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

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

1
r"^\$\{(\w+)\((.*)\)\}$"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> 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)就是函数的参数。

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

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

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

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

1
2
3
4
5
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)的调用形式。

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

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

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

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

对应的Python代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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转换后,就可以得到该函数的名称和参数信息:

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

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

实现函数的调用

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

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

1
2
3
4
func_name = function_meta['func_name']
args = function_meta['args']
kwargs = function_meta['kwargs']
custom_functions_dict[func_name](*args, **kwargs)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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)中实现函数的定义和调用
  • 如何在用例描述中实现参数的定义和引用,包括用例内部和用例集之间
  • 如何在用例描述中实现预期结果的描述和测试结果的校验

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

相关文章

  • 《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》
  • 《ApiTestEngine 演进之路(3)测试用例中实现Python函数的定义》
  • ApiTestEngine GitHub源码

ApiTestEngine 演进之路(3)测试用例中实现 Python 函数的定义

发表于 2017-07-11 | 更新于 2019-04-03 | 分类于 Development , 测试框架

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

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

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

实现函数的定义

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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中,函数参数共有四种,必选参数、默认参数、可变参数和关键字参数。

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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的实现方式就是如下的形式。

1
2
3
4
5
6
7
8
9
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模块,就可以将函数定义与函数名称绑定了。

1
2
3
4
5
6
7
8
9
10
- 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'))
variables:
- TOKEN: debugtalk
- random: ${gen_random_string(5)}
- json: {"name": "user", "password": "123456"}
- authorization: ${gen_md5($TOKEN, $json, $random)}

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

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

1
2
3
4
5
6
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_string为lambda字符串表达式,通过eval执行后,就转换为一个函数对象,然后就可以像正常定义的函数一样调用了。

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

1
2
3
4
5
6
>>> 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模块,罗列出当前测试用例所有需要引用的库,然后在加载用例的时候通过代码动态地进行导入依赖库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- 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'))
variables:
- TOKEN: debugtalk
- random: ${gen_random_string(5)}
- json: {"name": "user", "password": "123456"}
- authorization: ${gen_md5($TOKEN, $json, $random)}

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

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

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

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

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

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

1
random = __import__('random')

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

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

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

1
2
3
4
5
6
7
8
9
$ 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依赖库时,我们采用如下的写法也是等价的。

1
2
module_name = ”random“
globals()[module_name] = __import__(module_name)

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

1
2
module_name = ”random“
globals()[module_name] = importlib.import_module(module_name)

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

1
2
3
4
5
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中灵活地描述测试用例了。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- 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()"
variables:
- 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模块包含如下定义:

1
2
3
4
5
6
# moduleA

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

varA = "I am varA"

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

1
2
3
4
from moduleA import *

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

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

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

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

经过摸索,我发现了Python的vars函数,这也是Python的Built-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,相信大家看完下面这个例子就清晰了。

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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栏目中,我们就可以写为tests.data.custom_functions。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- test:
name: create user which does not exist
import_module_functions:
- tests.data.custom_functions
variables:
- 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,实现模块的导入。

1
imported = importlib.import_module(module_name)

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

1
vars(imported)

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

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

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

1
2
3
4
5
6
7
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)

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

1
2
3
4
5
6
7
8

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测试用例中,对模块进行引用;(对于测试用例集的模式,只需要引用一次,以后再详细讲解)
  • 业务测试人员只需要关注接口的业务数据字段,设计测试用例即可。

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

总结

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

  • 通过lambda实现函数的定义:该种方式适用于函数比较简单的情况,并且函数最好没有依赖库;虽然复杂的函数也能采用这种方式进行定义,但可能会存在一定的局限性,而且看上去也比较累赘。
  • 自定义函数模块并进行导入:该种方式通用性更强,所有类型的函数都可以通过这种方式进行定义和引用;但由于需要编写额外的Python模块文件,在函数比较简单的情况下反而会显得较为繁琐,此时采用lambda形式会更简洁。

到现在为止,我们已经清楚了如何在YAML/JSON测试用例中实现函数的定义,但是在YAML/JSON文本中要怎样实现函数的调用和传参呢?

1
2
3
4
5
variables:
- TOKEN: debugtalk
- json: {}
- random: ${gen_random_string(5)}
- authorization: ${gen_md5($TOKEN, $json, $random)}

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

下篇文章再详细讲解。

相关文章

  • 《Python的函数式编程–从入门到⎡放弃⎦》
  • 《接口自动化测试的最佳工程实践(ApiTestEngine)》
  • 《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》
  • ApiTestEngine GitHub源码

ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式

发表于 2017-07-07 | 更新于 2019-04-03 | 分类于 Development , 测试框架

在《ApiTestEngine 演进之路(1)搭建基础框架》一文中,我们完成了ApiTestEngine基础框架的搭建,并实现了简单接口的测试功能。

接下来,我们就针对复杂类型的接口(例如包含签名校验等机制),通过对接口的业务参数和技术细节进行分离,实现简洁优雅的接口测试用例描述。

传统的测试用例编写方式

对于在自动化测试中将测试数据与代码实现进行分离的好处,我之前已经讲过多次,这里不再重复。

测试数据与代码实现分离后,简单的接口还好,测试用例编写不会有什么问题;但是当面对复杂一点的接口(例如包含签名校验等机制)时,我们编写自动化测试用例还是会比较繁琐。

我们从一个最常见的案例入手,看下编写自动化测试用例的过程,相信大家看完后就会对上面那段话有很深的感受。

以API接口服务(Mock Server)的创建新用户功能为例,该接口描述如下:

请求数据:
Url: http://127.0.0.1:5000/api/users/1000
Method: POST
Headers: {“content-type”: “application/json”, “Random”: “A2dEx”, “Authorization”: “47f135c33e858f2e3f55156ae9f78ee1”}
Body: {“name”: “user1”, “password”: “123456”}

预期的正常响应数据:
Status_Code: 201
Headers: {‘Date’: ‘Fri, 23 Jun 2017 07:05:41 GMT’, ‘Content-Length’: ‘54’, ‘Content-Type’: ‘application/json’, ‘Server’: ‘Werkzeug/0.12.2 Python/2.7.13’}
Body: {“msg”: “user created successfully.”, “success”: true, “uuid”: “JsdfwerL”}

其中,请求Headers中的Random字段是一个5位长的随机字符串,Authorization字段是一个签名值,签名方式为TOKEN+RequestBody+Random拼接字符串的MD5值。更具体的,RequestBody要求字典的Key值按照由小到大的排序方式。接口请求成功后,返回的是一个JSON结构,里面的success字段标识请求成功与否的状态,如果成功,uuid字段标识新创建用户的唯一ID。

相信只要是接触过接口测试的同学对此应该都会很熟悉,这也是后台系统普遍采用的签名校验方式。在具体的系统中,可能字符串拼接方式或签名算法存在差异,但是模式基本上都是类似的。

那么面对这样一个接口,我们会怎样编写接口测试用例呢?

首先,请求的数据是要有的,我们会先准备一个可用的账号,例如{"password": "123456", "name": "user1"}。

然后,由于接口存在签名校验机制,因此我们除了要知道服务器端使用的TOKEN(假设为debugtalk)外,还要准备好Random字段和Authorization字段。Random字段好说,我们随便生成一个,例如A2dEx;Authorization字段就会复杂不少,需要我们按照规定先将RequestBody根据字典的Key值进行排序,得到{"name": "user1", "password": "123456"},然后与TOKEN和Random字段拼接字符串得到debugtalk{"password": "123456", "name": "user1"}A2dEx,接着再找一个MD5工具,计算得到签名值a83de0ff8d2e896dbd8efb81ba14e17d。

最后,我们才可以完成测试用例的编写。假如我们采用YAML编写测试用例,那么用例写好后应该就是如下样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-
name: create user which does not exist
request:
url: http://127.0.0.1:5000/api/users/1000
method: POST
headers:
Content-Type: application/json
authorization: a83de0ff8d2e896dbd8efb81ba14e17d
random: A2dEx
data:
name: user1
password: 123456
response:
status_code: 201
headers:
Content-Type: application/json
body:
success: true
msg: user created successfully.
uuid: JsdfwerL

该测试用例可以在ApiTestEngine中正常运行,我们也可以采用同样的方式,对系统的所有接口编写测试用例,以此实现项目的接口自动化测试覆盖。

但问题在于,每个接口通常会对应多条测试用例,差异只是在于请求的数据会略有不同,而测试用例量越大,我们人工去准备测试数据的工作量也就越大。更令人抓狂的是,我们的系统接口不是一直不变的,有时候会根据业务需求的变化进行一些调整,相应地,我们的测试数据也需要进行同步更新,这样一来,所有相关的测试用例数据就又得重新计算一遍(任意字段数据产生变化,签名值就会大不相同)。

可以看出,如果是采用这种方式编写维护接口测试用例,人力和时间成本都会非常高,最终的结果必然是接口自动化测试难以在实际项目中得以开展。

理想的用例描述方式

在上面案例中,编写接口测试用例时之所以会很繁琐,主要是因为接口存在签名校验机制,导致我们在准备测试数据时耗费了太多时间在这上面。

然而,对于测试人员来说,接口的业务功能才是需要关注的,至于接口采用什么签名校验机制这类技术细节,的确不应耗费过多时间和精力。所以,我们的接口测试框架应该设法将接口的技术细节实现和业务参数进行拆分,并能自动处理与技术细节相关的部分,从而让业务测试人员只需要关注业务参数部分。

那要怎么实现呢?

在开始实现之前,我们不妨借鉴BDD(行为驱动开发)的思想,先想下如何编写接口测试用例的体验最友好,换句话说,就是让业务测试人员写用例写得最爽。

还是上面案例的接口测试用例,可以看出,最耗时的地方主要是计算签名校验值部分。按理说,签名校验算法我们是已知的,要是可以在测试用例中直接调用签名算法函数就好了。

事实上,这也是各种模板语言普遍采用的方式,例如Jinja2模板语言,可以在{% %}中执行函数语句,在{{ }}中可以调用变量参数。之前我在设计[AppiumBooster][AppiumBooster]时也采用了类似的思想,可以通过${config.TestEnvAccount.UserName}的方式在测试用例中引用预定义的全局变量。

基于该思路,假设我们已经实现了gen_random_string这样一个生成指定位数的随机字符串的函数,以及gen_md5这样一个计算签名校验值的函数,那么我们就可以尝试采用如下方式来描述我们的测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- test:
name: create user which does not exist
variables:
- TOKEN: debugtalk
- random: ${gen_random_string(5)}
- json: {"name": "user", "password": "123456"}
- 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
extractors:
user_uuid: content.uuid
validators:
- {"check": "status_code", "comparator": "eq", "expected": 201}
- {"check": "content.success", "comparator": "eq", "expected": true}

在如上用例中,用到了两种转义符:

  • $作为变量转义符,$var将不再代表的是普遍的字符串,而是var变量的值;
  • ${}作为函数的转义符,${}内可以直接填写函数名称及调用参数,甚至可以包含变量。

为什么会选择采用这种描述方式?(Why?)

其实这也是我经过大量思考和实践之后,才最终确定的描述方式。如果真要讲述这个思路历程。。。还是不细说了,此处可省下一万字。(主要的思路无非就是要实现转义的效果,并且表达要简洁清晰,因此必然会用到特殊字符;而特殊字符在YAML中大多都已经有了特定的含义,排除掉不可用的之后,剩下的真没几个了,然后再借鉴其它框架常用的符号,所以说最终选择$和${}也算是必然。)

可以确定的是,这种描述方式的好处非常明显,不仅可以实现复杂计算逻辑的函数调用,还可以实现变量的定义和引用。

除了转义符,由于接口测试中经常需要对结果中的特定字段进行提取,作为后续接口请求的参数,因此我们实现了extractors这样一个结果提取器,只要返回结果是JSON类型,就可以将其中的任意字段进行提取,并保存到一个变量中,方便后续接口请求进行引用。

另外,为了更好地实现对接口响应结果的校验,我们废弃了先前的方式,实现了独立的结果校验器validators。这是因为,很多时候在比较响应结果时,并不能简单地按照字段值是否相等来进行校验,除此之外,我们可能还需要检查某个字段的长度是否为指定位数,元素列表个数是否大于某个数值,甚至某个字符串是否满足正则匹配等等。

相信你们肯定会想,以上这些描述方式的确是很简洁,但更多地感觉是在臆想,就像开始说的gen_random_string和gen_md5函数,我们只是假设已经定义好了。就算描述得再优雅再完美,终究也还只是YAML/JSON文本格式而已,要怎样才能转换为执行的代码呢?

这就要解决How?的问题了。

嗯,这就是用例模板引擎的核心了,也算是ApiTestEngine最核心的功能特性。

更具体的,从技术实现角度,主要分为三大块:

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

这三大块内容涉及到较多的技术实现细节,我们将在后续的文章中结合代码逐个深入进行讲解。

阅读更多

  • 《接口自动化测试的最佳工程实践(ApiTestEngine)》
  • 《ApiTestEngine 演化之路(0)开发未动,测试先行》
  • 《ApiTestEngine 演进之路(1)搭建基础框架》
  • ApiTestEngine GitHub源码

300 行 Python 代码打造实用接口测试框架

发表于 2017-06-28 | 更新于 2019-04-03 | 分类于 Development , 测试框架

在刚开始实现ApiTestEngine的时候,卡斯(kasi)提议做一个Java版的。对于这样的建议,我当然是拒绝的,瞬即回复了他,“人生苦短,回头是岸啊”。

当然,我没好意思跟他说的是,我不会Java啊。不过最主要的原因嘛,还是因为Python的语法简洁,可以采用很少的代码量实现丰富的功能。

有多简洁呢?

刚在coveralls上看了下ApiTestEngine框架的代码统计行数,总行数只有268行,还不足300行。

当然,这个行数指的是框架本身的Python代码行数,不包括示例注释的行数。从上图可以看出来,LINES列是文件总行数,RELEVANT列是实际的Python代码行数。例如ate/runner.py文件,注释的行数是远多于实际代码行数的。

最极端的一个例子是,ate/testcase.py文件中的parse函数,示例注释行数35行,Python代码只有2行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def parse(self, testcase_template):
""" parse testcase_template, replace all variables with bind value.
variables marker: ${variable}.
@param (dict) testcase_template
{
"request": {
"url": "http://127.0.0.1:5000/api/users/${uid}",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"authorization": "${authorization}",
"random": "${random}"
},
"body": "${data}"
},
"response": {
"status_code": "${expected_status}"
}
}
@return (dict) parsed testcase with bind values
{
"request": {
"url": "http://127.0.0.1:5000/api/users/1000",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"authorization": "a83de0ff8d2e896dbd8efb81ba14e17d",
"random": "A2dEx"
},
"body": '{"name": "user", "password": "123456"}'
},
"response": {
"status_code": 201
}
}
"""
return self.substitute(testcase_template)

另外,如果算上单元测试用例的行数(731行),总的Python代码行数能达到1000行的样子。嗯,代码可以精简,但是单元测试覆盖率还是要保证的,不达到90%以上的单元测试覆盖率,真不好意思说自己做了开源项目啊。

那这不足300行的Python代码,实际实现了哪些功能呢?

对比下《接口自动化测试的最佳工程实践(ApiTestEngine)》中规划的特性,已经实现了大半(前六项),至少已经算是一个有模有样的接口测试框架了。

  • 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等
  • 测试用例与代码分离,测试用例维护方式简洁优雅,支持YAML/JSON
  • 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果
  • 接口测试用例具有可复用性,便于创建复杂测试场景
  • 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试
  • 具有可扩展性,便于扩展实现Web平台化
  • 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等
  • 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust)

后面剩下的特性还在实现的过程中,但是可以预见得到,最后框架本身总的Python代码行数也不会超过500行。

当然,单纯地比代码行数的确是没有什么意义,写得爽写得开心才是最重要的。

最后引用下Guido van Rossum的语录:

Life is short, go Pythonic!

阅读更多

  • 《接口自动化测试的最佳工程实践(ApiTestEngine)》
  • 《ApiTestEngine 演化之路(0)开发未动,测试先行》
  • 《ApiTestEngine 演进之路(1)搭建基础框架》
  • ApiTestEngine GitHub源码

最后的最后

《ApiTestEngine 演进之路》系列文章还在继续写,只是前几天主要精力在编码实现上,博客方面没有同步更新,接下来我会整理好思路,继续完成余下的部分。

另外,如果大家对Python编程感兴趣,给大家推荐一个专注Python原创技术分享的公众号,⎡Python之禅⎦(VTtalk),里面关于Python的干货非常多,讲解也很通俗易懂,现在我如果有理解得不够透彻的概念,基本都会先到这个公众号里面去搜索下。

ApiTestEngine 演进之路(1)搭建基础框架

发表于 2017-06-22 | 更新于 2019-04-03 | 分类于 Development , 测试框架

在《ApiTestEngine 演进之路(0)开发未动,测试先行》一文中,我对ApiTestEngine项目正式开始前的准备工作进行了介绍,包括构建API接口服务(Mock Server)、搭建项目单元测试框架、实现持续集成构建检查机制(Travis CI)等。

接下来,我们就开始构建ApiTestEngine项目的基础框架,实现基本功能吧。

接口测试的核心要素

既然是从零开始,那我们不妨先想下,对于接口测试来说,最基本最核心的要素有哪些?

事实上,不管是手工进行接口测试,还是自动化测试平台执行接口测试,接口测试的核心要素都可以概括为如下三点:

  • 发起接口请求(Request)
  • 解析接口响应(Response)
  • 检查接口测试结果

这对于任意类型的接口测试也都是适用的。

在本系列文章中,我们关注的是API接口的测试,更具体地,是基于HTTP协议的API接口的测试。所以我们的问题就进一步简化了,只需要关注HTTP协议层面的请求和响应即可。

好在对于绝大多数接口系统,都有明确的API接口文档,里面会定义好接口请求的参数(包括Headers和Body),并同时描述好接口响应的内容(包括Headers和Body)。而我们需要做的,就是根据接口文档的描述,在HTTP请求中按照接口规范填写请求的参数,然后读取接口的HTTP响应内容,将接口的实际响应内容与我们的预期结果进行对比,以此判断接口功能是否正常。这里的预期结果,应该是包含在接口测试用例里面的。

由此可知,实现接口测试框架的第一步是完成对HTTP请求响应处理的支持。

HTTP客户端的最佳选择

ApiTestEngine项目选择Python作为编程语言,而在Python中实现HTTP请求,毫无疑问,Requests库是最佳选择,简洁优雅,功能强大,可轻松支持API接口的多种请求方法,包括GET/POST/HEAD/PUT/DELETE等。

并且,更赞的地方在于,Requests库针对所有的HTTP请求方法,都可以采用一套统一的接口。

1
requests.request(method, url, **kwargs)

其中,kwargs中可以包含HTTP请求的所有可能需要用到的信息,例如headers、cookies、params、data、auth等。

这有什么好处呢?

好处在于,这可以帮助我们轻松实现测试数据与框架代码的分离。我们只需要遵循Requests库的参数规范,在接口测试用例中复用Requests参数的概念即可。而对于框架的测试用例执行引擎来说,处理逻辑就异常简单了,直接读取测试用例中的参数,传参给Requests发起请求即可。

如果还感觉不好理解,没关系,直接看案例。

测试用例描述

在我们搭建的API接口服务(Mock Server)中,我们想测试“创建一个用户,该用户之前不存在”的场景

在上一篇文章中,我们也在unittest中对该测试场景实现了测试脚本。

1
2
3
4
5
6
7
8
9
10
11
12
def test_create_user_not_existed(self):
self.clear_users()

url = "%s/api/users/%d" % (self.host, 1000)
data = {
"name": "user1",
"password": "123456"
}
resp = self.api_client.post(url, json=data)

self.assertEqual(201, resp.status_code)
self.assertEqual(True, resp.json()["success"])

在该用例中,我们实现了HTTP POST请求,api_client.post(url, json=data),然后对响应结果进行解析,并检查resp.status_code、resp.json()["success"]是否满足预期。

可以看出,采用代码编写测试用例时会用到许多编程语言的语法,对于不会编程的人来说上手难度较大。更大的问题在于,当我们编写大量测试用例之后,因为模式基本都是固定的,所以会发现存在大量相似或重复的脚本,这给脚本的维护带来了很大的问题。

那如何将测试用例与脚本代码进行分离呢?

考虑到JSON格式在编程语言中处理是最方便的,分离后的测试用例可采用JSON描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"name": "create user which does not exist",
"request": {
"url": "http://127.0.0.1:5000/api/users/1000",
"method": "POST",
"headers": {
"content-type": "application/json"
},
"json": {
"name": "user1",
"password": "123456"
}
},
"response": {
"status_code": 201,
"headers": {
"Content-Type": "application/json"
},
"body": {
"success": true,
"msg": "user created successfully."
}
}
}

不难看出,如上JSON结构体包含了测试用例的完整描述信息。

需要特别注意的是,这里使用了一个讨巧的方式,就是在请求的参数中充分复用了Requests的参数规范。例如,我们要POST一个JSON的结构体,那么我们就直接将json作为request的参数名,这和前面写脚本时用的api_client.post(url, json=data)是一致的。

测试用例执行引擎

在如上测试用例描述的基础上,测试用例执行引擎就很简单了,以下几行代码就足够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
def run_single_testcase(testcase):
req_kwargs = testcase['request']

try:
url = req_kwargs.pop('url')
method = req_kwargs.pop('method')
except KeyError:
raise exception.ParamsError("Params Error")

resp_obj = requests.request(url=url, method=method, **req_kwargs)
diff_content = utils.diff_response(resp_obj, testcase['response'])
success = False if diff_content else True
return success, diff_content

可以看出,不管是什么HTTP请求方法的用例,该执行引擎都是适用的。

只需要先从测试用例中获取到HTTP接口请求参数,testcase['request']:

1
2
3
4
5
6
7
8
9
10
11
{
"url": "http://127.0.0.1:5000/api/users/1000",
"method": "POST",
"headers": {
"content-type": "application/json"
},
"json": {
"name": "user1",
"password": "123456"
}
}

然后发起HTTP请求:

1
requests.request(url=url, method=method, **req_kwargs)

最后再检查测试结果:

1
utils.diff_response(resp_obj, testcase['response'])

在测试用例执行引擎完成后,执行测试用例的方式也很简单。同样是在unittest中调用执行测试用例,就可以写成如下形式:

1
2
3
4
5
def test_run_single_testcase_success(self):
testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo.json')
testcases = utils.load_testcases(testcase_file_path)
success, _ = self.test_runner.run_single_testcase(testcases[0])
self.assertTrue(success)

可以看出,模式还是很固定:加载用例、执行用例、判断用例执行是否成功。如果每条测试用例都要在unittest.TestCase分别写一个单元测试进行调用,还是会存在大量重复工作。

所以比较好的做法是,再实现一个单元测试用例生成功能;这部分先不展开,后面再进行详细描述。

结果判断处理逻辑

这里再单独讲下对结果的判断逻辑处理,也就是diff_response函数。

1
2
3
4
5
6
7
8
9
def diff_response(resp_obj, expected_resp_json)
diff_content = {}
resp_info = parse_response_object(resp_obj)

# 对比 status_code,将差异存入 diff_content
# 对比 Headers,将差异存入 diff_content
# 对比 Body,将差异存入 diff_content

return diff_content

其中,expected_resp_json参数就是我们在测试用例中描述的response部分,作为测试用例的预期结果描述信息,是判断实际接口响应是否正常的参考标准。

而resp_obj参数,就是实际接口响应的Response实例,详细的定义可以参考requests.Response描述文档。

为了更好地实现结果对比,我们也将resp_obj解析为与expected_resp_json相同的数据结构。

1
2
3
4
5
6
7
8
9
10
11
def parse_response_object(resp_obj):
try:
resp_body = resp_obj.json()
except ValueError:
resp_body = resp_obj.text

return {
'status_code': resp_obj.status_code,
'headers': resp_obj.headers,
'body': resp_body
}

那么最后再进行对比就很好实现了,只需要编写一个通用的JSON结构体比对函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
def diff_json(current_json, expected_json):
json_diff = {}

for key, expected_value in expected_json.items():
value = current_json.get(key, None)
if str(value) != str(expected_value):
json_diff[key] = {
'value': value,
'expected': expected_value
}

return json_diff

这里只罗列了核心处理流程的代码实现,其它的辅助功能,例如加载JSON/YAML测试用例等功能,请直接阅读阅读项目源码。

总结

经过本文中的工作,我们已经完成了ApiTestEngine基础框架的搭建,并实现了两项最基本的功能:

  • 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等
  • 测试用例与代码分离,测试用例维护方式简洁优雅,支持YAML/JSON

然而,在实际项目中的接口通常比较复杂,例如包含签名校验等机制,这使得我们在配置接口测试用例时还是会比较繁琐。

在下一篇文章中,我们将着手解决这个问题,通过对框架增加模板配置功能,实现接口业务参数和技术细节的分离。

阅读更多

  • 《接口自动化测试的最佳工程实践(ApiTestEngine)》
  • 《ApiTestEngine 演进之路(0)开发未动,测试先行》
  • ApiTestEngine GitHub源码

ApiTestEngine 演进之路(0)开发未动,测试先行

发表于 2017-06-20 | 更新于 2019-04-03 | 分类于 Development , 测试框架

在《接口自动化测试的最佳工程实践(ApiTestEngine)》一文中,我详细介绍了ApiTestEngine诞生的背景,并对其核心特性进行了详尽的剖析。

接下来,我将在《ApiTestEngine演进之路》系列文章中讲解ApiTestEngine是如何从第一行代码开始,逐步实现接口自动化测试框架的核心功能特性的。

相信大家都有听说过TDD(测试驱动开发)这种开发模式,虽然网络上对该种开发模式存在异议,但我个人是非常推荐使用该种开发方式的。关于TDD的优势,我就不在此赘述了,我就只说下自己受益最深的两个方面。

  • 测试驱动,其实也是需求驱动。在开发正式代码之前,可以先将需求转换为单元测试用例,然后再逐步实现正式代码,直至将所有单元测试用例跑通。这可以帮助我们总是聚焦在要实现的功能特性上,避免跑偏。特别是像我们做测试开发的,通常没有需求文档和设计文档,如果没有清晰的思路,很可能做着做着就不知道自己做到哪儿了。
  • 高覆盖率的单元测试代码,对项目质量有充足的信心。因为是先写测试再写实现,所以正常情况下,所有的功能特性都应该能被单元测试覆盖到。再结合持续集成的手段,我们可以轻松保证每个版本都是高质量并且可用的。

所以,ApiTestEngine项目也将采用TDD的开发模式。本篇文章就重点介绍下采用TDD之前需要做的一些准备工作。

搭建API接口服务(Mock Server)

接口测试框架要运行起来,必然需要有可用的API接口服务。因此,在开始构建我们的接口测试框架之前,最好先搭建一套简单的API接口服务,也就是Mock Server,然后我们在采用TDD开发模式的时候,就可以随时随地将框架代码跑起来,开发效率也会大幅提升。

为什么不直接采用已有的业务系统API接口服务呢?

这是因为通常业务系统的接口比较复杂,并且耦合了许多业务逻辑,甚至还可能涉及到和其它业务系统的交互,搭建或维护一套测试环境的成本可能会非常高。另一方面,接口测试框架需要具有一定的通用性,其功能特性很难在一个特定的业务系统中找到所有合适的接口。就拿最简单的接口请求方法来说,测试框架需要支持GET/POST/HEAD/PUT/DELETE方法,但是可能在我们已有的业务系统中只有GET/POST接口。

自行搭建API接口服务的另一个好处在于,我们可以随时调整接口的实现方式,来满足接口测试框架特定的功能特性,从而使我们总是能将注意力集中在测试框架本身。比较好的做法是,先搭建最简单的接口服务,在此基础上将接口测试框架搭建起来,实现最基本的功能;后面在实现框架的高级功能特性时,我们再对该接口服务进行拓展升级,例如增加签名校验机制等,来适配测试框架的高级功能特性。

幸运的是,使用Python搭建API接口服务十分简单,特别是在结合使用Flask框架的情况下。

例如,我们想实现一套可以对用户账号进行增删改查(CRUD)功能的接口服务,用户账号的存储结构大致如下:

1
2
3
4
5
6
7
8
9
10
users_dict = {
'uid1': {
'name': 'name1',
'password': 'pwd1'
},
'uid2': {
'name': 'name2',
'password': 'pwd2'
}
}

那么,新增(Create)和更新(Update)功能的接口就可以通过如下方式实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import json
from flask import Flask
from flask import request, make_response

app = Flask(__name__)
users_dict = {}

@app.route('/api/users/<int:uid>', methods=['POST'])
def create_user(uid):
user = request.get_json()
if uid not in users_dict:
result = {
'success': True,
'msg': "user created successfully."
}
status_code = 201
users_dict[uid] = user
else:
result = {
'success': False,
'msg': "user already existed."
}
status_code = 500

response = make_response(json.dumps(result), status_code)
response.headers["Content-Type"] = "application/json"
return response

@app.route('/api/users/<int:uid>', methods=['PUT'])
def update_user(uid):
user = users_dict.get(uid, {})
if user:
user = request.get_json()
success = True
status_code = 200
else:
success = False
status_code = 404

result = {
'success': success,
'data': user
}
response = make_response(json.dumps(result), status_code)
response.headers["Content-Type"] = "application/json"
return response

限于篇幅,其它类型的接口实现就不在此赘述,完整的接口实现可以参考项目源码。

接口服务就绪后,按照Flask官方文档,可以通过如下方式进行启动:

1
2
3
4
$ export FLASK_APP=tests/api_server.py
$ flask run
* Serving Flask app "tests.api_server"
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

启动后,我们就可以通过请求接口来调用已经实现的接口功能了。例如,先创建一个用户,然后查看所有用户的信息,在Python终端中的调用方式如下:

1
2
3
4
5
6
7
8
9
$ python
Python 3.6.0 (default, Mar 24 2017, 16:58:25)
>>> import requests
>>> requests.post('http://127.0.0.1:5000/api/users/1000', json={'name': 'user1', 'password': '123456'})
<Response [201]>
>>> resp = requests.get('http://127.0.0.1:5000/api/users')
>>> resp.content
b'{"success": true, "count": 1, "items": [{"name": "user1", "password": "123456"}]}'
>>>

通过接口请求结果可见,接口服务运行正常。

在单元测试用例中使用 Mock Server

API接口服务(Mock Server)已经有了,但是如果每次运行单元测试时都要先在外部手工启动API接口服务的话,做法实在是不够优雅。

推荐的做法是,制作一个ApiServerUnittest基类,在其中添加setUpClass类方法,用于启动API接口服务(Mock Server);添加tearDownClass类方法,用于停止API接口服务。由于setUpClass会在单元测试用例集初始化的时候执行一次,所以可以保证单元测试用例在运行的时候API服务处于可用状态;而tearDownClass会在单元测试用例集执行完毕后运行一次,停止API接口服务,从而避免对下一次启动产生影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# tests/base.py
import multiprocessing
import time
import unittest
from . import api_server

class ApiServerUnittest(unittest.TestCase):
"""
Test case class that sets up an HTTP server which can be used within the tests
"""
@classmethod
def setUpClass(cls):
cls.api_server_process = multiprocessing.Process(
target=api_server.app.run
)
cls.api_server_process.start()
time.sleep(0.1)

@classmethod
def tearDownClass(cls):
cls.api_server_process.terminate()

这里采用的是多进程的方式(multiprocessing),所以我们的单元测试用例可以和API接口服务(Mock Server)同时运行。除了多进程的方式,我看到locust项目采用的是gevent.pywsgi.WSGIServer的方式,不过由于在gevent中要实现异步需要先monkey.patch_all(),感觉比较麻烦,而且还需要引入gevent这么一个第三方依赖库,所以还是决定采用multiprocessing的方式了。至于为什么没有选择多线程模型(threading),是因为线程至不支持显式终止的(terminate),要实现终止服务会比使用multiprocessing更为复杂。

不过需要注意的是,由于启动Server存在一定的耗时,因此在启动完毕后必须要等待一段时间(本例中0.1秒就足够了),否则在执行单元测试用例时,调用的API接口可能还处于不可用状态。

ApiServerUnittest基类就绪后,对于需要用到Mock Server的单元测试用例集,只需要继承ApiServerUnittest即可;其它的写法跟普通的单元测试完全一致。

例如,下例包含一个单元测试用例,测试“创建一个用户,该用户之前不存在”的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# tests/test_apiserver.py
import requests
from .base import ApiServerUnittest

class TestApiServer(ApiServerUnittest):
def setUp(self):
super(TestApiServer, self).setUp()
self.host = "http://127.0.0.1:5000"
self.api_client = requests.Session()
self.clear_users()

def tearDown(self):
super(TestApiServer, self).tearDown()

def test_create_user_not_existed(self):
self.clear_users()

url = "%s/api/users/%d" % (self.host, 1000)
data = {
"name": "user1",
"password": "123456"
}
resp = self.api_client.post(url, json=data)

self.assertEqual(201, resp.status_code)
self.assertEqual(True, resp.json()["success"])

为项目添加持续集成构建检查(Travis CI)

当我们的项目具有单元测试之后,我们就可以为项目添加持续集成构建检查,从而在每次提交代码至GitHub时都运行测试,确保我们每次提交的代码都是可正常部署及运行的。

要实现这个功能,推荐使用Travis CI提供的服务,该服务对于GitHub公有仓库是免费的。要完成配置,操作也很简单,基本上只有三步:

  • 在Travis CI使用GitHub账号授权登录;
  • 在Travis CI的个人profile页面开启需要持续集成的项目;
  • 在Github项目的根目录下添加.travis.yml配置文件。

大多数情况下,.travis.yml配置文件可以很简单,例如ApiTestEngine的配置就只有如下几行:

1
2
3
4
5
6
7
8
9
10
11
12
sudo: false
language: python
python:
- 2.7
- 3.3
- 3.4
- 3.5
- 3.6
install:
- pip install -r requirements.txt
script:
- python -m unittest discover

具体含义不用解释也可以很容易看懂,其中install中包含我们项目的依赖库安装命令,script中包含执行构建测试的命令。

配置完毕后,后续每次提交代码时,GitHub就会调用Travis CI实现构建检查;并且更赞的在于,构建检查可以同时在多个指定的Python版本环境中进行。

下图是某次提交代码时的构建结果。

另外,我们还可以在GitHub项目的README.md中添加一个Status Image,实时显示项目的构建状态,就像下图显示的样子。

配置方式也是很简单,只需要先在Travis CI中获取到项目Status Image的URL地址,然后添加到README.md即可。

为项目添加单元测试覆盖率检查(coveralls)

对项目添加持续集成构建检查以后,就能完全保证我们提交的代码运行没问题么?

答案是并不能。试想,假如我们整个项目中就只有一条单元测试用例,甚至这一条单元测试用例还是个假用例,即没有调用任何代码,那么可想而知,我们的持续集成构建检查总是成功的,并没有起到检查的作用。

因此,这里还涉及到一个单元测试覆盖率的问题。

怎么理解单元测试覆盖率呢?简单地说,就是我们在执行单元测试时运行代码的行数,与项目总代码数的比值。

对于主流的编程语言,都存在大量的覆盖率检查工具,可以帮助我们快速统计单元测试覆盖率。在Python中,用的最多的覆盖率检查工具是coverage。

要使用coverage,需要先进行安装,采用pip的安装方式如下:

1
$ pip install coverage

然后,我们就可以采用如下命令执行单元测试。

1
$ coverage run --source=ate -m unittest discover

这里需要说明的是,--source参数的作用是指定统计的目录,如果不指定该参数,则会将所有依赖库也计算进去,但由于很多依赖库在安装时是没有包含测试代码的,因此会造成统计得到的单元测试覆盖率远低于实际的情况。在上面的命令中,就只统计了ate目录下的单元测试覆盖率;如果要统计当前项目的覆盖率,那么可以指定--source=.(即当前目录下的所有子文夹)。

采用上述命令执行完单元测试后,会在当前目录下生成一个统计结果文件,.coverage,里面包含了详细的统计结果。

1
2
3
4
5
6
7
8
9
10
cat .coverage
!coverage.py: This is a private format, don't read it directly!{"lines":{"/Users/Leo/MyProjects/ApiTestEngine/ate/__init__.py":[1],"/Users/Leo/MyProjects/
ApiTestEngine/ate/testcase.py":[1,2,4,6,9,15,42,7,12,40,46,64,67,68,69,70,48,49,62,72,74,13,65,51,52,53,56,60,58,54,55],"/Users/Leo/MyProjects/ApiTestEngi
ne/ate/exception.py":[2,4,5,9,12,15,16,6,7],"/Users/Leo/MyProjects/ApiTestEngine/ate/utils.py":[1,2,3,4,5,7,9,11,12,14,15,18,22,25,47,51,55,65,77,90,129,1
41,27,31,32,19,20,23,34,41,43,45,56,57,59,60,48,49,154,163,166,170,172,173,174,176,177,181,182,183,186,187,189,91,92,66,67,72,73,74,94,95,97,98,101,102,78
,80,81,82,84,85,88,103,104,106,108,110,115,121,122,124,125,127,58,52,53,184,185,109,116,118,119,112,113,132,134,135,136,137,139,63,164,155,157,158,159,161
,167,168,192,68,69],"/Users/Leo/MyProjects/ApiTestEngine/ate/context.py":[1,3,5,6,10,16,30,45,7,8,25,26,28,41,42,43,49,55,58,59,63,64,56,74,65,68,69,72,66
,27,13,14,50,53,52,70],"/Users/Leo/MyProjects/ApiTestEngine/ate/main.py":[1,2,4,7,9,10,15,21,38,51,25,27,28,29,30,32,33,11,12,13,34,36,42,43,45,46,47,49],
"/Users/Leo/MyProjects/ApiTestEngine/ate/runner.py":[1,3,4,5,8,10,15,46,68,97,135,11,12,13,35,36,38,39,41,42,44,82,63,65,66,84,86,87,88,92,93,94,95,124,12
6,127,128,129,130,131,133,154]}}%

但是,这个结果就不是给人看的。要想直观地看到统计报告,需要再执行命令coverage report -m,执行完后,就可以看到详细的统计数据了。

1
2
3
4
5
6
7
8
9
10
11
12
➜  ApiTestEngine git:(master) ✗ coverage report -m
Name Stmts Miss Cover Missing
------------------------------------------------
ate/__init__.py 0 0 100%
ate/context.py 35 0 100%
ate/exception.py 11 2 82% 10, 13
ate/main.py 34 7 79% 18-19, 54-62
ate/runner.py 44 2 95% 89-90
ate/testcase.py 30 0 100%
ate/utils.py 112 8 93% 13, 29, 36-39, 178-179
------------------------------------------------
TOTAL 266 19 93%

通过这个报告,可以看到项目整体的单元测试覆盖率为93%,并清晰地展示了每个源代码文件的具体覆盖率数据,以及没有覆盖到的代码行数。

那要怎么将覆盖率检查添加到我们的持续集成(Travis CI)中呢?

事实上,当前存在多个可选服务,可以与Travis CI配合使用。当前,使用得比较广泛的是coveralls,针对Public类型的GitHub仓库,这也是一个免费服务。

coveralls的使用方式与Travis CI类似,也需要先在coveralls网站上采用GitHub账号授权登录,然后开启需要进行检查的GitHub仓库。而要执行的命令,也可以在.travis.yml配置文件中指定。

增加覆盖率检查后的.travis.yml配置文件内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo: false
language: python
python:
- 2.7
- 3.3
- 3.4
- 3.5
- 3.6
install:
- pip install -r requirements.txt
- pip install coverage
- pip install coveralls
script:
- coverage run --source=. -m unittest discover
after_success:
- coveralls

如上配置应该也很好理解,要使用coveralls的服务,需要先安装coveralls。在采用coverage执行完单元测试后,要将结果上报到coveralls网站,需要再执行coveralls命令。由于coveralls命令只有在测试覆盖率检查成功以后运行才有意义,因此可将其放在after_success部分。

配置完毕后,后续每次提交代码时,GitHub就会调用Travis CI实现构建检查,并同时统计得到单元测试覆盖率。

下图是某次提交代码时的覆盖率检查。

另外,我们在GitHub项目的README.md中也同样可以添加一个Status Image,实时显示项目的单元测试覆盖率。

配置方式也跟之前类似,在coveralls中获取到项目Status Image的URL地址,然后添加到README.md即可。

最后需要说明的是,项目的单元测试覆盖率只能起到参考作用,没有被单元测试覆盖到的代码我们不能说它肯定有问题,100%覆盖率的代码也并不能保证它肯定没有问题。归根结底,这还是要依赖于单元测试的策略实现,因此我们在写单元测试的时候也要尽可能多地覆盖到各种逻辑路径,以及兼顾到各种异常情况。

写在后面

通过本文中的工作,我们就对项目搭建好了测试框架,并实现了持续集成构建检查机制。从下一篇开始,我们就将开始逐步实现接口自动化测试框架的核心功能特性了。

阅读更多

  • 《接口自动化测试的最佳工程实践(ApiTestEngine)》
  • ApiTestEngine GitHub源码
1234…8
solomiss

solomiss

75 日志
11 分类
50 标签
GitHub E-Mail
Creative Commons
© 2019 solomiss