理想国

我要看尽这世间繁华


  • 首页

  • 归档

  • 标签

  • 分类

  • 关于

  • 搜索

接口自动化测试的最佳工程实践(ApiTestEngine)

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

背景

当前市面上存在的接口测试工具已经非常多,常见的如Postman、JMeter、RobotFramework等,相信大多数测试人员都有使用过,至少从接触到的大多数简历的描述上看是这样的。除了这些成熟的工具,也有很多有一定技术能力的测试(开发)人员自行开发了一些接口测试框架,质量也是参差不齐。

但是,当我打算在项目组中推行接口自动化测试时,搜罗了一圈,也没有找到一款特别满意的工具或框架,总是与理想中的构想存在一定的差距。

那么理想中的接口自动化测试框架应该是怎样的呢?

测试工具(框架)脱离业务使用场景都是耍流氓!所以我们不妨先来看下日常工作中的一些常见场景。

  • 测试或开发人员在定位问题的时候,想调用某个接口查看其是否响应正常;
  • 测试人员在手工测试某个功能点的时候,需要一个订单号,而这个订单号可以通过顺序调用多个接口实现下单流程;
  • 测试人员在开始版本功能测试之前,可以先检测下系统的所有接口是否工作正常,确保接口正常后再开始手工测试;
  • 开发人员在提交代码前需要检测下新代码是否对系统的已有接口产生影响;
  • 项目组需要每天定时检测下测试环境所有接口的工作情况,确保当天的提交代码没有对主干分支的代码造成破坏;
  • 项目组需要定时(30分钟)检测下生产环境所有接口的工作情况,以便及时发现生产环境服务不可用的情况;
  • 项目组需要不定期对核心业务场景进行性能测试,期望能减少人力投入,直接复用接口测试中的工作成果。

可以看到,以上罗列的场景大家应该都很熟悉,这都是我们在日常工作中经常需要去做的事情。但是在没有一款合适工具的情况下,效率往往十分低下,或者就是某些重要工作压根就没有开展,例如接口回归测试、线上接口监控等。

先说下最简单的手工调用接口测试。可能有人会说,Postman就可以满足需求啊。的确,Postman作为一款通用的接口测试工具,它可以构造接口请求,查看接口响应,从这个层面上来说,它是满足了接口测试的功能需求。但是在具体的项目中,使用Postman并不是那么高效。

不妨举个最常见的例子。

某个接口的请求参数非常多,并且接口请求要求有MD5签名校验;签名的方式为在Headers中包含一个sign参数,该参数值通过对URL、Method、Body的拼接字符串进行MD5计算后得到。

回想下我们要对这个接口进行测试时是怎么做的。首先,我们需要先参照接口文档的描述,手工填写完所有接口参数;然后,按照签名校验方式,对所有参数值进行拼接得到一个字符串,在另一个MD5计算工具计算得到其MD5值,将签名值填入sign参数;最后,才是发起接口请求,查看接口响应,并人工检测响应是否正常。最坑爹的是,我们每次需要调用这个接口的时候,以上工作就得重新来一遍。这样的实际结果是,面对参数较多或者需要签名验证的接口时,测试人员可能会选择忽略不进行接口测试。

除了单个接口的调用,很多时候我们也需要组合多个接口进行调用。例如测试人员在测试物流系统时,经常需要一个特定组合条件下生成的订单号。而由于订单号关联的业务较多,很难直接在数据库中生成,因此当前业务测试人员普遍采取的做法,就是每次需要订单号时模拟下单流程,顺序调用多个相应的接口来生成需要的订单号。可以想象,在手工调用单个接口都如此麻烦的情况下,每次都要手工调用多个接口会有多么的费时费力。

再说下接口自动化调用测试。这一块儿大多接口测试框架都支持,普遍的做法就是通过代码编写接口测试用例,或者采用数据驱动的方式,然后在支持命令行(CLI)调用的情况下,就可以结合Jenkins或者crontab实现持续集成,或者定时接口监控的功能。

思路是没有问题的,问题在于实际项目中的推动落实情况。要说自动化测试用例最靠谱的维护方式,还是直接通过代码编写测试用例,可靠且不失灵活性,这也是很多经历过惨痛教训的老手的感悟,甚至网络上还出现了一些反测试框架的言论。但问题在于项目中的测试人员并不是都会写代码,也不是对其强制要求就能马上学会的。这种情况下,要想在具体项目中推动接口自动化测试就很难,就算我可以帮忙写一部分,但是很多时候接口测试用例也是要结合业务逻辑场景的,我也的确是没法在这方面投入太多时间,毕竟对接的项目实在太多。所以也是基于这类原因,很多测试框架提倡采用数据驱动的方式,将业务测试用例和执行代码分离。不过由于很多时候业务场景比较复杂,大多数框架测试用例模板引擎的表达能力不足,很难采用简洁的方式对测试场景进行描述,从而也没法很好地得到推广使用。

可以列举的问题还有很多,这些也的确都是在互联网企业的日常测试工作中真实存在的痛点。

基于以上背景,我产生了开发ApiTestEngine的想法。

对于ApiTestEngine的定位,与其说它是一个工具或框架,它更多的应该是一套接口自动化测试的最佳工程实践,而简洁优雅实用应该是它最核心的特点。

当然,每位工程师对最佳工程实践的理念或多或少都会存在一些差异,也希望大家能多多交流,在思维的碰撞中共同进步。

核心特性

ApiTestEngine的核心特性概述如下:

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

特性拆解介绍

支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等

个人偏好,编程语言选择Python。而采用Python实现HTTP请求,最好的方式就是采用Requests库了,简洁优雅,功能强大。

测试用例与代码分离,测试用例维护方式简洁优雅,支持YAML

要实现测试用例与代码的分离,最好的做法就是做一个测试用例加载引擎和一个测试用例执行引擎,这也是之前在做AppiumBooster框架的时候总结出来的最优雅的实现方式。当然,这里需要事先对测试用例制定一个标准的数据结构规范,作为测试用例加载引擎和测试用例执行引擎的桥梁。

需要说明的是,测试用例数据结构必须包含接口测试用例完备的信息要素,包括接口请求的信息内容(URL、Headers、Method等参数),以及预期的接口请求响应结果(StatusCode、ResponseHeaders、ResponseContent)。

这样做的好处在于,不管测试用例采用什么形式进行描述(YAML、JSON、CSV、Excel、XML等),也不管测试用例是否采用了业务分层的组织思想,只要在测试用例加载引擎中实现对应的转换器,都可以将业务测试用例转换为标准的测试用例数据结构。而对于测试用例执行引擎而言,它无需关注测试用例的具体描述形式,只需要从标准的测试用例数据结构中获取到测试用例信息要素,包括接口请求信息和预期接口响应信息,然后构造并发起HTTP请求,再将HTTP请求的响应结果与预期结果进行对比判断即可。

至于为什么明确说明支持YAML,这是因为个人认为这是最佳的测试用例描述方式,表达简洁不累赘,同时也能包含非常丰富的信息。当然,这只是个人喜好,如果喜欢采用别的方式,只需要扩展实现对应的转换器即可。

测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果

测试用例与框架代码分离以后,对业务逻辑测试场景的描述重任就落在测试用例上了。比如我们选择采用YAML来描述测试用例,那么我们就应该能在YAML中描述各种复杂的业务场景。

那么怎么理解这个“表现力”呢?

简单的参数值传参应该都容易理解,我们举几个相对复杂但又比较常见的例子。

  • 接口请求参数中要包含当前的时间戳;
  • 接口请求参数中要包含一个16位的随机字符串;
  • 接口请求参数中包含签名校验,需要对多个请求参数进行拼接后取md5值;
  • 接口响应头(Headers)中要包含一个X-ATE-V头域,并且需要判断该值是否大于100;
  • 接口响应结果中包含一个字符串,需要校验字符串中是否包含10位长度的订单号;
  • 接口响应结果为一个多层嵌套的json结构体,需要判断某一层的某一个元素值是否为True。

可以看出,以上几个例子都是没法直接在测试用例里面描述参数值的。如果是采用Python脚本来编写测试用例还好解决,只需要通过Python函数实现即可。但是现在测试用例和框架代码分离了,我们没法在YAML里面执行Python函数,这该怎么办呢?

答案就是,定义函数转义符,实现自定义模板。

这种做法其实也不难理解,也算是模板语言通用的方式。例如,我们将${}定义为转义符,那么在{}内的内容就不再当做是普通的字符串,而应该转义为变量值,或者执行函数得到实际结果。当然,这个需要我们在测试用例执行引擎进行适配实现,最简单方式就是提取出${}中的字符串,通过eval计算得到表达式的值。如果要实现更复杂的功能,我们也可以将接口测试中常用的一些功能封装为一套关键字,然后在编写测试用例的时候使用这些关键字。

接口测试用例具有可复用性,便于创建复杂测试场景

很多情况下,系统的接口都是有业务逻辑关联的。例如,要请求调用登录接口,需要先请求获取验证码的接口,然后在登录请求中带上获取到的验证码;而要请求数据查询的接口,又要在请求参数中包含登录接口返回的session值。这个时候,我们如果针对每一个要测的业务逻辑,都单独描述要请求的接口,那么就会造成大量的重复描述,测试用例的维护也十分臃肿。

比较好的做法是,将每一个接口调用单独封装为一条测试用例,然后在描述业务测试场景时,选择对应的接口,按照顺序拼接为业务场景测试用例,就像搭积木一般。如果你之前读过AppiumBooster的介绍,应该还会联想到,我们可以将常用的功能组成模块用例集,然后就可以在更高的层面对模块用例集进行组装,实现更复杂的测试场景。

不过,这里有一个非常关键的问题需要解决,就是如何在接口测试用例之前传参的问题。其实实现起来也不复杂,我们可以在接口请求响应结果中指定一个变量名,然后将接口返回关键值提取出来后赋值给那个变量;然后在其它接口请求参数中,传入这个${变量名}即可。

测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试

通过背景中的例子可以看出,需要使用接口测试工具的场景很多,除了定时地对所有接口进行自动化测试检测外,很多时候在手工测试的时候也需要采用接口测试工具进行辅助,也就是半手工+半自动化的模式。

而业务测试人员在使用测试工具的时候,遇到的最大问题在于除了需要关注业务功能本身,还需要花费很多时间去处理技术实现细节上的东西,例如签名校验这类情况,而且往往后者在重复操作中占用的时间更多。

这个问题的确是没法避免的,毕竟不同系统的接口千差万别,不可能存在一款工具可以自动处理所有情况。但是我们可以尝试将接口的技术细节实现和业务参数进行拆分,让业务测试人员只需要关注业务参数部分。

具体地,我们可以针对每一个接口配置一个模板,将其中与业务功能无关的参数以及技术细节封装起来,例如签名校验、时间戳、随机值等,而与业务功能相关的参数配置为可传参的模式。

这样做的好处在于,与业务功能无关的参数以及技术细节我们只需要封装配置一次,而且这个工作可以由开发人员或者测试开发人员来实现,减轻业务测试人员的压力;接口模板配置好后,测试人员只需要关注与业务相关的参数即可,结合业务测试用例,就可以在接口模板的基础上很方便地配置生成多个接口测试用例。

测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等

测试结果统计报告,应该遵循简洁而不简单的原则。“简洁”,是因为大多数时候我们只需要在最短的时间内判断所有接口是否运行正常即可。而“不简单”,是因为当存在执行失败的测试用例时,我们期望能获得接口测试时尽可能详细的数据,包括测试时间、请求参数、响应内容、接口响应耗时等。

之前在读locust源码时,其对HTTP客户端的封装方式给我留下了深刻的印象。它采用的做法是,继承requests.Session类,在子类HttpSession中重写覆盖了request方法,然后在request方法中对requests.Session.request进行了一层封装。

1
2
3
4
5
6
7
8
9
10
11
12
request_meta = {}

# set up pre_request hook for attaching meta data to the request object
request_meta["method"] = method
request_meta["start_time"] = time.time()

response = self._send_request_safe_mode(method, url, **kwargs)

# record the consumed time
request_meta["response_time"] = int((time.time() - request_meta["start_time"]) * 1000)

request_meta["content_size"] = int(response.headers.get("content-length") or 0)

而HttpLocust的每一个虚拟用户(client)都是一个HttpSession实例,这样每次在执行HTTP请求的时候,既可充分利用Requests库的强大功能,同时也能将请求的响应时间、响应体大小等原始性能数据进行保存,实现可谓十分优雅。

受到该处启发,要保存接口的详细请求响应数据也可采用同样的方式。例如,要保存Response的Headers、Body只需要增加如下两行代码:

1
2
request_meta["response_headers"] = response.headers
request_meta["response_content"] = response.content

身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust)

其实像接口性能测试这样的需求,不应该算到接口自动化测试框架的职责范围之内。但是在实际项目中需求就是这样,又要做接口自动化测试,又要做接口性能测试,而且还不想同时维护两套代码。

多亏有了locust性能测试框架,接口自动化和性能测试脚本还真能合二为一。

前面也讲了,HttpLocust的每一个虚拟用户(client)都是一个HttpSession实例,而HttpSession又继承自requests.Session类,所以HttpLocust的每一个虚拟用户(client)也是requests.Session类的实例。

同样的,我们在用Requests库做接口测试时,请求客户端其实也是requests.Session类的实例,只是我们通常用的是requests的简化用法。

以下两种用法是等价的。

1
2
3
4
5
resp = requests.get('https://debugtalk.com')

# 等价于
client = requests.Session()
resp = client.get('https://debugtalk.com')

有了这一层关系以后,要在接口自动化测试和性能测试之间切换就很容易了。在接口测试框架内,可以通过如下方式初始化HTTP客户端。

1
2
def __init__(self, origin, kwargs, http_client_session=None):
self.http_client_session = http_client_session or requests.Session()

默认情况下,http_client_session是requests.Session的实例,用于进行接口测试;当需要进行性能测试时,只需要传入locust的HttpSession实例即可。

具有可扩展性,便于扩展实现Web平台化

当要将测试平台推广至更广阔的用户群体(例如产品经理、运营人员)时,对框架实现Web化就在所难免了。在Web平台上查看接口测试用例运行情况、对接口模块进行配置、对接口测试用例进行管理,的确会便捷很多。

不过对于接口测试框架来说,Web平台只能算作锦上添花的功能。我们在初期可以优先实现命令行(CLI)调用方式,规范好数据存储结构,后期再结合Web框架(如Flask)增加实现Web平台功能。

写在后面

以上便是我对ApiTestEngine特性的详细介绍,也算是我个人对接口自动化测试最佳工程实践的理念阐述。

当前,ApiTestEngine还处于开发过程中,代码也开源托管在GitHub上,欢迎Star关注。

GitHub项目地址:https://github.com/debugtalk/ApiTestEngine

参考

  • 《打造心目中理想的自动化测试框架(AppiumBooster)》
  • 《告别robotframework》
  • 《Advanced Guide For PyRestTest》

使用 pyenv 管理多个 Python 版本依赖环境

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

背景

从接触Python以来,一直都是采用virtualenv和virtualenvwrapper来管理不同项目的依赖环境,通过workon、mkvirtualenv等命令进行虚拟环境切换,很是愉快。

然而,最近想让项目能兼容更多的Python版本,例如至少同时兼容Python2.7和Python3.3+,就发现采用之前的方式行不通了。

最大的问题在于,在本地计算机同时安装Python2.7和Python3后,即使分别针对两个Python版本安装了virtualenv和virtualenvwrapper,也无法让两个Python版本的workon、mkvirtualenv命令同时生效。另外一方面,要想在本地计算机安装多个Python版本,会发现安装的成本都比较高,实现方式也不够优雅。

幸运地是,针对该痛点,已经存在一个比较成熟的方案,那就是pyenv。

如下是官方的介绍。

pyenv lets you easily switch between multiple versions of Python. It’s simple, unobtrusive, and follows the UNIX tradition of single-purpose tools that do one thing well.

This project was forked from rbenv and ruby-build, and modified for Python.

本文就针对pyenv最核心的功能进行介绍。

基本原理

如果要讲解pyenv的工作原理,基本上采用一句话就可以概括,那就是:修改系统环境变量PATH。

对于系统环境变量PATH,相信大家都不陌生,里面包含了一串由冒号分隔的路径,例如/usr/local/bin:/usr/bin:/bin。每当在系统中执行一个命令时,例如python或pip,操作系统就会在PATH的所有路径中从左至右依次寻找对应的命令。因为是依次寻找,因此排在左边的路径具有更高的优先级。

而pyenv做的,就是在PATH最前面插入一个$(pyenv root)/shims目录。这样,pyenv就可以通过控制shims目录中的Python版本号,来灵活地切换至我们所需的Python版本。

如果还想了解更多细节,可以查看pyenv的文档介绍及其源码实现。

环境初始化

pyenv的安装方式包括多种,重点推荐采用pyenv-installer的方式,原因主要有两点:

  • 通过pyenv-installer可一键安装pyenv全家桶,后续也可以很方便地实现一键升级;
  • pyenv-installer的安装方式基于GitHub,可保证总是使用到最新版本的pyenv,并且Python版本库也是最新最全的。

install && config

通过如下命令安装pyenv全家桶。

1
$ curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash

内容除了包含pyenv以外,还包含如下插件:

  • pyenv-doctor
  • pyenv-installer
  • pyenv-update
  • pyenv-virtualenv
  • pyenv-which-ext

安装完成后,pyenv命令还没有加进系统的环境变量,需要将如下内容加到~/.zshrc中,然后执行source ~/.zshrc。

1
2
3
export PATH=$HOME/.pyenv/bin:$PATH
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"

完成以上操作后,pyenv就安装完成了。

1
2
$ pyenv -v
pyenv 1.0.8

如果不确定pyenv的环境是否安装正常,可以通过pyenv doctor命令对环境进行检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ pyenv doctor
Cloning /Users/Leo/.pyenv/plugins/pyenv-doctor/bin/.....
Installing python-pyenv-doctor...

BUILD FAILED (OS X 10.12.3 using python-build 20160602)

Last 10 log lines:
checking for memory.h... yes
checking for strings.h... yes
checking for inttypes.h... yes
checking for stdint.h... yes
checking for unistd.h... yes
checking openssl/ssl.h usability... no
checking openssl/ssl.h presence... no
checking for openssl/ssl.h... no
configure: error: OpenSSL development header is not installed.
make: *** No targets specified and no makefile found. Stop.
Problem(s) detected while checking system.

通过检测,可以发现本地环境可能存在的问题,例如,从以上输出可以看出,本地的OpenSSL development header还没有安装。根据提示的问题,逐一进行修复,直到检测不再出现问题为止。

update

通过pyenv update命令,可以更新pyenv全家桶的所有内容。

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
$ pyenv update
Updating /Users/Leo/.pyenv...
From https://github.com/yyuu/pyenv
* branch master -> FETCH_HEAD
Already up-to-date.
Updating /Users/Leo/.pyenv/plugins/pyenv-doctor...
From https://github.com/yyuu/pyenv-doctor
* branch master -> FETCH_HEAD
Already up-to-date.
Updating /Users/Leo/.pyenv/plugins/pyenv-installer...
From https://github.com/yyuu/pyenv-installer
* branch master -> FETCH_HEAD
Already up-to-date.
Updating /Users/Leo/.pyenv/plugins/pyenv-update...
From https://github.com/yyuu/pyenv-update
* branch master -> FETCH_HEAD
Already up-to-date.
Updating /Users/Leo/.pyenv/plugins/pyenv-virtualenv...
From https://github.com/yyuu/pyenv-virtualenv
* branch master -> FETCH_HEAD
Already up-to-date.
Updating /Users/Leo/.pyenv/plugins/pyenv-which-ext...
From https://github.com/yyuu/pyenv-which-ext
* branch master -> FETCH_HEAD
Already up-to-date.

pyenv的核心使用方法

pyenv的主要功能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ pyenv -h
Usage: pyenv <command> [<args>]

Some useful pyenv commands are:
commands List all available pyenv commands
local Set or show the local application-specific Python version
global Set or show the global Python version
shell Set or show the shell-specific Python version
install Install a Python version using python-build
uninstall Uninstall a specific Python version
rehash Rehash pyenv shims (run this after installing executables)
version Show the current Python version and its origin
versions List all Python versions available to pyenv
which Display the full path to an executable
whence List all Python versions that contain the given executable

See `pyenv help <command>' for information on a specific command.
For full documentation, see: https://github.com/yyuu/pyenv#readme

查看所有可安装的Python版本

1
2
3
4
5
6
7
8
9
10
11
12
$ pyenv install --list
Available versions:
2.1.3
...
2.7.12
2.7.13
...
3.5.3
3.6.0
3.6-dev
3.6.1
3.7-dev

需要注意的是,如果是采用brew命令安装的pyenv,可能会发现Python版本库中没有最新的Python版本。所以建议还是通过GitHub源码方式安装pyenv。

安装指定版本的Python环境

1
2
3
4
5
$ pyenv install 3.6.0
Downloading Python-3.6.0.tar.xz...
-> https://www.python.org/ftp/python/3.6.0/Python-3.6.0.tar.xz
Installing Python-3.6.0...
Installed Python-3.6.0 to /Users/Leo/.pyenv/versions/3.6.0

查看当前系统中所有可用的Python版本

1
2
3
4
$ pyenv versions
* system (set by /Users/Leo/.pyenv/version)
2.7.13
3.6.0

切换Python版本

pyenv可以从三个维度来管理Python环境,简称为:当前系统、当前目录、当前shell。这三个维度的优先级从左到右依次升高,即当前系统的优先级最低、当前shell的优先级最高。

如果想修改系统全局的Python环境,可以采用pyenv global PYTHON_VERSION命令。该命令执行后会在$(pyenv root)目录(默认为~/.pyenv)中创建一个名为version的文件(如果该文件已存在,则修改该文件的内容),里面记录着系统全局的Python版本号。

1
2
3
4
5
6
7
8
9
10
11
$ pyenv global 2.7.13
$ cat ~/.pyenv/version
2.7.13
$ pyenv version
2.7.13 (set by /Users/Leo/.pyenv/version)

$ pyenv global 3.6.0
$ cat ~/.pyenv/version
3.6.0
$ pyenv version
3.6.0 (set by /Users/Leo/.pyenv/version)

通常情况下,对于特定的项目,我们可能需要切换不同的Python环境,这个时候就可以通过pyenv local PYTHON_VERSION命令来修改当前目录的Python环境。命令执行后,会在当前目录中生成一个.python-version文件(如果该文件已存在,则修改该文件的内容),里面记录着当前目录使用的Python版本号。

1
2
3
4
5
6
7
8
9
10
11
$ cat ~/.pyenv/version
2.7.13
$ pyenv local 3.6.0
$ cat .python-version
3.6.0
$ cat ~/.pyenv/version
2.7.13
$ pyenv version
3.6.0 (set by /Users/Leo/MyProjects/.python-version)
$ pip -V
pip 9.0.1 from /Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/site-packages (python 3.6)

可以看出,当前目录中的.python-version配置优先于系统全局的~/.pyenv/version配置。

另外一种情况,通过执行pyenv shell PYTHON_VERSION命令,可以修改当前shell的Python环境。执行该命令后,会在当前shell session(Terminal窗口)中创建一个名为PYENV_VERSION的环境变量,然后在当前shell的任意目录中都会采用该环境变量设定的Python版本。此时,当前系统和当前目录中设定的Python版本均会被忽略。

1
2
3
4
5
6
7
8
9
$ echo $PYENV_VERSION

$ pyenv shell 3.6.0
$ echo $PYENV_VERSION
3.6.0
$ cat .python-version
2.7.13
$ pyenv version
3.6.0 (set by PYENV_VERSION environment variable)

顾名思义,当前shell的Python环境仅在当前shell中生效,重新打开一个新的shell后,该环境也就失效了。如果想在当前shell中取消shell级别的Python环境,采用unset命令重置PYENV_VERSION环境变量即可。

1
2
3
4
5
6
7
8
$ cat .python-version
2.7.13
$ pyenv version
3.6.0 (set by PYENV_VERSION environment variable)

$ unset PYENV_VERSION
$ pyenv version
2.7.13 (set by /Users/Leo/MyProjects/.python-version)

管理多个依赖库环境

经过以上操作,我们在本地计算机中就可以安装多个版本的Python运行环境,并可以按照实际需求进行灵活地切换。然而,很多时候在同一个Python版本下,我们仍然希望能根据项目进行环境分离,就跟之前我们使用virtualenv一样。

在pyenv中,也包含这么一个插件,pyenv-virtualenv,可以实现同样的功能。

使用方式如下:

1
$ pyenv virtualenv PYTHON_VERSION PROJECT_NAME

其中,PYTHON_VERSION是具体的Python版本号,例如,3.6.0,PROJECT_NAME是我们自定义的项目名称。比较好的实践方式是,在PROJECT_NAME也带上Python的版本号,以便于识别。

现假设我们有XDiff这么一个项目,想针对Python 2.7.13和Python 3.6.0分别创建一个虚拟环境,那就可以依次执行如下命令。

1
2
$ pyenv virtualenv 3.6.0 py36_XDiff
$ pyenv virtualenv 2.7.13 py27_XDiff

创建完成后,通过执行pyenv virtualenvs命令,就可以看到本地所有的项目环境。

1
2
3
4
5
$ pyenv virtualenvs
2.7.13/envs/py27_XDiff (created from /Users/Leo/.pyenv/versions/2.7.13)
* 3.6.0/envs/py36_XDiff (created from /Users/Leo/.pyenv/versions/3.6.0)
py27_XDiff (created from /Users/Leo/.pyenv/versions/2.7.13)
py36_XDiff (created from /Users/Leo/.pyenv/versions/3.6.0)

通过这种方式,在同一个Python版本下我们也可以创建多个虚拟环境,然后在各个虚拟环境中分别维护依赖库环境。

例如,py36_XDiff虚拟环境位于/Users/Leo/.pyenv/versions/3.6.0/envs目录下,而其依赖库位于/Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/site-packages中。

1
2
$ pip -V
pip 9.0.1 from /Users/Leo/.pyenv/versions/3.6.0/lib/python3.6/site-packages (python 3.6)

后续在项目开发过程中,我们就可以通过pyenv local XXX或pyenv activate PROJECT_NAME命令来切换项目的Python环境。

1
2
3
4
5
6
7
➜  MyProjects pyenv local py27_XDiff
(py27_XDiff) ➜ MyProjects pyenv version
py27_XDiff (set by /Users/Leo/MyProjects/.python-version)
(py27_XDiff) ➜ MyProjects python -V
Python 2.7.13
(py27_XDiff) ➜ MyProjects pip -V
pip 9.0.1 from /Users/Leo/.pyenv/versions/2.7.13/envs/py27_XDiff/lib/python2.7/site-packages (python 2.7)

可以看出,切换环境后,pip命令对应的目录也随之改变,即始终对应着当前的Python虚拟环境。

对应的,采用pyenv deactivate命令退出当前项目的Python虚拟环境。

如果想移除某个项目环境,可以通过如下命令实现。

1
$ pyenv uninstall PROJECT_NAME

以上便是日常开发工作中常用的pyenv命令,基本可以满足绝大多数依赖库环境管理方面的需求。

深入浅出开源性能测试工具 Locust(脚本增强)

发表于 2017-02-22 | 更新于 2019-04-09 | 分类于 Testing , 性能测试

在《深入浅出开源性能测试工具Locust(使用篇)》一文中,罗列了编写性能测试脚本时常用的几类脚本增强的场景,本文是对应的代码示例。

关联

在某些请求中,需要携带之前从Server端返回的参数,因此在构造请求时需要先从之前的Response中提取出所需的参数。

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
from lxml import etree
from locust import TaskSet, task, HttpLocust

class UserBehavior(TaskSet):

@staticmethod
def get_session(html):
tree = etree.HTML(html)
return tree.xpath("//div[@class='btnbox']/input[@name='session']/@value")[0]

@task(10)
def test_login(self):
html = self.client.get('/login').text
username = 'user@compay.com'
password = '123456'
session = self.get_session(html)
payload = {
'username': username,
'password': password,
'session': session
}
self.client.post('/login', data=payload)

class WebsiteUser(HttpLocust):
host = 'https://debugtalk.com'
task_set = UserBehavior
min_wait = 1000
max_wait = 3000

参数化

循环取数据,数据可重复使用

所有并发虚拟用户共享同一份测试数据,各虚拟用户在数据列表中循环取值。
例如,模拟3用户并发请求网页,总共有100个URL地址,每个虚拟用户都会依次循环加载这100个URL地址;加载示例如下表所示。

\ vuser1 vuser2 vuser3
iteration1 url1 url1 url1
iteration2 url2 url2 url2
… … … …
iteration100 url100 url100 url100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from locust import TaskSet, task, HttpLocust

class UserBehavior(TaskSet):
def on_start(self):
self.index = 0

@task
def test_visit(self):
url = self.locust.share_data[self.index]
print('visit url: %s' % url)
self.index = (self.index + 1) % len(self.locust.share_data)
self.client.get(url)

class WebsiteUser(HttpLocust):
host = 'https://debugtalk.com'
task_set = UserBehavior
share_data = ['url1', 'url2', 'url3', 'url4', 'url5']
min_wait = 1000
max_wait = 3000

保证并发测试数据唯一性,不循环取数据

所有并发虚拟用户共享同一份测试数据,并且保证虚拟用户使用的数据不重复。
例如,模拟3用户并发注册账号,总共有9个账号,要求注册账号不重复,注册完毕后结束测试;加载示例如下表所示。

\ vuser1 vuser2 vuser3
iteration1 account1 account2 account3
iteration2 account4 account6 account5
iteration3 account7 account9 account8
N/A - — -
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
from locust import TaskSet, task, HttpLocust
import queue

class UserBehavior(TaskSet):

@task
def test_register(self):
try:
data = self.locust.user_data_queue.get()
except queue.Empty:
print('account data run out, test ended.')
exit(0)

print('register with user: {}, pwd: {}'\
.format(data['username'], data['password']))
payload = {
'username': data['username'],
'password': data['password']
}
self.client.post('/register', data=payload)

class WebsiteUser(HttpLocust):
host = 'https://debugtalk.com'
task_set = UserBehavior

user_data_queue = queue.Queue()
for index in range(100):
data = {
"username": "test%04d" % index,
"password": "pwd%04d" % index,
"email": "test%04d@debugtalk.test" % index,
"phone": "186%08d" % index,
}
user_data_queue.put_nowait(data)

min_wait = 1000
max_wait = 3000

保证并发测试数据唯一性,循环取数据

所有并发虚拟用户共享同一份测试数据,保证并发虚拟用户使用的数据不重复,并且数据可循环重复使用。
例如,模拟3用户并发登录账号,总共有9个账号,要求并发登录账号不相同,但数据可循环使用;加载示例如下表所示。

\ vuser1 vuser2 vuser3
iteration1 account1 account2 account3
iteration2 account4 account6 account5
iteration3 account7 account9 account8
iteration4 account1 account2 account3
iteration5 account4 account5 account6
… … … …

该种场景的实现方式与上一种场景基本相同,唯一的差异在于,每次使用完数据后,需要再将数据放入队列中。

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
from locust import TaskSet, task, HttpLocust
import queue

class UserBehavior(TaskSet):

@task
def test_register(self):
try:
data = self.locust.user_data_queue.get()
except queue.Empty:
print('account data run out, test ended.')
exit(0)

print('register with user: {}, pwd: {}'\
.format(data['username'], data['password']))
payload = {
'username': data['username'],
'password': data['password']
}
self.client.post('/register', data=payload)
self.locust.user_data_queue.put_nowait(data)

class WebsiteUser(HttpLocust):
host = 'https://debugtalk.com'
task_set = UserBehavior

user_data_queue = queue.Queue()
for index in range(100):
data = {
"username": "test%04d" % index,
"password": "pwd%04d" % index,
"email": "test%04d@debugtalk.test" % index,
"phone": "186%08d" % index,
}
user_data_queue.put_nowait(data)

min_wait = 1000
max_wait = 3000

深入浅出开源性能测试工具 Locust(使用篇)

发表于 2017-02-22 | 更新于 2019-04-09 | 分类于 Testing , 性能测试

在《【LocustPlus序】漫谈服务端性能测试》中,我对服务端性能测试的基础概念和性能测试工具的基本原理进行了介绍,并且重点推荐了Locust这一款开源性能测试工具。然而,当前在网络上针对Locust的教程极少,不管是中文还是英文,基本都是介绍安装方法和简单的测试案例演示,但对于较复杂测试场景的案例演示却基本没有,因此很多测试人员都感觉难以将Locust应用到实际的性能测试工作当中。

经过一段时间的摸索,包括通读Locust官方文档和项目源码,并且在多个性能测试项目中对Locust进行应用实践,事实证明,Locust完全能满足日常的性能测试需求,LoadRunner能实现的功能Locust也基本都能实现。

本文将从Locust的功能特性出发,结合实例对Locust的使用方法进行介绍。考虑到大众普遍对LoadRunner比较熟悉,在讲解Locust时也会采用LoadRunner的一些概念进行类比。

概述

先从Locust的名字说起。Locust的原意是蝗虫,原作者之所以选择这个名字,估计也是听过这么一句俗语,“蝗虫过境,寸草不生”。我在网上找了张图片,大家可以感受下。

而Locust工具生成的并发请求就跟一大群蝗虫一般,对我们的被测系统发起攻击,以此检测系统在高并发压力下是否能正常运转。

在《【LocustPlus序】漫谈服务端性能测试》中说过,服务端性能测试工具最核心的部分是压力发生器,而压力发生器的核心要点有两个,一是真实模拟用户操作,二是模拟有效并发。

在Locust测试框架中,测试场景是采用纯Python脚本进行描述的。对于最常见的HTTP(S)协议的系统,Locust采用Python的requests库作为客户端,使得脚本编写大大简化,富有表现力的同时且极具美感。而对于其它协议类型的系统,Locust也提供了接口,只要我们能采用Python编写对应的请求客户端,就能方便地采用Locust实现压力测试。从这个角度来说,Locust可以用于压测任意类型的系统。

在模拟有效并发方面,Locust的优势在于其摒弃了进程和线程,完全基于事件驱动,使用gevent提供的非阻塞IO和coroutine来实现网络层的并发请求,因此即使是单台压力机也能产生数千并发请求数;再加上对分布式运行的支持,理论上来说,Locust能在使用较少压力机的前提下支持极高并发数的测试。

脚本编写

编写Locust脚本,是使用Locust的第一步,也是最为重要的一步。

简单示例

先来看一个最简单的示例。

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

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

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

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

class WebsiteUser(HttpLocust):
task_set = WebsiteTasks
host = "https://debugtalk.com"
min_wait = 1000
max_wait = 5000

在这个示例中,定义了针对https://debugtalk.com网站的测试场景:先模拟用户登录系统,然后随机地访问首页(/)和关于页面(/about/),请求比例为2:1;并且,在测试过程中,两次请求的间隔时间为1~5秒间的随机值。

那么,如上Python脚本是如何表达出以上测试场景的呢?

从脚本中可以看出,脚本主要包含两个类,一个是WebsiteUser(继承自HttpLocust,而HttpLocust继承自Locust),另一个是WebsiteTasks(继承自TaskSet)。事实上,在Locust的测试脚本中,所有业务测试场景都是在Locust和TaskSet两个类的继承子类中进行描述的。

那如何理解Locust和TaskSet这两个类呢?

简单地说,Locust类就好比是一群蝗虫,而每一只蝗虫就是一个类的实例。相应的,TaskSet类就好比是蝗虫的大脑,控制着蝗虫的具体行为,即实际业务场景测试对应的任务集。

这个比喻可能不是很准确,接下来,我将分别对Locust和TaskSet两个类进行详细介绍。

class HttpLocust(Locust)

在Locust类中,具有一个client属性,它对应着虚拟用户作为客户端所具备的请求能力,也就是我们常说的请求方法。通常情况下,我们不会直接使用Locust类,因为其client属性没有绑定任何方法。因此在使用Locust时,需要先继承Locust类,然后在继承子类中的client属性中绑定客户端的实现类。

对于常见的HTTP(S)协议,Locust已经实现了HttpLocust类,其client属性绑定了HttpSession类,而HttpSession又继承自requests.Session。因此在测试HTTP(S)的Locust脚本中,我们可以通过client属性来使用Python requests库的所有方法,包括GET/POST/HEAD/PUT/DELETE/PATCH等,调用方式也与requests完全一致。另外,由于requests.Session的使用,因此client的方法调用之间就自动具有了状态记忆的功能。常见的场景就是,在登录系统后可以维持登录状态的Session,从而后续HTTP请求操作都能带上登录态。

而对于HTTP(S)以外的协议,我们同样可以使用Locust进行测试,只是需要我们自行实现客户端。在客户端的具体实现上,可通过注册事件的方式,在请求成功时触发events.request_success,在请求失败时触发events.request_failure即可。然后创建一个继承自Locust类的类,对其设置一个client属性并与我们实现的客户端进行绑定。后续,我们就可以像使用HttpLocust类一样,测试其它协议类型的系统。

原理就是这样简单!

在Locust类中,除了client属性,还有几个属性需要关注下:

  • task_set: 指向一个TaskSet类,TaskSet类定义了用户的任务信息,该属性为必填;
  • max_wait/min_wait: 每个用户执行两个任务间隔时间的上下限(毫秒),具体数值在上下限中随机取值,若不指定则默认间隔时间固定为1秒;
  • host:被测系统的host,当在终端中启动locust时没有指定--host参数时才会用到;
  • weight:同时运行多个Locust类时会用到,用于控制不同类型任务的执行权重。

测试开始后,每个虚拟用户(Locust实例)的运行逻辑都会遵循如下规律:

  1. 先执行WebsiteTasks中的on_start(只执行一次),作为初始化;
  2. 从WebsiteTasks中随机挑选(如果定义了任务间的权重关系,那么就是按照权重关系随机挑选)一个任务执行;
  3. 根据Locust类中min_wait和max_wait定义的间隔时间范围(如果TaskSet类中也定义了min_wait或者max_wait,以TaskSet中的优先),在时间范围中随机取一个值,休眠等待;
  4. 重复2~3步骤,直至测试任务终止。

class TaskSet

再说下TaskSet类。

性能测试工具要模拟用户的业务操作,就需要通过脚本模拟用户的行为。在前面的比喻中说到,TaskSet类好比蝗虫的大脑,控制着蝗虫的具体行为。

具体地,TaskSet类实现了虚拟用户所执行任务的调度算法,包括规划任务执行顺序(schedule_task)、挑选下一个任务(execute_next_task)、执行任务(execute_task)、休眠等待(wait)、中断控制(interrupt)等等。在此基础上,我们就可以在TaskSet子类中采用非常简洁的方式来描述虚拟用户的业务测试场景,对虚拟用户的所有行为(任务)进行组织和描述,并可以对不同任务的权重进行配置。

在TaskSet子类中定义任务信息时,可以采取两种方式,@task装饰器和tasks属性。

采用@task装饰器定义任务信息时,描述形式如下:

1
2
3
4
5
6
7
8
9
10
from locust import TaskSet, task

class UserBehavior(TaskSet):
@task(1)
def test_job1(self):
self.client.get('/job1')

@task(2)
def test_job2(self):
self.client.get('/job2')

采用tasks属性定义任务信息时,描述形式如下:

1
2
3
4
5
6
7
8
9
10
11
from locust import TaskSet

def test_job1(obj):
obj.client.get('/job1')

def test_job2(obj):
obj.client.get('/job2')

class UserBehavior(TaskSet):
tasks = {test_job1:1, test_job2:2}
# tasks = [(test_job1,1), (test_job1,2)] # 两种方式等价

在如上两种定义任务信息的方式中,均设置了权重属性,即执行test_job2的频率是test_job1的两倍。

若不指定执行任务的权重,则相当于比例为1:1。

1
2
3
4
5
6
7
8
9
10
from locust import TaskSet, task

class UserBehavior(TaskSet):
@task
def test_job1(self):
self.client.get('/job1')

@task
def test_job2(self):
self.client.get('/job2')
1
2
3
4
5
6
7
8
9
10
11
from locust import TaskSet

def test_job1(obj):
obj.client.get('/job1')

def test_job2(obj):
obj.client.get('/job2')

class UserBehavior(TaskSet):
tasks = [test_job1, test_job2]
# tasks = {test_job1:1, test_job2:1} # 两种方式等价

在TaskSet子类中除了定义任务信息,还有一个是经常用到的,那就是on_start函数。这个和LoadRunner中的vuser_init功能相同,在正式执行测试前执行一次,主要用于完成一些初始化的工作。例如,当测试某个搜索功能,而该搜索功能又要求必须为登录态的时候,就可以先在on_start中进行登录操作;前面也提到,HttpLocust使用到了requests.Session,因此后续所有任务执行过程中就都具有登录态了。

脚本增强

掌握了HttpLocust和TaskSet,我们就基本具备了编写测试脚本的能力。此时再回过头来看前面的案例,相信大家都能很好的理解了。

然而,当面对较复杂的测试场景,可能有的同学还是会感觉无从下手;例如,很多时候脚本需要做关联或参数化处理,这些在LoadRunner中集成的功能,换到Locust中就不知道怎么实现了。可能也是这方面的原因,造成很多测试人员都感觉难以将Locust应用到实际的性能测试工作当中。

其实这也跟Locust的目标定位有关,Locust的定位就是small and very hackable。但是小巧并不意味着功能弱,我们完全可以通过Python脚本本身来实现各种各样的功能,如果大家有疑问,我们不妨逐项分解来看。

在LoadRunner这款功能全面应用广泛的商业性能测试工具中,脚本增强无非就涉及到四个方面:

  • 关联
  • 参数化
  • 检查点
  • 集合点

先说关联这一项。在某些请求中,需要携带之前从Server端返回的参数,因此在构造请求时需要先从之前请求的Response中提取出所需的参数,常见场景就是session_id。针对这种情况,LoadRunner虽然可能通过录制脚本进行自动关联,但是效果并不理想,在实际测试过程中也基本都是靠测试人员手动的来进行关联处理。

在LoadRunner中手动进行关联处理时,主要是通过使用注册型函数,例如web_reg_save_param,对前一个请求的响应结果进行解析,根据左右边界或其它特征定位到参数值并将其保存到参数变量,然后在后续请求中使用该参数。采用同样的思想,我们在Locust脚本中也完全可以实现同样的功能,毕竟只是Python脚本,通过官方库函数re.search就能实现所有需求。甚至针对html页面,我们也可以采用lxml库,通过etree.HTML(html).xpath来更优雅地实现元素定位。

然后再来看参数化这一项。这一项极其普遍,主要是用在测试数据方面。但通过归纳,发现其实也可以概括为三种类型。

  • 循环取数据,数据可重复使用:e.g. 模拟3用户并发请求网页,总共有100个URL地址,每个虚拟用户都会依次循环加载这100个URL地址;
  • 保证并发测试数据唯一性,不循环取数据:e.g. 模拟3用户并发注册账号,总共有90个账号,要求注册账号不重复,注册完毕后结束测试;
  • 保证并发测试数据唯一性,循环取数据:模拟3用户并发登录账号,总共有90个账号,要求并发登录账号不相同,但数据可循环使用。

通过以上归纳,可以确信地说,以上三种类型基本上可以覆盖我们日常性能测试工作中的所有参数化场景。

在LoadRunner中是有一个集成的参数化模块,可以直接配置参数化策略。那在Locust要怎样实现该需求呢?

答案依旧很简单,使用Python的list和queue数据结构即可!具体做法是,在WebsiteUser定义一个数据集,然后所有虚拟用户在WebsiteTasks中就可以共享该数据集了。如果不要求数据唯一性,数据集选择list数据结构,从头到尾循环遍历即可;如果要求数据唯一性,数据集选择queue数据结构,取数据时进行queue.get()操作即可,并且这也不会循环取数据;至于涉及到需要循环取数据的情况,那也简单,每次取完数据后再将数据插入到队尾即可,queue.put_nowait(data)。

最后再说下检查点。该功能在LoadRunner中通常是使用web_reg_find这类注册函数进行检查的。在Locust脚本中,处理就更方便了,只需要对响应的内容关键字进行assert xxx in response操作即可。

针对如上各种脚本增强的场景,我也通过代码示例分别进行了演示。但考虑到文章中插入太多代码会影响到阅读,因此将代码示例部分剥离了出来,如有需要请点击查看《深入浅出开源性能测试工具Locust(脚本增强)》。

Locust运行模式

在开始运行Locust脚本之前,我们先来看下Locust支持的运行模式。

运行Locust时,通常会使用到两种运行模式:单进程运行和多进程分布式运行。

单进程运行模式的意思是,Locust所有的虚拟并发用户均运行在单个Python进程中,具体从使用形式上,又分为no_web和web两种形式。该种模式由于单进程的原因,并不能完全发挥压力机所有处理器的能力,因此主要用于调试脚本和小并发压测的情况。

当并发压力要求较高时,就需要用到Locust的多进程分布式运行模式。从字面意思上看,大家可能第一反应就是多台压力机同时运行,每台压力机分担负载一部分的压力生成。的确,Locust支持任意多台压力机(一主多从)的分布式运行模式,但这里说到的多进程分布式运行模式还有另外一种情况,就是在同一台压力机上开启多个slave的情况。这是因为当前阶段大多数计算机的CPU都是多处理器(multiple processor cores),单进程运行模式下只能用到一个处理器的能力,而通过在一台压力机上运行多个slave,就能调用多个处理器的能力了。比较好的做法是,如果一台压力机有N个处理器内核,那么就在这台压力机上启动一个master,N个slave。当然,我们也可以启动N的倍数个slave,但是根据我的试验数据,效果跟N个差不多,因此只需要启动N个slave即可。

脚本调试

Locust脚本编写完毕后,通常不会那么顺利,在正式开始性能测试之前还需要先调试运行下。

不过,Locust脚本虽然为Python脚本,但却很难直接当做Python脚本运行起来,为什么呢?这主要还是因为Locust脚本中引用了HttpLocust和TaskSet这两个类,如果要想直接对其进行调用测试,会发现编写启动脚本是一个比较困难的事情。因为这个原因,刚接触Locust的同学可能就会觉得Locust脚本不好调试。

但这个问题也能克服,那就是借助Locust的单进程no_web运行模式。

在Locust的单进程no_web运行模式中,我们可以通过--no_web参数,指定并发数(-c)和总执行次数(-n),直接在Terminal中执行脚本。

在此基础上,当我们想要调试Locust脚本时,就可以在脚本中需要调试的地方通过print打印日志,然后将并发数和总执行次数都指定为1,执行形式如下所示。

1
$ locust -f locustfile.py --no_web -c 1 -n 1

通过这种方式,我们就能很方便地对Locust脚本进行调试了。

执行测试

Locust脚本调试通过后,就算是完成了所有准备工作,可以开始进行压力测试了。

Locust是通过在Terminal中执行命令进行启动的,通用的参数有如下两个:

  • -H, --host:被测系统的host,若在Terminal中不进行指定,就需要在Locust子类中通过host参数进行指定;
  • -f, --locustfile:指定执行的Locust脚本文件;

除了这两个通用的参数,我们还需要根据实际测试场景,选择不同的Locust运行模式,而模式的指定也是通过其它参数来进行控制的。

单进程运行

no_web

如果采用no_web形式,则需使用--no-web参数,并会用到如下几个参数。

  • -c, --clients:指定并发用户数;
  • -n, --num-request:指定总执行测试;
  • -r, --hatch-rate:指定并发加压速率,默认值位1。
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
$ locust -H https://debugtalk.com -f demo.py --no-web -c1 -n2
[2017-02-21 21:27:26,522] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2
[2017-02-21 21:27:26,523] Leos-MacBook-Air.local/INFO/locust.runners: Hatching and swarming 1 clients at the rate 1 clients/s...
Name # reqs # fails Avg Min Max | Median req/s
--------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------
Total 0 0(0.00%) 0.00

[2017-02-21 21:27:27,526] Leos-MacBook-Air.local/INFO/locust.runners: All locusts hatched: WebsiteUser: 1
[2017-02-21 21:27:27,527] Leos-MacBook-Air.local/INFO/locust.runners: Resetting stats

Name # reqs # fails Avg Min Max | Median req/s
--------------------------------------------------------------------------------------------------------------------------------------
GET /about/ 0 0(0.00%) 0 0 0 | 0 0.00
--------------------------------------------------------------------------------------------------------------------------------------
Total 0 0(0.00%) 0.00

Name # reqs # fails Avg Min Max | Median req/s
--------------------------------------------------------------------------------------------------------------------------------------
GET /about/ 1 0(0.00%) 17 17 17 | 17 0.00
--------------------------------------------------------------------------------------------------------------------------------------
Total 1 0(0.00%) 0.00

[2017-02-21 21:27:32,420] Leos-MacBook-Air.local/INFO/locust.runners: All locusts dead

[2017-02-21 21:27:32,421] Leos-MacBook-Air.local/INFO/locust.main: Shutting down (exit code 0), bye.
Name # reqs # fails Avg Min Max | Median req/s
--------------------------------------------------------------------------------------------------------------------------------------
GET / 1 0(0.00%) 20 20 20 | 20 0.00
GET /about/ 1 0(0.00%) 17 17 17 | 17 0.00
--------------------------------------------------------------------------------------------------------------------------------------
Total 2 0(0.00%) 0.00

Percentage of the requests completed within given times
Name # reqs 50% 66% 75% 80% 90% 95% 98% 99% 100%
--------------------------------------------------------------------------------------------------------------------------------------
GET / 1 20 20 20 20 20 20 20 20 20
GET /about/ 1 17 17 17 17 17 17 17 17 17
--------------------------------------------------------------------------------------------------------------------------------------

web

如果采用web形式,,则通常情况下无需指定其它额外参数,Locust默认采用8089端口启动web;如果要使用其它端口,就可以使用如下参数进行指定。

  • -P, --port:指定web端口,默认为8089.
1
2
3
$ locust -H https://debugtalk.com -f demo.py
[2017-02-21 21:31:26,334] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089
[2017-02-21 21:31:26,334] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2

此时,Locust并没有开始执行测试,还需要在Web页面中配置参数后进行启动。

如果Locust运行在本机,在浏览器中访问http://localhost:8089即可进入Locust的Web管理页面;如果Locust运行在其它机器上,那么在浏览器中访问http://locust_machine_ip:8089即可。

在Locust的Web管理页面中,需要配置的参数只有两个:

  • Number of users to simulate: 设置并发用户数,对应中no_web模式的-c, --clients参数;
  • Hatch rate (users spawned/second): 启动虚拟用户的速率,对应着no_web模式的-r, --hatch-rate参数。

参数配置完毕后,点击【Start swarming】即可开始测试。

多进程分布式运行

不管是单机多进程,还是多机负载模式,运行方式都是一样的,都是先运行一个master,再启动多个slave。

启动master时,需要使用--master参数;同样的,如果要使用8089以外的端口,还需要使用-P, --port参数。

1
2
3
$ locust -H https://debugtalk.com -f demo.py --master --port=8088
[2017-02-21 22:59:57,308] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8088
[2017-02-21 22:59:57,310] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2

master启动后,还需要启动slave才能执行测试任务。

启动slave时需要使用--slave参数;在slave中,就不需要再指定端口了。

1
2
3
4
$ locust -H https://debugtalk.com -f demo.py --slave
[2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2
[2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.runners: Client 'Leos-MacBook-Air.local_980ab0eec2bca517d03feb60c31d6a3a' reported as
ready. Currently 2 clients ready to swarm.

如果slave与master不在同一台机器上,还需要通过--master-host参数再指定master的IP地址。

1
2
3
4
$ locust -H https://debugtalk.com -f demo.py --slave --master-host=<locust_machine_ip>
[2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2
[2017-02-21 23:07:58,696] Leos-MacBook-Air.local/INFO/locust.runners: Client 'Leos-MacBook-Air.local_980ab0eec2bca517d03feb60c31d6a3a' reported as
ready. Currently 2 clients ready to swarm.

master和slave都启动完毕后,就可以在浏览器中通过http://locust_machine_ip:8089进入Locust的Web管理页面了。使用方式跟单进程web形式完全相同,只是此时是通过多进程负载来生成并发压力,在web管理界面中也能看到实际的slave数量。

测试结果展示

Locust在执行测试的过程中,我们可以在web界面中实时地看到结果运行情况。

相比于LoadRunner,Locust的结果展示十分简单,主要就四个指标:并发数、RPS、响应时间、异常率。但对于大多数场景来说,这几个指标已经足够了。

在上图中,RPS和平均响应时间这两个指标显示的值都是根据最近2秒请求响应数据计算得到的统计值,我们也可以理解为瞬时值。

如果想看性能指标数据的走势,就可以在Charts栏查看。在这里,可以查看到RPS和平均响应时间在整个运行过程中的波动情况。这个功能之前在Locust中一直是缺失的,直到最近,这个坑才被我之前在阿里移动的同事(网络IDmyzhan)给填上了。当前该功能已经合并到Locust了,更新到最新版即可使用。

除了以上数据,Locust还提供了整个运行过程数据的百分比统计值,例如我们常用的90%响应时间、响应时间中位值,该数据可以通过Download response time distribution CSV获得,数据展示效果如下所示。

总结

通过前面对Locust全方位的讲解,相信大家对Locust的功能特性已经非常熟悉了,在实际项目中将Locust作为生产力工具应该也没啥问题了。

不过,任何一款工具都不是完美的,必定都会存在一些不足之处。但是好在Locust具有极强的可定制型,当我们遇到一些特有的需求时,可以在Locust上很方便地实现扩展。

还是前面提到的那位技术大牛(myzhan),他为了摆脱CPython的GIL和gevent的 monkey_patch(),将Locust的slave端采用golang进行了重写,采用goroutine取代了gevent。经过测试,相较于原生的Python实现,他的这套golang实现具有5~10倍以上的性能提升。当前,他已经将该实现开源,项目名称为myzhan/boomer,如果大家感兴趣,可以阅读他的博客文章进一步了解,《用 golang 来编写压测工具》。

如果我们也想在Locust的基础上进行二次开发,那要怎么开始呢?

毫无疑问,阅读Locust的项目源码是必不可少的第一步。可能对于很多人来说,阅读开源项目源码是一件十分困难的事情,不知道如何着手,在知乎上也看到好多关于如何阅读开源项目源码的提问。事实上,Locust项目的代码结构清晰,核心代码量也比较少,十分适合阅读学习。哪怕只是想体验下阅读开源项目源码,或者说想提升下自己的Python技能,Locust也是个不错的选择。

在下一篇文章中,我将对Locust源码进行解析,《深入浅出开源性能测试工具Locust(源码篇)》,敬请期待!

春节旅行之意法印象

发表于 2017-02-02 | 更新于 2019-04-03 | 分类于 成长历程

今年春节没有像往常一样回家过年,而是到欧洲旅行(意大利和法国),算是蜜月游吧。第一次到欧洲,确切地说,第一次出国,新鲜感还是蛮大的,趁着返程飞机上的空闲时间,写篇博客记录下。

我这次旅行全程12天,游玩的城市有罗马、梵蒂冈、佛罗伦萨、米兰和巴黎,景点以历史名胜和城市风光为主。全程自助游,行程安排比较自由宽松。当然,这篇博客并不是旅行攻略,描述的内容也不会面面俱到,只是挑了几个感触较深的点,观点可能很是片面,如有偏颇还请留言讨论。

先说下总体印象吧。如果说要通过几个关键词来概括这次旅行的所见所闻,特别是跟国内(一二线城市)进行对比的话,我个人的印象是:自然环境优美、生活节奏舒缓、饮食类型单一、公共设施陈旧、公共安全紧张、工程师氛围稀缺。

自然环境优美没啥好说的,之前不管是通过网络,还是和朋友的聊天交流,对欧洲的印象就是自然环境特别好。实际情况也的确是这样的。空气质量这块儿我没啥特别的感觉,因为深圳的空气本来也挺好的。见过的河水湖泊很是清澈,完全没有污染的痕迹。绿化非常赞,除了街道的树木,在城市中有很多森林公园。印象最深的就是小动物了,虽然是在城市里,但是到处都能看到鸽子、海鸥,在公园里还能看到野鸡、野鸭、天鹅,而且好玩的是这些动物基本都不怕人,靠近它们的时候也不会躲避,我还趁天鹅把头钻进水里捕食时偷偷摸了它的屁股。想想在国内,能在城市中看见只麻雀就算稀罕了,这方面真是没法比。

在人文环境方面,最大的感触就是欧洲的生活节奏十分舒缓。店铺通常开得挺晚的,关得也特别早,特别是在罗马和佛罗伦萨,下午六七点天黑后,就基本很少看到人了。在我等天朝IT狗的眼里,他们的工作量真是极其的不饱和啊。可能也是这方面的原因,商家普遍的服务态度都挺好的,很有耐心。有一次在梵蒂冈的礼品店里,虽然语言交流不是很顺畅,但老太太还是很有耐心的一一介绍,最后由于感觉价格比较贵,不大好意思地说不买了,老太太也完全没有表现出任何不悦,而是笑盈盈地说了句Thank you, have a nice day。在欧洲街道上,如果是在没有红绿灯的情况下,司机都会挥手示意让行人先过马路。刚开始的时候还有些不习惯,站在路口等汽车先走,结果司机更有耐心,执意等行人先走。哦对,在欧洲马路上,基本上没有听到汽车喇叭声,有时候没有看到身后的车辆,司机会探出头来打招呼。另外,在欧洲街头经常会看见各种行为艺术,特别是在罗马斗兽场的一条街道上,一路走去,吹拉弹唱跳,形式各异,水准颇高,真是一道靓丽的风景。看到他们总会经不住感叹,人家这才是生活啊,我等只能叫活着。

说到语言交流,这跟我之前的预期不大一样。在没出国之前,原以为欧洲的英语应该普及度很高,结果到了这边后发现并非如此,好多本地人并不会英语,即使是警察这样的公务员,会说英语的也是少数,而且口音还特别重。不过语言也真是一个神奇的东西,可能同样的场景,做英语听力理解题时无法正确答题,但是在面对面交流时,加上简单的肢体动作和眼神,基本上只要不是太复杂的场景,都难很好的完成交流。最多为了保险起见,我再用英语复述一遍我的理解,跟对方进行确认即可。哦对了,在欧洲遇到的好多人虽然不会说英语,但是貌似听懂是没啥问题,只是可能有些词汇不理解,需要变换下说法。例如,在询问能不能飞无人机时,跟他们说drone他们都不知道是啥意思,但是说mini aircraft他们就会明白了。

然后说下差异最大的饮食吧。跟博大精深的中餐相比,欧洲的饮食真是单一乏味。当然,像法餐这样高逼格的不在讨论范围内,价格太贵,我相信即使是本地人也不至于天天这么吃吧。在意大利和法国,当地人吃得最多的应该就是披萨和汉堡了。在我看来,意大利人真是除了披萨意面就没啥别的了。虽然披萨被做出花儿了,各种馅儿的都有,但老是这么吃还是受不了。意面更别提了,我们吃了一次就不想再吃。有一次在披萨店惊奇地发现有米饭,结果还是半生的,完全没法吃,想想也是,微波炉哪能做出米饭来。因此我们后面每到一个城市,就到处找中餐馆。中餐的价格普遍比披萨店贵两倍,面条、炒饭、盖浇饭这类快餐,普遍在7~9欧的样子,如果点菜的话,人均消费差不多要十多欧,这还是在比较克制的情况下。即使是这样,中餐馆的生意还都特别好。在罗马的一家中餐馆给我们的印象尤其深刻,我们是通过大众点评找过去的,结果到了以后发现门口贴纸告知要五点半开门。我们还以为店家不会开门了,就先在旁边的一家披萨店先吃着。过一会儿后,发现那家店门口逐渐聚集了一波中国人,到了五点半的时候,还真准时开门了。进去之后,发现基本满座了,全是中国人。一会儿后,服务员领班说,要等一阵才能点菜,因为厨师们要先吃饭。然后,所有顾客就看着一大桌厨师和服务员在那儿吃。我们也真是长见识了,原来餐馆还能这么开的,牛逼,换成在国内试试?

除了饮食,公共基础设施方面跟国内也没法比。首先是公交地铁,普遍比叫陈旧。特别是巴黎的RER线,简直难以相信这就是号称时尚浪漫之都的巴黎,刷票的门坏了好几个,各种电线暴露在外面垂在半空中,好多通道没有电梯,地铁车厢特别脏,地面积了厚厚的泥土和水渍,估计是好久都没有打扫过了,让我们一度以为我们到了假巴黎。然后说下欧洲的公厕,上一次要1欧,即使是在有的麦当劳店里,上厕所也是要收费的,每次想上厕所时换算下,要七块多人民币,真是尿不起啊有木有!不过公厕收费也比较好理解,因为欧洲的人力成本本来就比较高,特别是清洁工这类工种。除了硬件类的基础设施,软件类的服务跟国内相比也落后太多。在国内的时候,基本上带个手机出门就行了,吃饭、购物、叫车全电子化,但是在欧洲都基本还是用现金,大额消费可以刷信用卡,出去一趟回来兜里就好多找零的硬币。当然,虽然欧洲本土的互联网服务不咋地,但是人家能用Google的各项服务啊,光这一项我大天朝就完全被秒杀了。

在安全方面,感觉氛围比较紧张,没有在国内那么踏实。在机场、车站以及各个旅游景点,到处都是全副武装的大兵,警察也都是配枪的。本来我还打算出国后多拍下风景,特地在出国前买了一台Mavic Pro,结果到那儿以后看到这阵势,又在网上看到各种禁飞条令,因此也不敢造次,要是一不小心被当做恐怖分子击毙就不值了。另外,在街头随处可见流浪汉,甚至好多一家四五口一起睡在路边,估计是从其它国家过来避难的。如果政府没有救助措施的话,估计也会形成一定的安全隐患,之前也听朋友说最好晚上不要出门,抢劫、行窃比较多,还好我们没有遇到。

最后再说下跟我们软件工程师的相关的吧。不像在国内,上下班时满眼的笔记本电脑背包,在意大利和法国我真是一个都没看到过。我在想,这里是没有程序员的么,还是说程序员的电脑也是装在意大利皮革的手提包里了?不过,在艺术气息如此浓厚的地方,本地人应该也很难对写代码感兴趣吧。在巴黎的Airbnb民宿中,虽然没看到房东本人,但是从房屋装饰、陈列书籍和CD碟片来看,房东应该也是个搞绘画或雕塑的。之前听朋友说欧洲的技术工种稀缺,过去比较好找工作,当时我还颇为心动的;现在看来,是否稀缺不好说,但估计需求也比较少,即使过去,感觉从技术氛围的角度来看,也不是一个好的选择。所以去欧洲工作的想法先暂时搁置吧,后面再找机会去其它国家转转。

以上便是我这次欧洲意法之行的见闻和感受了,收获还是蛮大的。之前可能是一直处于天朝的大环境中,多多少少会变得有些功利和急躁,大至职业发展薪酬涨幅,小至绩效考核分数排名,很难做到不那么在意。但是当我在欧洲街头看到各类艺人专注于技艺表演时,在阿诺河旁看到白发老人沉浸于绘画写生时,我不禁在想,专注于自己喜爱的事情已经就足够幸福了,何必被世俗的眼光所左右,被眼前的蝇头小利所蒙蔽呢。虽说人生短暂,但是即使从现在开始,也还有好几十年的时光,足够自己折腾了。

嗯,前提是永远不要为自己设限。

Keep learning and programming.

我的 2016 年终总结

发表于 2016-12-31 | 更新于 2019-04-03 | 分类于 成长历程

2016年于我而言,经历的事情挺多的,现在回想起来,很难相信这些都是在一年内发生的。

在5月份的时候,我从UC离职去了大疆,从移动互联网公司到了做无人机的硬件公司,从待了5年的广州到了深圳。很多朋友都很好奇换工作的原因,也许,是我还没有折腾够吧,再或者,是想到一个全新的环境去实践自己的一些想法吧。有种说法是,判断一个选择是否正确的方式,就是假设让你再回到之前选择的时刻,你还是否会做出同样的决定。如果让我回答这个问题,还真不好说,毕竟UC和大疆都是非常优秀的公司,只是作为成熟的上市公司和处于快速成长期的创业公司,各自有着不同的特质罢了。不过,我对现在的工作状态挺满意的,我想这就够了。

在技术方面,今年接触的领域挺多的。离开UC之前,主要是在Android客户端性能测试的持续集成方面,探索实践了几类专项测试,并将公司的测试平台任务成功率从不足70%提升到了95%以上。到了大疆以后,发现这边在测试这块儿基本上还处于蛮荒状态,于是便借此机会从零开始建设测试平台。刚开始接手的项目是DXX+Discover,于是新接触了iOS测试,从零开始做了iOS的UI自动化测试,并开源了一个基于Appium的测试框架AppiumBooster;然后搞了持续集成测试平台,将自动化打包构建和iOS自动化测试串了起来。后面因为项目调整,接手了商城性能测试,重新捡起了之前的老本行服务端性能测试,不过没有使用之前精通的LoadRunner,而是选择了Locust这款开源的测试工具,并在其基础上做了一些扩展。再到后来,公司成立了互联网事业部一级部门,划分了质量部二级部门,然后我就从业务测试工作中抽调出来,专职做测试开发的工作,主要内容暂时是在Web的接口自动化测试和流程自动化测试方面。

除了纯粹的技术工作,今年也开始做测试岗的技术面试工作,前前后后面试了三四十人。在面试工作中,收获也挺大的,最主要的还是可以借此了解到其它公司在软件测试方面开展的情况,以及其他同行在做的事情,这些都挺有借鉴意义的。其中,有一点感触特别深,在我们这个行业,干技术的如果工作年限与实际能力不匹配,真的是挺尴尬的。虽然听上去很残忍,但是站在招聘方的角度就很好理解了,一个工作七八年的人如果技术积累跟工作了一两年的人差不多,或者是只高了一点,那么公司不管是从薪酬成本还是从员工发展潜力的角度,肯定会选择后者了。因此,既然选择了技术这条路,要想以后不经历这种尴尬,我们能做的也只有多磨练多积累了。

在今年,我开始用心经营个人博客和微信公众号(DebugTalk)。虽然在2013年的时候,当时也有开通过博客和公众号(52test.org),但那时玩的性质更多一些,没写几篇就搁置了。今年重新开始写博客的原因也挺简单的,就是想对自己的工作和学习进行下总结和记录。而且自身也越来越认同一个观点,最好的学习方式就是去教授别人,因为要向别人将一个问题通俗易懂地讲解清楚,自己就必须对其中的原理和因果联系有足够清晰的认识,有了这层动力,学习也就更有方向性,学习效率和效果也就上去了。再延伸一点,其实这也跟TDD的开发模式挺类似的,现在我也挺喜欢这样的开发模式,并在实际开发工作中践行TDD。

截止当前,今年更新的博客大概有30篇左右,主要内容还是工作中对一些技术的感悟和对学习过程的记录。从数量上看,这个量还是挺少的,而且更新频率也从每周数篇,到每周一篇,再到半月一篇,一月一篇,工作繁忙是一方面的原因,更主要的还是拖延症导致的。而且一旦丢了写博客的感觉,再提笔写起来更是难上加难。还算欣慰的是,从留言反馈来看,写的东西至少还是给一部分人带去了一些价值,并且博客在搜索引擎中也有了不错的收录。特别是有时候同事搜索解决方案结果检索到我写的文章时,还是会有一些成就感的。另外,通过公众号和博客,我也认识了好多业界同行,这也是一个很大的收获。

因为个人博客的原因,今年第一次接了私活儿,挣到了第一笔工资以外的收入。当时深圳一所高校的老师看到我的博客后,觉得还不错,于是联系我想让我给他和他的同事培训,他们再将培训内容用到教学中。之前也没接过私活儿,只是觉得这也是一个不错的实践机会,所以就答应了。好在实际教学过程中,他们还挺满意,我的压力也就小了许多。这里再插入一件比较有趣的事情,第一次授课后一位老师问我是研究生还是博士,我不大好意思地说我是本科。一个本科生给博士和博士后培训,这也算是一份难得的人生经历了。不过,术业有专攻嘛,理论教学和工程实践毕竟存在一定的差异。我也挺佩服他们的,能在业余休息时间主动去学习,让学生能了解到当前工程界流行的技术,这比某些照着课本念几十年的”老教授”真的不知道强了多少倍。

除了博客分享,今年在公司内部也进行了几场技术分享,印象比较深的有:《Python的函数式编程–从入门到⎡放弃⎦》,《从0到1搭建移动APP功能自动化测试平台(AppiumBooster)》,《漫谈服务端性能测试》,《基于有限状态机的流程自动化测试》等。在演讲方面,自我感觉在时间控制方面还比较欠缺,一不小心就超时,这个在明年得好好改进下。

当然,对我个人而言,2016年最大的一件事儿就是成家了。虽然在年初的时候还完全没有想过今年会结婚的,但我还是和女朋友在2016年结束了四年的恋爱长跑,在9.19领了证,在10.3办了婚礼。当然,不是奉子成婚,请不要邪恶。在婚礼这件事情上,我们没有请婚庆主持,而是找了我们从小玩到大的好朋友,我们自己设计的流程环节,自己选的背景音乐,自己做的视频,虽然在婚礼前一晚还因为设备故障忙得焦头烂额,但是经历这么一个过程本身也是挺有意义的。

最后,我再简单地展望下2017年吧。

1、坚持写博客。至于具体指标,盯着博客阅读量和微信公众号关注人数也没啥意义,更多的还是希望能通过写博客让自己的内心静下来,同时获得自我成长。

2、进行一场对外技术分享,毕竟,面向公众演讲和面向同事演讲,感觉还是不一样的。也希望通过这种方式,逼迫自己更快的成长。

3、经得住诱惑,做好手头的工作,将公司的软件测试技术提升一个层次。这一年来收到的工作邀请也挺多的,特别是华为和腾讯,开出的薪资也很是诱人。但我还是觉得不能这么浮躁,换一个地方如果还是做同样的事情,意义也不大,还不如跟着公司一起成长。

4、学会生活,锻炼身体,陪伴家人,工作是长跑,讲究的是可持续发展。

如何优雅地一键实现 macOS 网络代理切换

发表于 2016-11-24 | 更新于 2019-04-03 | 分类于 效率工具

在macOS中配置Web代理时,通常的做法是在控制面板中进行操作,System Preferences -> Network -> Advanced -> Proxies.

macOS-Web-Proxy-Setting

这种配置方式虽然可以实现需求,但缺点在于操作比较繁琐,特别是在需要频繁切换的情况下,效率极其低下。

基于该痛点,我们希望能避免重复操作,实现快速切换配置。

Terminal中实现网络代理配置

要避免在GUI进行重复的配置操作,比较好的简化方式是在Terminal中通过命令实现同样的功能。事实上,在macOS系统中的确是存在配置网络代理的命令,该命令即是networksetup。

获取系统已有的网络服务

首先需要明确的是,macOS系统中针对不同网络服务(networkservice)的配置是独立的,因此在配置Web代理时需要进行指定。

而要获取系统中存在哪些网络服务,可以通过如下命令查看:

1
2
3
4
5
6
7
$ networksetup -listallnetworkservices
An asterisk (*) denotes that a network service is disabled.

Wi-Fi
iPhone USB
Bluetooth PAN
Thunderbolt Bridge

如果计算机是通过Wi-Fi上网的,那么我们设置网络代理时就需要对Wi-Fi进行设置。

开启Web代理

通过networksetup命令对HTTP接口设置代理时,可以采用如下命令:

1
2
$ sudo networksetup -setwebproxy <networkservice> <domain> <port number> <authenticated> <username> <password>
# e.g. sudo networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8080

执行该命令时,会开启系统的Web HTTP Proxy,并将Proxy设置为127.0.0.1:8080。

如果是对HTTPS接口设置代理时,命令为:

1
2
$ networksetup -setsecurewebproxy <networkservice> <domain> <port number> <authenticated> <username> <password>
# e.g. sudo networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8080

关闭Web代理

对应地,关闭HTTP和HTTPS代理的命令为:

1
2
3
4
5
$ sudo networksetup -setwebproxystate <networkservice> <on off>
# e.g. sudo networksetup -setwebproxystate "Wi-Fi" off

$ networksetup -setsecurewebproxystate <networkservice> <on off>
# e.g. sudo networksetup -setsecurewebproxystate "Wi-Fi" off

结合Shuttle实现一键配置

现在我们已经知道如何通过networksetup命令在Terminal中进行Web代理切换了,但如果每次都要重新输入命令和密码,还是会很麻烦,并没有真正地解决我们的痛点。

而且在实际场景中,我们通常需要同时开启或关闭HTTP、HTTPS两种协议的网络代理,这类操作如此高频,要是还能通过点击一个按钮就实现切换,那就优雅多了。

幸运的是,这种优雅的方式还真能实现,只需要结合使用Shuttle这么一款小工具。

Shuttle,简而言之,它可以将一串命令映射到macOS顶部菜单栏的快捷方式。我们要做的很简单,只需要将要实现的任务拼接成一条串行的命令即可,然后就可以在系统菜单栏中点击按钮运行整条命令。

例如,在Terminal中,要想在不手动输入sudo密码的情况下实现同时关闭HTTP和HTTPS的网络代理,就可以通过如下串行命令实现。

1
$ echo <password> | sudo -S networksetup -setwebproxystate 'Wi-Fi' off && sudo networksetup -setsecurewebproxystate 'Wi-Fi' off

类似地,我们还可以实现同时开启HTTP和HTTPS网络代理,更有甚者,我们还可以实现在同时开启HTTP和HTTPS网络代理后,启动mitmproxy抓包工具。

这一切配置都可以在Shuttle的配置文件~/.shuttle.json中完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"hosts": [
{
"mitmproxy": [
{
"name": "Open mitmproxy",
"cmd": "echo <password> | sudo -S networksetup -setwebproxy 'Wi-Fi' 127.0.0.1 8080 && sudo networksetup -setsecurewebproxy 'Wi-Fi' 127.0.0.1 8080 && workon mitmproxy && mitmproxy -p 8080"
}
],
"HTTP(S) Proxy": [
{
"name": "Turn on HTTP(S) Proxy",
"cmd": "echo <password> | sudo -S networksetup -setwebproxy 'Wi-Fi' 127.0.0.1 8080 && sudo networksetup -setsecurewebproxy 'Wi-Fi' 127.0.0.1 8080"
},
{
"name": "Turn off HTTP(S) Proxy",
"cmd": "echo <password> | sudo -S networksetup -setwebproxystate 'Wi-Fi' off && sudo networksetup -setsecurewebproxystate 'Wi-Fi' off"
},

],
},
]

配置十分简洁清晰,不用解释也能看懂。完成配置后,在macOS顶部菜单栏中就会出现如下效果的快捷方式。

macOS-Web-Proxy-Setting

后续,我们就可以通过快捷方式实现一键切换HTTP(S)代理配置、一键启动mitmproxy抓包工具了。

说到mitmproxy这款开源的抓包工具,只能说相见恨晚,第一次使用它时就被惊艳到了,情不自禁地想给它点个赞!自从使用了mitmproxy,我现在基本上就不再使用Fiddler和Charles了,日常工作中HTTP(S)抓包任务全靠它搞定。

哦对了,mitmproxy不仅可以实现抓包任务,还可以跟locust性能测试工具紧密结合,直接将抓取的数据包生成locust脚本啊!

mitmproxy如此强大,本文就不再多说了,后续必须得写一篇博客单独对其详细介绍。

漫谈服务端性能测试

发表于 2016-11-02 | 更新于 2019-04-09 | 分类于 Testing , 性能测试

最近因为工作原因,我又拾起了老本行,开始做Web性能测试。之前虽然做过三四年的性能测试,但是在博客和开源项目方面都没有什么输出,一直是一个很大的遗憾。因此,近期打算围绕服务端性能测试的话题,将自己在这方面的经历进行整理。并且,最近使用的性能测试工具Locust感觉挺不错的,只是其功能比较单薄,特别是在性能指标监控和测试报告图表方面比较缺失,因此也打算在Locust的基础上做二次开发,打造一款自己用得顺手的性能测试工具,暂且将其命名为LocustPlus吧。

简述性能测试

提起性能测试,可能移动APP的从业人员会感觉比较混淆,因为在客户端(Android、iOS)中也有性能测试专项,主要涉及的是APP的启动时间、内存、包大小、帧率,流量等客户端相关的指标。在本博客之前的文章中,也包含了一些客户端性能测试的内容。需要说明的是,本文所讲解的性能测试都是针对服务器端,尤指Web系统的,与移动APP的性能测试完全是不同的领域。

那么,什么是服务端的性能测试呢?

先从大家都熟悉的功能测试说起吧。例如,我们要测试一个搜索功能,那么我们测试时,就会输入搜索关键词,点击搜索按钮,然后再去查看搜索结果,看结果是否跟我们输入的搜索关键词匹配,如果匹配则说明搜索功能实现正确。

Google Search

那如何对该功能进行性能测试呢?

答案就是,N个人同时进行功能性操作的同时,在确保功能实现正确的前提下,考察服务端应用程序的各项性能指标,以及服务器硬件资源的使用情况。

当然,这个答案比较简单粗暴,但是它仍然包含了性能测试的基本特点:

  • 以功能实现正确为前提
  • 通常有一定的并发用户
  • 重点考察服务器端在一定并发压力下的性能指标

最后,再明确下性能测试的目的。通常,对服务器端应用程序开展性能测试,是为了验证软件系统是否能够达到预期的性能指标,同时发现软件系统中存在的性能瓶颈,从而实现优化系统的目的。

性能测试方法的核心

根据不同的测试目的,性能测试可以分为多种类型,常见的有如下几类:

  • 基准测试(Standard Testing)
  • 负载测试(Load Testing)
  • 压力测试(Stress Testing)
  • 疲劳强度测试

首先说下基准测试。基准测试指的是模拟单个用户执行业务场景时,考察系统的性能指标。严格意义上来讲,基准测试并不能算作性能测试范畴,它跟功能测试并没有太大区别。差异在于,基准测试的目的更多地是关注业务功能的正确性,或者说验证测试脚本的正确性,然后,将基准测试时采集得到的系统性能指标,作为基准测试结果,为后续并发压力测试的性能分析提供参考依据。

负载测试,主要指的是模拟系统在正常负载压力场景下,考察系统的性能指标。这里说的正常负载,主要是指用户对系统能承受的最大业务负载量的期望值,即预计系统最大应该支持多大用户的并发量。通过负载测试,目的是验证系统是否能满足预期的业务压力场景。

和负载测试的概念比较接近的是压力测试。通俗地讲,压力测试是为了发现在多大并发压力下系统的性能会变得不可接受,或者出现性能拐点(崩溃)的情况。在加压策略上,压力测试会对被测系统逐步加压,在加压的过程中考察系统性能指标的走势情况,最终找出系统在出现性能拐点时的并发用户数,也就是系统支持的最大并发用户数。

最后再说下疲劳强度测试。其实疲劳强度测试的加压策略跟负载测试也很接近,都是对系统模拟出系统能承受的最大业务负载量,差异在于,疲劳强度测试更关注系统在长时间运行情况下系统性能指标的变化情况,例如,系统在运行一段时间后,是否会出现事务处理失败、响应时间增长、业务吞吐量降低、CPU/内存资源增长等问题。

通过对比可以发现,不同的性能测试类型,其本质的差异还是在加压策略上,而采用何种加压策略,就取决于我们实际的测试目的,即期望通过性能测试发现什么问题。明白了这一点,性能测试类型的差异也就不再容易混淆了。

结论要点1:性能测试手段的重点在于加压的方式和策略。

性能瓶颈定位的核心

在前面频繁地提到了性能指标,那性能指标究竟有哪些,我们在性能测试的过程中需要重点关注哪些指标项呢?

从维度上划分,性能指标主要分为两大类,分别是业务性能指标和系统资源性能指标。

业务性能指标可以直观地反映被测系统的实际性能状况,常用的指标项有:

  • 并发用户数
  • 事务吞吐率(TPS/RPS)
  • 事务平均响应时间
  • 事务成功率

而系统资源性能指标,主要是反映整个系统环境的硬件资源使用情况,常用的指标包括:

  • 服务器:CPU利用率、处理器队列长度、内存利用率、内存交换页面数、磁盘IO状态、网卡带宽使用情况等;
  • 数据库:数据库连接数、数据库读写响应时长、数据库读写吞吐量等;
  • 网络:网络吞吐量、网络带宽、网络缓冲池大小;
  • 缓存(Redis):静态资源缓存命中率、动态数据缓存命中率、缓存吞吐量等;
  • 测试设备(压力发生器):CPU利用率、处理器队列长度、内存利用率、内存交换页面数、磁盘IO状态、网卡带宽使用情况等。

对于以上指标的具体含义我就不在此进行逐一说明了,大家可以自行搜索,务必需要搞清楚每个指标的概念及其意义。可能有些指标在不同的操作系统中的名称有些差异,但是基本都会有对应的指标,其代表的意义也是相通的。例如,处理器队列长度这个指标,在Windows中的指标名称是System\Processor Queue Length,而在Linux系统中则需要看load averages。

可能对于最后一项(测试设备)有些人不大理解,监控被测系统环境的相关硬件资源使用情况不就好了么,为什么还要关注测试设备本身呢?这是因为测试设备在模拟高并发请求的过程中,设备本身也会存在较高的资源消耗,例如CPU、内存、网卡带宽吃满,磁盘IO读写频繁,处理器排队严重等;当出现这类情况后,测试设备本身就会出现瓶颈,无法产生预期的并发压力,从而我们测试得到的数据也就不具有可参考性了。此处暂不进行展开,后面我会再结合实际案例,通过图表和数据对此详细进行说明。

需要说明的是,性能指标之间通常都是有密切关联的,单纯地看某个指标往往很难定位出性能瓶颈,这需要我们对各项性能指标的含义了然于胸,然后才能在实际测试的过程中对系统性能状况综合进行分析,找出整个系统真正的瓶颈。举个简单的例子,压力测试时发现服务器端CPU利用率非常高,那这个能说明什么问题呢?是服务端应用程序的算法问题,还是服务器硬件资源配置跟不上呢?光看这一个指标并不能定位出产生问题的真正原因,而如果仅因为这一点,就决定直接去优化程序算法或者升级服务器配置,最后也很难真正地解决问题。

结论要点2:性能瓶颈定位的重点在于性能指标的监控和分析。

引入性能测试工具

通过前面的讲解,我们已经知道性能测试的主要手段是通过产生模拟真实业务的压力对被测系统进行加压,与此同时监控被测系统的各项性能指标,研究被测系统在不同压力情况下的表现,找出其潜在的性能瓶颈。

那么,如何对系统进行加压,又如何对系统的指标进行监控呢?这里,就需要引入性能测试工具了。

当然,我们也可以先看下在不借助性能测试工具的情况下,如何手工地对系统进行性能测试。

假设现在我们要对前面提到的搜索功能进行负载测试,验证在20个并发用户下搜索功能的事务平均响应时间是否在3秒以内。

很自然地,我们可以想到测试的必要条件有如下几点:

  • 20个测试人员,产生业务压力
  • 1个指挥人员,对20个人员的协调控制,实现并发操作
  • 1个结果记录人员,对每一个人员的操作耗时进行监控和记录
  • 若干资源监控人员,实时查看被测系统的各项性能指标,对指标进行汇总、分析
  • 1个结果统计人员,对20个用户各操作消耗的时长进行汇总,计算其平均值

可以看出,要通过人工来进行性能测试,操作上极为繁琐,需要投入的资源非常多,而这还仅仅是一个非常简单的场景。设想,如果要测试10000并发,服务器有好几十台,显然,这种情况下是完全不可能通过投入人力就能解决的。这也就是性能测试工具存在的必要性和诞生的背景。

性能测试工具的基本组成

当前,市面上已经有了很多性能测试工具,但不管是哪一款,基本都会包含如下几个核心的模块。

  • 压力生成器(Virtual User Generator)
  • 结果采集器(Result Collector)
  • 负载控制器(Controller)
  • 系统资源监控器(Monitor)
  • 结果分析器(Analysis)

原理结构图如下所示:

Google Search

对照前面手工进行性能测试的案例,不难理解,压力发生器对应的是众多测试人员,结果采集器对应的是结果记录人员,负载控制器对应的是指挥人员,资源监控器对应的是若干资源监控人员,结果分析器对应的是结果统计人员。

其中,压力发生器又是性能测试工具最核心的部分,它主要有两个功能,一是真实模拟用户操作,二是模拟有效并发。

然而,大多数性能测试工作人员可能都会忽略的是,当前市面上性能测试工具的压力发生器基本都是存在缺陷的。

先说下模拟真实用户操作。如果熟悉浏览器的工作原理,就会知道浏览器在加载网页的时候,是同时并发多个TCP连接去请求页面对应的HTTP资源,包括HTML、JS、图片、CSS,当前流行的浏览器普遍会并发6-10个连接。然而,性能测试工具在模拟单个用户操作的时候,基本上都是单连接串行加载页面资源。产生的差异在于,假如页面有100个资源,每个HTTP请求的响应时间约为100毫秒,那么浏览器采用6个连接并行加载网页时大概会需要1.7秒(100/6*100毫秒),而测试工具采用单连接串行加载就需要10秒(100*100毫秒),两者结果相差十分巨大。这也解释了为什么有时候我们通过性能测试工具测试得到的响应时间挺长,但是手动用浏览器加载网页时感觉挺快的原因。

再说下有效并发。什么叫有效并发?有效并发就是我们在测试工具中设置了1000虚拟用户数,实际在服务器端就能产生1000并发压力。然而现实情况是,很多时候由于测试设备自身出现了性能瓶颈,压力发生器产生的并发压力远小于设定值,并且通常测试工具也不会将该问题暴露给测试人员;如果测试人员忽略了这个问题,以为测试得到的结果就是在设定并发压力下的结果,那么最终分析得出的结论也就跟实际情况大相径庭了。不过,我们可以通过保障测试环境不存在瓶颈,使得实际生成的并发压力尽可能地与设定值一致;另一方面,我们也可以通过在测试过程中监控Web层(例如Nginx)的连接数和请求数,查看实际达到服务器端的并发数是否跟我们的设定值一致,以此来反推压力发生器的压力是否有效。

了解这些缺陷的意义在于,我们可以更清楚测试工具的原理,从而更准确地理解测试结果的真实含义。

性能测试工具推荐

经过充分的理论铺垫,现在总算可以进入正题,开始讲解工具部分了。

在性能测试工具方面,我重点向大家推荐Locust这款开源工具。目前阶段,该款工具在国内的知名度还很低,大多数测试人员可能之前都没有接触过。为了便于理解,我先将Locust与LoadRunner、Jmeter这类大众耳熟能详的性能测试工具进行简单对比。

\ LoadRunner Jmeter Locust
授权方式 商业收费 开源免费 开源免费
开发语言 C/Java Java Python
测试脚本形式 C/Java GUI Python
并发机制 进程/线程 线程 协程
单机并发能力 低 低 高
分布式压力 支持 支持 支持
资源监控 支持 不支持 不支持
报告与分析 完善 简单图表 简单图表
支持二次开发 不支持 支持 支持

通过对比,大家可能会疑惑,Locust也不怎么样嘛,资源监控也不支持,报告分析能力也这么弱,那为啥还要选择它呢?

授权方式这个就不说了。虽然LoadRunner是商业软件,价格极其昂贵,但是国内盗版横行,别说个人,就算是大型互联网公司,用正版的也没几个。

从功能特性的角度来讲,LoadRunner是最全面的,用户群体也是最多的,相应的学习资料也最为丰富。个人建议如果是新接触性能测试,可以先熟悉LoadRunner,借此了解性能测试工具各个模块的概念和功能,在此基础上再转到别的测试工具,也都比较好上手了。不过,LoadRunner只能在Windows平台使用,并且并发效率比较低,单台压力机难以产生较高的并发能力,这也是现在我弃用该款工具的主要原因。

同样地,Jmeter的并发机制也是基于线程,并发效率存在同样的问题;另外,Jmeter在脚本编写和描述方面是基于GUI操作,个人感觉操作比较繁琐(这个因人而异),因此不是很喜欢。

那么,我重点推荐的Locust有啥特别的地方呢?

如果从整体功能上来看的话,Locust的功能的确比较单薄。不过,作为性能测试工具最核心的压力发生器部分,却是非常不错的。抛开官方文档的介绍,个人觉得最赞的有两点。

首先是模拟用户操作,也就是测试脚本描述方面。Locust采用Pure Python脚本描述,并且HTTP请求完全基于Requests库。用过Requests的都知道,这个库非常简洁易用,但功能十分强大,很多其它编程语言的HTTP库都借鉴了它的思想和模式,如果将其评选为最好用的HTTP库之一(不限语言),应该也不会有太大的争议。除了HTTP(S)协议,Locust也可以测试其它任意协议的系统,只需要采用Python调用对应的库进行请求描述即可。

另外一点就是并发机制了。Locust的并发机制摒弃了进程和线程,采用协程(gevent)的机制。采用多线程来模拟多用户时,线程数会随着并发数的增加而增加,而线程之间的切换是需要占用资源的,IO的阻塞和线程的sleep会不可避免的导致并发效率下降;正因如此,LoadRunner和Jmeter这类采用进程和线程的测试工具,都很难在单机上模拟出较高的并发压力。而协程和线程的区别在于,协程避免了系统级资源调度,由此大幅提高了性能。正常情况下,单台普通配置的测试机可以生产数千并发压力,这是LoadRunner和Jmeter都无法实现的。

有了一个不错的引擎,外表装饰简陋点也都是可以接受的了。不过虽然Locust功能单薄,特别是在性能指标监控和测试报告图表方面比较缺失,但是Locust的代码结构清晰,核心代码量也只有几百行,可扩展性也非常不错。换言之,Locust的可玩性(hackable)极强,对于一个想深入挖掘性能测试工具原理的人来说,Locust非常适合。

好了,Locust的介绍暂且到这儿,后续我会再对Locust的使用方法和二次开发进行详细介绍,也算是弥补官方文档的不足吧。

打造心目中理想的自动化测试框架(AppiumBooster)

发表于 2016-09-07 | 更新于 2019-04-03 | 分类于 Testing , 自动化测试

前言

做过自动化测试的人应该都会有这样一种体会,要写个自动化demo测试用例很容易,但是要真正将自动化测试落地,对成百上千的自动化测试用例实现较好的可复用性和可维护性就很难了。

基于这一痛点,我开发了AppiumBooster框架。顾名思义,AppiumBooster基于Appium实现,但更简单和易于使用;测试人员不用接触任何代码,就可以直接采用简洁优雅的方式来编写和维护自动化测试用例。

原型开发完毕后,我将其应用在当前所在团队的项目上,并在使用的过程中,按照自己心目中理想的自动化测试框架的模样对其进行迭代优化,最终打磨成了一个自己还算用得顺手的自动化测试框架。

本文便是对AppiumBooster的核心特性及其设计思想进行介绍。在内容组织上,本文的各个部分相对独立,大家可直接选择自己感兴趣的部分进行阅读。

UI交互基础

UI交互是自动化测试的基础,主要分为三部分内容:定位控件、操作控件、检测结果。

控件定位

定位控件时,统一采用元素ID进行定位。这里的ID包括accessibility_id或accessibility_label,需要在iOS工程项目中预先进行设置。

另外,考虑到控件可能出现延迟加载的情况,定位控件时统一执行wait操作;定位成功后会立即返回控件对象,定位失败时会进行等待并不断尝试定位,直到超时(30秒)后抛出异常。

1
wait { id control_id }

源码路径:AppiumBooster/lib/pages/control.rb

控件操作

根据实践证明,UI的控件操作基本主要就是点击、输入和滑动,这三个操作基本上可以覆盖绝大多数场景。

  • scrollToDisplay: 根据指定控件的坐标位置,对屏幕进行上/下/左/右滑动操作,直至将指定控件展示在屏幕中
  • click: 通过控件ID定位到指定控件,并对指定控件进行click操作;若指定控件不在当前屏幕中,则先执行scrollToDisplay,再执行click操作
  • type(text): 在指定控件中输入字符串;若指定控件不在当前屏幕中,则先执行scrollToDisplay,再执行输入操作
  • tapByCoordinate: 先执行scrollToDisplay,确保指定控件在当前屏幕中;获取指定控件的坐标值,然后对坐标进行tap操作
  • scroll(direction): 对屏幕进行指定方向的滑动

源码路径:AppiumBooster/lib/pages/actions.rb

预期结果检查

每次执行一步操作后,需要对执行结果进行判断,以此来确定测试用例的各个步骤是否执行成功。

当前,AppiumBooster采用控件的ID作为检查对象,并统一封装到check_elements(control_ids)方法中。

在实际使用过程中,需要先确定当前步骤执行完成后的跳转页面的特征控件,即当前步骤执行前不存在该控件,但执行成功后的页面中具有该控件。然后在操作步骤描述的expectation属性中指定特征控件的ID。

具体地,在指定控件ID的时候还可以配合使用操作符(!,||,&&),以此实现多种复杂场景的检测。典型的预期结果描述形式如下:

  • A: 预期控件A存在;
  • !A: 预期控件A不存在;
  • A||B: 预期控件A或控件B至少存在一个;
  • A&&B: 预期控件A和控件B同时存在;
  • A&&!B: 预期控件A存在,但控件B不存在;
  • !A&&!B: 预期控件A和控件B都不存在。

源码路径:AppiumBooster/lib/pages/inner_screen.rb

测试用例引擎(YAML)

对于自动化测试而言,自动化测试用例的组织与管理是最为重要的部分,直接关系到自动化测试用例的可复用性和可维护性。

经过综合考虑,AppiumBooster从三个层面来描述测试用例,从低到高分别是step、feature和testcase;描述方式推荐使用YAML格式。

steps(测试步骤描述)

首先是对于单一操作步骤的描述。

从UI层面来看,每一个操作步骤都可以归纳为三个方面:定位控件、操作控件和检查结果。

AppiumBooster的做法是,将App根据功能模块进行拆分,每一个模块单独创建一个YAML文件,并保存在steps目录下。然后,在每个模块中以控件为单位,分别进行定义。

现以如下示例进行详细说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
---
AccountSteps:
enter Login page:
control_id: tablecellMyAccountLogin
control_action: click
expectation: btnForgetPassword

input test EmailAddress:
control_id: txtfieldEmailAddress
control_action: type
data: leo.lee@debugtalk.com
expectation: sectxtfieldPassword

check if coupon popup window exists(optional):
control_id: inner_screen
control_action: has_control
data: btnViewMyCoupons
expectation: btnClose
optional: true

其中,AccountSteps是steps模块名称,用于区分不同的steps模块,方便在features模块中进行引用。

描述单个步骤时,有三项是必不可少的:步骤名称、控件ID(control_id)和控件操作方式(control_action)。当控件操作方式为输入(type)时,则还需指定data属性,即输入内容。

在检查步骤执行结果方面,可通过在expectation属性中指定控件ID进行实现,前面在预期结果检查一节中已经详细介绍了使用方法。该属性可以置空或不进行填写,相当于不对当前步骤进行检测。

另外还有一个optional属性,对步骤指定该属性并设置为true时,当前步骤的执行结果不影响整个测试用例。

features(功能点描述)

各个模块的单一操作步骤定义完毕后,虽然可以直接将多个步骤进行组合形成对测试场景的描述,即测试用例,但是操作起来会过于局限细节;特别是当测试用例较多时,可维护性是一个很大的问题。

AppiumBooster的做法是,将App根据功能模块进行拆分,每一个模块单独创建一个YAML文件,并保存在features目录下。然后,在每个模块中以功能点为单位,通过对steps模块中定义好的操作步骤进行引用并组合,即可实现对功能点的描述。

以系统登录功能为例,功能点的描述可采用如下形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
---
AccountFeatures:
login with valid test account:
- AccountSteps | enter My Account page
- AccountSteps | enter Login page
- AccountSteps | input test EmailAddress
- AccountSteps | input test Password
- AccountSteps | login
- AccountSteps | close coupon popup window(optional)

login with valid production account:
- AccountSteps | enter My Account page
- AccountSteps | enter Login page
- AccountSteps | input production EmailAddress
- AccountSteps | input production Password
- AccountSteps | login
- AccountSteps | close coupon popup window(optional)

logout:
- AccountSteps | enter My Account page
- SettingsSteps | enter Settings page
- AccountSteps | logout

其中,AccountFeatures是features模块名称,用于区分不同的features模块,方便在testcase中进行引用。

在引用steps模块的操作步骤时,需要同时指定steps模块名称和操作步骤的名称,并以|进行分隔。

testcases(测试用例描述)

在功能点描述的基础上,AppiumBooster就可以在第三个层面,简单清晰地描述测试用例了。

具体做法很简单,针对每个测试用例创建一个YAML文件,并保存在testcases目录下。然后,通过对features模块中定义好的功能点描述进行引用并组合,即可实现对测试用例的描述。

同样的,在引用features模块的功能点时,也需要同时指定features模块名称和功能点的名称,并以|进行分隔。

如下示例便是实现了在商城中购买商品的整个流程,包括切换国家、登录、选择商品、添加购物车、下单完成支付等功能点。

1
2
3
4
5
6
7
8
9
---
Buy Phantom 4:
- SettingsFeatures | initialize first startup
- SettingsFeatures | Change Country to China
- AccountFeatures | login with valid account
- AccountFeatures | Change Shipping Address to China
- StoreFeatures | add phantom 4 to cart
- StoreFeatures | finish order
- AccountFeatures | logout

另外,在某些测试场景中可能需要重复进行某一个功能点的操作。虽然可以将需要重复的步骤多写几次,但会显得比较累赘,特别是重复次数较多时更是麻烦。

AppiumBooster的做法是,在测试用例的步骤中可指定执行次数,并以|进行分隔,如下例所示。

1
2
3
4
5
6
---
Send random text messages:
- SettingsFeatures | initialize first startup
- AccountFeatures | login with valid test account
- MessageFeatures | enter follower user message page
- MessageFeatures | send random text message | 100

测试用例引擎(CSV)

基本上,YAML测试用例引擎已经可以很好地满足组织和管理自动化测试用例的需求。

但考虑到部分用户会偏向于使用表格的形式,因为表格看上去更直观一些,AppiumBooster同时还支持CSV格式的测试用例引擎。

testcases(测试用例描述)

采用表格来编写测试用例时,只需要在任意表格工具,包括Microsoft Excel、iWork Numbers、WPS等,按照如下形式对测试用例进行描述。

AppiumBooster CSV Testcase example

然后,将表格内容另存为CSV格式的文件,并放置于testcases目录中即可。

可以看出,CSV格式的测试用例和YAML格式的测试用例是等价的,两者包含的信息内容完全相同。

在具体实现上,AppiumBooster在执行测试用例之前,也会将两个测试用例引擎的测试用例描述转换为相同的数据结构,然后再进行统一的操作。

统一转换后的数据结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"testcase_name": "Login and Logout",
"features_suite": [
{
"feature_name": "login with valid account",
"feature_steps": [
{"control_id": "btnMenuMyAccount", "control_action": "click", "expectation": "tablecellMyAccountSystemSettings", "step_desc": "enter My Account page"},
{"control_id": "tablecellMyAccountLogin", "control_action": "click", "expectation": "btnForgetPassword", "step_desc": "enter Login page"},
{"control_id": "txtfieldEmailAddress", "control_action": "type", "data": "leo.lee@debugtalk.com", "expectation": "sectxtfieldPassword", "step_desc": "input EmailAddress"},
{"control_id": "sectxtfieldPassword", "control_action": "type", "data": 12345678, "expectation": "btnLogin", "step_desc": "input Password"},
{"control_id": "btnLogin", "control_action": "click", "expectation": "tablecellMyMessage", "step_desc": "login"},
{"control_id": "btnClose", "control_action": "click", "expectation": nil, "optional": true, "step_desc": "close coupon popup window(optional)"}
]
},
{
"feature_name": "logout",
"feature_steps": [
{"control_id": "btnMenuMyAccount", "control_action": "click", "expectation": "tablecellMyAccountSystemSettings", "step_desc": "enter My Account page"},
{"control_id": "tablecellMyAccountSystemSettings", "control_action": "click", "expectation": "txtCountryDistrict", "step_desc": "enter Settings page"},
{"control_id": "btnLogout", "control_action": "click", "expectation": "uiviewMyAccount", "step_desc": "logout"}
]
}
]
}

测试用例转换器(yaml2csv)

既然CSV格式的测试用例和YAML格式的测试用例是等价的,那么两者之间的转换也就容易实现了。

当前,AppiumBooster支持将YAML格式的测试用例转换为CSV格式的测试用例,只需要执行一条命令即可。

1
$ ruby start.rb -c "yaml2csv" -f ios/testcases/login_and_logout.yml

过程记录及结果存储

在自动化测试执行过程中,应尽量对测试用例执行过程进行记录,方便后续对问题根据定位和追溯。

过程记录方式

当前,AppiumBooster已实现的记录形式有如下三种:

  • logger模块:可指定日志级别对测试过程进行记录
  • 截图功能:测试用例运行过程中,在每个步骤执行完成后进行截图
  • DOM source:测试用例运行过程中,在每个步骤执行完成后保存当前页面的DOM内容

测试结果存储

由于Appium分为Server端和Client端,因此AppiumBooster在记录日志的时候也将日志分为了三份:

  • appium_server.log: Appium Server端的日志,这部分日志是由Appium框架打印的
  • appium_booster.log: 包括测试环境初始化和测试用例执行记录,这部分日志是由AppiumBooster中采用logger模块打印的
  • client_server.log: 同时记录AppiumBooster和Appium框架的日志,相当于appium_server.log和appium_booster.log的并集,优点在于可以清晰地看到测试用例执行过程中Client端和Server端的通讯交互过程

另外,当测试用例执行失败时,AppiumBooster会将执行失败的步骤截图和日志提取出来,单独保存到errors文件夹中,方便问题追溯。

具体地,每次执行测试前,AppiumBooster会在指定的results目录下创建一个以当前时间(%Y-%m-%d_%H:%M:%S)命名的文件夹,存储结构如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2016-08-28_16:28:48
├── appium_server.log
├── appium_booster.log
├── client_server.log
├── errors
│ ├── 16_31_29_btnLogin.click.dom
│ ├── 16_31_29_btnLogin.click.png
│ ├── 16_32_03_btnMenuMyAccount.click.dom
│ └── 16_32_03_btnMenuMyAccount.click.png
├── screenshots
│ ├── 16_30_34_tablecellMyAccountLogin.click.png
│ ├── 16_30_41_txtfieldEmailAddress.type_leo.lee@debugtalk.com.png
│ ├── 16_30_48_sectxtfieldPassword.type_123456.png
│ ├── 16_31_29_btnLogin.click.png
│ └── 16_32_03_btnMenuMyAccount.click.png
└── xmls
├── 16_30_34_tablecellMyAccountLogin.click.dom
├── 16_30_41_txtfieldEmailAddress.type_leo.lee@debugtalk.com.dom
├── 16_30_48_sectxtfieldPassword.type_123456.dom
├── 16_31_29_btnLogin.click.dom
└── 16_32_03_btnMenuMyAccount.click.dom

对于每一个测试步骤的截图和DOM,存储文件命名格式为%H_%M_%S_ControlID.ControlAction。采用这种命名方式有两个好处:

  • 文件通过时间排序,对应着测试用例执行的步骤顺序
  • 可以在截图或DOM中直观地看到每一步操作指令对应的执行结果

环境初始化

Appium Server

在执行自动化测试时,某些情况下可能会造成Appium Server出现异常情况(e.g. 500 error),并影响到下一次测试的执行。

为了避免这类情况,AppiumBooster在每次执行测试前,会强制性地对Appium Server进行重启。方式也比较简单暴力,运行测试之前先检查系统是否有bin/appium的进程在运行,如果有,则先kill掉该进程,然后再启动Appium Server。

需要说明的是,由于Appium Server的启动需要一定时间,为了防止运行Appium Client时Appium Server还未初始化完毕,因此启动Appium Server后最好能等待一段时间(e.g. sleep 10s)。

iOS/Android模拟器

在模拟器中运行一段时间后,也会存在缓存数据和文件,可能会对下一次测试造成影响。

为了避免这类情况,AppiumBooster在每次执行测试前,会先删除已存在的模拟器,然后再用指定的模拟器配置创建新的模拟器。

对于iOS模拟器,AppiumBooster通过调用xcrun simctl命令的方式来对模拟器进行操作,基本原理如下所示。

1
2
3
4
# delete iOS simulator: xcrun simctl delete device_id
$ xcrun simctl delete F2F53866-50A5-4E0F-B164-5AC1702AD1BD
# create iOS simulator: xcrun simctl create device_type device_type_id runtime_id
$ xcrun simctl create 'iPhone 5' 'com.apple.CoreSimulator.SimDeviceType.iPhone-5' 'com.apple.CoreSimulator.SimRuntime.iOS-9-3'

其中,device_id/device_type_id/runtime_id这些属性值可以通过执行xcrun simctl list命令获取得到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ xcrun simctl list
== Device Types ==
iPhone 5s (com.apple.CoreSimulator.SimDeviceType.iPhone-5s)
iPhone 6 (com.apple.CoreSimulator.SimDeviceType.iPhone-6)
== Runtimes ==
iOS 8.4 (8.4 - 12H141) (com.apple.CoreSimulator.SimRuntime.iOS-8-4)
iOS 9.3 (9.3 - 13E230) (com.apple.CoreSimulator.SimRuntime.iOS-9-3)
== Devices ==
-- iOS 8.4 --
iPhone 5s (E1BD9CC5-8E95-408F-849C-B0C6A44D669A) (Shutdown)
-- iOS 9.3 --
iPhone 5s (BAFEFBE1-3ADB-45C4-9C4E-E3791D260524) (Shutdown)
iPhone 6 (F23B3F85-7B65-4999-9F1C-80111783F5A5) (Shutdown)
== Device Pairs ==

增强特性

除了以上基础特性,AppiumBooster还支持一些辅助特性,可以增强测试框架的使用体验。

Data参数化

在某些场景下,测试用例执行时需要动态获取数值。例如,注册账号的测试用例中,每次执行测试用例时需要保证用户名为未注册的,常见的做法就是在注册用户名中包含时间戳。

AppiumBooster的做法是,可以在测试步骤的data字段中,传入Ruby表达式,格式为${ruby_expression}。在执行测试用例时,会先对ruby_expression进行eval计算,然后用计算得到的值作为实际参数。

回到刚才的注册账号测试用例,填写用户名的步骤就可以按照如下形式指定参数。

1
2
3
4
5
input test EmailAddress:
control_id: txtfieldEmailAddress
control_action: type
data: ${Time.now.to_i}@debugtalk.com
expectation: sectxtfieldPassword

实际执行测试用例时,data就会参数化为`1471318368@debugtalk.com`的形式。

全局参数配置

对于某些配置参数,例如系统的登录账号密码等,虽然可以直接填写到测试用例的steps中,但是终究不够灵活。特别是当存在多个测试用例引用同一个参数时,涉及到参数改动时就需要同时修改多个地方。

更好的做法是,将此类参数提取出来,在统一的地方进行配置。在AppiumBooster中,可以在config.yml文件中配置全局参数。

1
2
3
4
5
6
7
8
---
TestEnvAccount:
UserName: test@debugtalk.com
Password: 123456

ProductionEnvAccount:
UserName: production@debugtalk.com
Password: 12345678

然后,在测试用例的steps就可以采用如下形式对全局参数进行引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
---
AccountSteps:
input test EmailAddress:
control_id: txtfieldEmailAddress
control_action: type
data: ${config.TestEnvAccount.UserName}
expectation: sectxtfieldPassword

input test Password:
control_id: sectxtfieldPassword
control_action: type
data: ${config.TestEnvAccount.Password}
expectation: btnLogin

optional选项

在执行测试用例时,有时候可能会存在这样的场景:某个步骤作为非必要步骤,当其执行失败时,我们并不想将测试用例判定为不通过。

基于该场景,在测试用例设计表格中增加了optional参数。该参数值默认不用填写。但如果在某个步骤对应的optional栏填写了true值后,那么该步骤就会作为非必要步骤,其执行结果不会影响整个用例的执行结果。

例如,在电商类APP中,某些账号有优惠券,登录系统后,会弹出优惠券的提示框;而有的账号没有优惠券,登录后就不会有这样的弹框。那么关闭优惠券弹框的步骤就可以将其optional参数设置为true。

1
2
3
4
5
6
7
---
AccountSteps:
close coupon popup window(optional):
control_id: btnClose
control_action: click
expectation: !btnViewMyCoupons
optional: true

命令行工具

AppiumBooster通过在命令行中进行调用。

1
2
3
4
5
6
7
8
$ ruby start.rb -h
Usage: start.rb [options]
-p, --app_path <value> Specify app path
-t, --app_type <value> Specify app type, ios or android
-f, --testcase_file <value> Specify testcase file(s)
-d, --output_folder <value> Specify output folder
-c, --convert_type <value> Specify testcase converter, yaml2csv or csv2yaml
--disable_output_color Disable output color

执行测试用例

指定执行测试用例时支持多种方式,常见的几种使用方式示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
$ cd ${AppiumBooster}
# 执行指定的测试用例文件(绝对路径)
$ ruby run.rb -p "ios/app/test.zip" -f "/Users/Leo/MyProjects/AppiumBooster/ios/testcases/login.yml"

# 执行指定的测试用例文件(相对路径)
$ ruby run.rb -p "ios/app/test.zip" -f "ios/testcases/login.yml"

# 执行所有yaml格式的测试用例文件
$ ruby run.rb -p "ios/app/test.zip" -f "ios/testcases/*.yml"

# 执行ios目录下所有csv格式的测试用例文件
$ ruby run.rb -p "ios/app/test.zip" -t "ios" -f "*.csv"

测试用例转换

将YAML格式的测试用例转换为CSV格式的测试用例:

1
$ ruby start.rb -c "yaml2csv" -f ios/testcases/login_and_logout.yml

总结

什么才算是心目中理想的自动化测试框架?我也没有确切的答案。

为什么要登山?
因为山在那里。

Jenkins 的输出日志也可以变得色色的

发表于 2016-08-15 | 更新于 2019-04-03 | 分类于 Development

在《使用Jenkins实现持续集成构建检查》一文中,写到了这么一段话:

在这里,我们还可以通过–disable_output_color开关将输出日志的颜色关闭。之所以实现这么一个功能,是因为在Jenkins中本来也无法显示颜色,但是如果还将Terminal中有颜色的日志内容输出到Jenkins中,就会出现一些额外的字符,比较影响日志的美观。

非常感谢热心的读者,及时地为我纠正了这一点。事实上,当前在Jenkins中,是可以通过安装插件来实现在输出日志中显示颜色的。

这个插件就是AnsiColor。

安装 && 配置

安装的方式很简单,【Manage Jenkins】->【Manage Plugins】,搜索AnsiColor进行安装即可。

安装完成后,在Jenkins Project的Configure页面中,Build Environment栏目下会多出Color ANSI Console Output配置项,勾选后即可开启颜色输出配置。

Jenkins Color ANSI Console Output

在ANSI color map的列表选择框中,存在多个选项,默认情况下,选择xterm即可。

保存配置后,再次执行构建时,就可以在Console中看到颜色输出了。

效果图

使用xctool命令编译iOS应用时,在Jenkins的Console output中会看到和Terminal中一样的颜色效果。

Jenkins Console Output Colored

补充说明

需要说明的是,在输出日志中显示颜色,依赖于输出的日志本身。也就是说,如果输出日志时并没有ANSI escape sequences,那么安装该插件后也没有任何作用,并不会凭空给日志加上颜色。

例如,如果采用xcodebuild命令编译iOS应用,那么输出日志就不会显示颜色。

说到这里,再简单介绍下ANSI escape sequences。

ANSI escape sequences

ANSI escape sequences,也叫ANSI escape codes,主要是用于对Terminal中的文本字符进行颜色的控制,包括字符背景颜色和字符颜色。

使用方式如下:

33[字符背景颜色;字符颜色m{String}33[0m

其中,33[字符背景颜色;字符颜色m是开始标识,33[0m是结束标识,{String}是原始文本内容。通过这种形式,就可以对输出的文本颜色进行控制。

具体地,字符颜色和字符背景颜色的编码如下:

字符颜色(foreground color):30~37

  • 30:黑
  • 31:红
  • 32:绿
  • 33:黄
  • 34:蓝色
  • 35:紫色
  • 36:深绿
  • 37:白色

字符背景颜色(background color):40~47

  • 40:黑
  • 41:深红
  • 42:绿
  • 43:黄色
  • 44:蓝色
  • 45:紫色
  • 46:深绿
  • 47:白色

需要说明的是,字符背景颜色和字符颜色并非必须同时设置,也可以只设置一项。

代码示例

掌握了以上概念后,我们就可以通过对打印日志的代码进行一点调整,然后就可以让输出的日志更加美观了。

以Ruby为例,在Sting基础类中添加一些展示颜色的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class String
# colorization
def colorize(color_code)
"\e[#{color_code}m#{self}\e[0m"
end

def red
colorize(31)
end

def green
colorize(32)
end

def yellow
colorize(33)
end
end

然后,我们在打印日志时就可以通过如下方式来控制日志的颜色了。

1
2
3
4
5
6
7
# 步骤执行正常,输出为绿色
step_action_desc += " ... ✓"
puts step_action_desc.green

# 步骤执行异常,输出为红色
step_action_desc += " ... ✖"
puts step_action_desc.red

展示效果如下图所示。

Terminal Output Colored

是不是好看多了?

1…345…8
solomiss

solomiss

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