理想国

我要看尽这世间繁华


  • 首页

  • 归档

  • 标签

  • 分类

  • 关于

  • 搜索

在大疆做测试开发是一种什么体验?

发表于 2018-03-06 | 更新于 2019-04-03 | 分类于 招聘

提到测试开发这个岗位,可能有的人会有些陌生,都听说过软件测试和软件开发,但测试开发又是干什么的呢?

说到这里,我又想起之前在阿里移动事业群的一件趣事儿。当时也是做测试开发岗位,我所在的组在公司内部有一个响当当的名号,“牲畜组”,生产力促进组嘛。有的同事更直接,亲切地叫我们组为”畜牲组“,促进生产力组的简称。

可以看到,不管是”牲畜“还是”畜牲“,测试开发这个岗位的核心都是提升团队生产力,也就是工作效率。更具体地,在大多数公司内部,测试开发岗位的职责就是提升业务测试人员的测试效率。

接触过软件测试的同学都知道,在项目版本迭代的过程中,业务测试人员需要进行大量的回归测试,重复工作量较大,在短暂的时间内也很难保证较大的测试覆盖率。这就需要测试开发人员来辅助开发相应的工具和平台,常见的包括实现接口测试自动化、UI自动化、性能专项、持续集成、线上监控等,将测试人员从重复性的工作解放出来,从而能有更多的时间精力投入到探索性测试当中去。

从这个层面上来讲,当前各个互联网公司的做法都差不多,只是不同公司可能会因为业务类型差异而有所侧重而已。

但回归生产力的本质,我们会发现,测试效率只能算是团队生产力的一部分。这就好比一个木桶,测试效率只是其中的一块木板,而研发效率、运维效率、项目管理等木板,同样制约着项目团队的整体工作效率。

可能有人会说,这些都不是测试开发的工作范畴啊!

事实上,很多公司的测试开发的确是没法触及到测试以外的工作内容的。甚至很多时候,整个测试团队都是直接向研发团队进行工作汇报,即使在提升测试效率的本职工作中话语权也是少之又少,更别谈测试以外的了。

那么,在大疆互联网事业部做测试开发又有什么不同呢?

得益于大疆“激极尽致,求真品诚”的企业文化,岗位并没有那么严格的界限划分。如果你发现一件事确实有价值,但当前却没有人去做,那么即使这不在你的工作范畴之内,你也可以主动站出来挑起这块儿的担子。

对于大疆的测试开发岗位同样如此,岗位性质决定了我们会比其它任何岗位都更关注团队的生产力和工作效率,那么不管是研发、运维、测试、运营还是项目管理,假如其中任何一个环节存在效率低下的问题,我们都可以申请立项,通过开发工具或平台来解决团队的痛点。当然,可能某些事情并不是测试开发岗位的同学就能独自完成的,那也没有关系,只要提出的问题确实具有业务价值,那么我们也可以申请到其它岗位的同学来协作一起完成。可能对于某些公司来说不可想象,但这在大疆确实就是切实可行的。

另一方面,大疆发展极其迅速,触及的领域也越来越广。这对我们测试开发来说,既是机遇,也是挑战。在这种环境下,我们有机会充分发挥主观能动性,去挖掘并解决团队中实际存在的痛点。分析需求、设计方案、技术选型、编码实现、收集反馈、迭代优化,这么一个流程下来,产出的工具或平台就如同自己精心打造的产品,成就感和按部就班地搬砖是完全不一样的。也许,我们测试开发岗位才是最接近”全栈“的工程师?

除此之外,在大疆的测试开发工程师还兼具着更多的职责。例如,整个互联网事业部的所有系统,遇到新品发布、技术升级改造、系统架构重构等重大事件时,压力测试都是由我们测试开发组来主导完成的。在质量部内部,我们测试开发组还会兼顾对业务测试同学进行测试技术和编程语言的培训,以及协助业务测试同学解决一些技术难题等。在这个过程中,我们测试开发本身的技术视野和解决问题的能力也得到了极大的增强。

最后,如果你对我们的岗位感兴趣,欢迎加入我们!

广告时间

近期,大疆创新开启新的一轮招聘啦,深圳总部、北京研发中心、上海分公司均有大量岗位招聘需求。

详细的招聘需求和日程安排请见:【内推】大疆创新春季招聘开启啦(深圳+北京)

如需找我内推,请发送简历至我的个人邮箱: **mail@debugtalk.com**

HttpRunner 实现参数化数据驱动机制

发表于 2018-02-16 | 更新于 2019-04-03 | 分类于 Development , 测试框架

从 1.1.0 版本开始,数据驱动机制进行了较大的优化和调整。
请参考:《HttpRunner 再议参数化数据驱动机制》

背景

在自动化测试中,经常会遇到如下场景:

1、测试搜索功能,只有一个搜索输入框,但有10种不同类型的搜索关键字;
2、测试账号登录功能,需要输入用户名和密码,按照等价类划分后有20种组合情况。

这里只是随意找了两个典型的例子,相信大家都有遇到过很多类似的场景。总结下来,就是在我们的自动化测试脚本中存在参数,并且我们需要采用不同的参数去运行。

经过概括,参数基本上分为两种类型:

  • 单个独立参数:例如前面的第一种场景,我们只需要变换搜索关键字这一个参数
  • 多个具有关联性的参数:例如前面的第二种场景,我们需要变换用户名和密码两个参数,并且这两个参数需要关联组合

然后,对于参数而言,我们可能具有一个参数列表,在脚本运行时需要按照不同的规则去取值,例如顺序取值、随机取值、循环取值等等。

对于这一块儿,没有太多新的概念,这就是典型的参数化和数据驱动。遗憾的是,当前HttpRunner并未支持该功能特性。

考虑到该需求的普遍性,并且近期提到该需求的的人也越来越多(issue #74, issue #87, issue #88, issue #97),因此趁着春节假期的空闲时间,决定优先实现下。

经过前面的场景分析,我们的目标已经很明确了,接下来就是如何实现的问题了。

借鉴 LoadRunner 的数据参数化

要造一个轮子,最好是先看下现有知名轮子的实现机制。之前有用过一段时间的 LoadRunner,对其参数化机制印象蛮深的,虽然它是性能测试工具,但在脚本参数化方面是通用的。

我们先看下在 LoadRunner 中是如何实现参数化的。

在 LoadRunner 中,可以在脚本中创建一个参数,然后参数会保存到一个.dat的文件中,例如下图中的psd.dat。

在.dat文件中,是采用表格的形式来存储参数值,结构与CSV基本一致。

对于单个独立参数,可以将参数列表保存在一个单独的.dat文件中,第一行为参数名称,后续每一行为一个参数值。例如本文背景介绍中的第一类场景,数据存储形式如下所示:

1
2
3
4
Keyword
hello
world
debugtalk

然后对于参数的取值方式,可以通过Select next row和Update value on进行配置。

Select next row的可选方式有:

  • Sequential:顺序取值
  • Random:随机取值
  • Unique:为每个虚拟用户分配一条唯一的数据

Update value on的可选方式有:

  • Each iteration:每次脚本迭代时更新参数值
  • Each occurrence:每次出现参数引用时更新参数值
  • Once:每条数据只能使用一次

而且,可以通过对这两种方式进行组合,配制出9种参数化方式。

另外,因为 LoadRunner 本身是性能测试工具,具有长时间运行的需求,假如Select next row选择为Unique,同时Update value on设置为Each iteration,那么就会涉及到参数用完的情况。在该种情况下,可通过When out of value配置项实现如下选择:

  • Abort vuser:当超出时终止脚本
  • Continue in a cyclic manner:当超出时回到列表头再次取值
  • Continue with last value:使用参数表中的最后一个值

对于多个具有关联性的参数,可以将关联参数列表保存在一个.dat文件中,第一行为参数名称,后续每一行为一个参数值,参数之间采用逗号进行分隔。例如本文背景介绍中的第二类场景,数据存储形式如下所示:

1
2
3
4
UserName,Password
test1,111111
test2,222222
test3,333333

对于参数的取值方式,与上面单个独立参数的取值方式基本相同。差异在于,我们可以只配置一个参数(例如UserName)的取值方式,然后其它参数(例如Password)的取值方式选择为same line as UserName。如此一来,我们就可以保证参数化时的数据关联性。

LoadRunner 的参数化机制就回顾到这里,可以看出,其功能还是很强大的,使用也十分灵活。

设计思路演变历程

现在再回到我们的 HttpRunner,要如何来实现参数化机制呢?

因为 LoadRunner 的参数化机制比较完善,用户群体也很大,因此我在脑海里最先冒出的想法就是照抄 LoadRunner,将 LoadRunner 在 GUI 中配置的内容在 HttpRunner 中通过YAML/JSON来进行配置。

按照这个思路,在 HttpRunner 的 config 中,就要有一块儿地方用来进行参数化配置,暂且设定为parameters吧。然后,对于每一个参数,其参数列表要单独存放在文件中,考虑到LoadRunner中的.dat文件基本就是CSV格式,因此可以约定采用大众更熟悉的.csv文件来存储参数;在脚本中,要指定参数变量从哪个文件中取值,那么就需要设定一个parameter_file,用于指定对应的参数文件路径。接下来,要实现取值规则的配置,例如是顺序取值还是随机取值,那么就需要设定select_next_row和update_value_on。

根据该设想,在YAML测试用例文件中,数据参数化将描述为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- config:
name: "demo for data driven."
parameters:
- Keyword:
parameter_file: keywords.csv
select_next_row: Random
update_value_on: EachIteration
- UserName:
parameter_file: account.csv
select_next_row: Sequential
update_value_on: EachIteration
- Password:
parameter_file: account.csv
select_next_row: same line as UserName

这个想法基本可行,但就是感觉配置项有些繁琐,我们可以尝试再对其进行简化。

首先,比较明显的,针对每个参数都要配置select_next_row和update_value_on,虽然从功能上来说比较丰富,但是对于用户来说,这些功能并不都是必须的。特别是update_value_on这个参数,绝大多数情况下我们的需求应该都是采用Each iteration,即每次脚本再次运行时更新参数值。因此,我们可以去除update_value_on这个配置项,默认都是采用Each iteration的方式。

经过第一轮简化,配置描述方式变为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
- config:
name: "demo for data driven."
parameters:
- Keyword:
parameter_file: keywords.csv
select_next_row: Random
- UserName:
parameter_file: account.csv
select_next_row: Sequential
- Password:
parameter_file: account.csv
select_next_row: same line as UserName

然后,我们可以看到UserName和Password这两个参数,它们有关联性,但却各自单独进行了配置;而且对于有关联性的参数,除了需要对第一个参数配置取值方式外,其它参数的select_next_row应该总是为same line as XXX,这样描述就显得比较累赘了。

既然是有关联性的参数,那就放在一起吧,参数名称可以采用约定的符号进行分离。考虑到参数变量名称通常包含字母、数字和下划线,同时要兼顾YAML/JSON中对字符的限制,因此选择短横线(-)作为分隔符吧。

经过第二轮简化,配置描述方式变为如下形式:

1
2
3
4
5
6
7
8
9
- config:
name: "demo for data driven."
parameters:
- Keyword:
parameter_file: keywords.csv
select_next_row: Random
- UserName-Password:
parameter_file: account.csv
select_next_row: Sequential

接着,我们再看下parameter_file参数。因为我们测试用例中的参数名称必须与数据源进行绑定,因此这一项信息是不可少的。但是在描述形式上,还是会感觉有些繁琐。再一想,既然我们本来就要指定参数名称,那何必不将参数名称约定为文件名称呢?

例如,对于参数Keyword,我们可以将其数据源文件名称约定为Keyword.csv;对于参数UserName和Password,我们可以将其数据源文件名称约定为UserName-Password.csv;然后,再约定数据源文件需要与当前YAML/JSON测试用例文件放置在同一个目录。

经过第三轮简化,配置描述方式变为如下形式:

1
2
3
4
5
6
7
- config:
name: "demo for data driven."
parameters:
- Keyword:
select_next_row: Random
- UserName-Password:
select_next_row: Sequential

同时该用例文件同级目录下的数据源文件名称为Keyword.csv和UserName-Password.csv。

现在,我们就只剩下select_next_row一个配置项了。既然是只剩一项,那就也省略配置项名称吧。

最终,我们的配置描述方式变为:

1
2
3
4
5
- config:
name: "demo for data driven."
parameters:
- Keyword: Random
- UserName-Password: Sequential

不过,我们还忽略了一个信息,那就是脚本的运行次数。假如参数取值都是采用Sequential的方式,那么我们可以将不同组参数进行笛卡尔积的组合,这是一个有限次数,可以作为自动化测试运行终止的条件;但如果参数取值采用Random的方式,即每次都是在参数列表里面随机取值,那么就不好界定自动化测试运行终止的条件了,我们只能手动进行终止,或者事先指定运行的总次数,不管是采用哪种方式,都会比较麻烦。

针对参数取值采用Random方式的这个问题,我们不妨换个思路。从数据驱动的角度来看,我们期望在自动化测试时能遍历数据源文件中的所有数据,那么重复采用相同参数进行测试的意义就不大了。因此,在选择Random的取值方式时,我们可以先将参数列表进行乱序排序,然后采用顺序的方式进行遍历;对于存在多组参数的情况,也可以实现乱序排序后再进行笛卡尔积的组合方式了。

到此为止,我们的参数化配置方式应该算是十分简洁了,而且在功能上也能满足常规参数化的配置需求。

最后,我们再回过头来看脚本参数化设计思路的演变历程,基本上都可以概括为约定大于配置,这的确也是HttpRunner崇尚和遵循的准则。

开发实现

设计思路理顺了,实现起来就比较简单了,点击此处查看相关代码,就会发现实际的代码量并不多。

在这里我就只挑几个典型的点讲下。

数据源格式约定

既然是参数化,那么肯定会存在数据源的问题,我们约定采用.csv文件格式来存储参数列表。同时,在同一个测试场景中可能会存在多个参数的情况,为了降低问题的复杂度,我们可以约定独立参数存放在独立的.csv文件中,多个具有关联性的参数存放在一个.csv文件中。另外,我们同时约定在.csv文件中的第一行必须为参数名称,并且要与文件名保持一致;从第二行开始为参数值,每个值占一行。

例如,keyword这种独立的参数就可以存放在keyword.csv中,内容形式如下:

1
2
3
4
keyword
hello
world
debugtalk

username和password这种具有关联性的参数就可以存放在username-password.csv中,内容形式如下:

1
2
3
4
username,password
test1,111111
test2,222222
test3,333333

csv 解析器

数据源的格式约定好了,我们要想进行读取,那么就得有一个对应的解析器。因为我们后续想要遍历每一行数据,并且还会涉及到多个参数进行组合的情况,因此我们希望解析出来的每一行数据应该同时包含参数名称和参数值。

于是,我们的数据结构就约定采用list of dict的形式。即每一个.csv文件解析后会得到一个列表(list),而列表中的每一个元素为一个字典结构(dict),对应着一行数据的参数名称和参数值。具体实现的代码函数为_load_csv_file。

例如,上面的username-password.csv经过解析,会生成如下形式的数据结构。

1
2
3
4
5
[
{'username': 'test1', 'password': '111111'},
{'username': 'test2', 'password': '222222'},
{'username': 'test3', 'password': '333333'}
]

这里还会涉及到一个问题,就是参数取值顺序。

在YAML/JSON测试用例中,我们会配置参数的取值顺序,是要顺序取值(Sequential)还是乱序随机取值(Random)。对于顺序的情况没啥好说的,默认从.csv文件中读取出的内容就是顺序的;对于随机取值,更确切地说,应该是乱序取值,我们需要进行一次乱序排序,实现起来也很简单,使用random.shuffle函数即可。

1
2
if fetch_method.lower() == "random":
random.shuffle(csv_content_list)

多个参数的组合

然后,对于多个参数的情况,为了组合出所有可能的情况,我们就需要用到笛卡尔积的概念。直接看例子可能会更好理解些。

例如我们在用例场景中具有三个参数,a为独立参数,参数列表为[1, 2];x和y为关联参数,参数列表为[[111,112], [121,122]];经过解析后,得到的数据分别为:

1
2
3
4
5
6
7
8
a:
[{"a": 1}, {"a": 2}]

x & y:
[
{"x": 111, "y": 112},
{"x": 121, "y": 122}
]

那么经过笛卡尔积,就可以组合出4种情况,组合后的结果应该为:

1
2
3
4
5
6
[
{'a': 1, 'x': 111, 'y': 112},
{'a': 1, 'x': 121, 'y': 122},
{'a': 2, 'x': 111, 'y': 112},
{'a': 2, 'x': 121, 'y': 122}
]

这里需要强调的是,多个参数经过笛卡尔积运算转换后,仍然是list of dict的数据结构,列表中的每一个字典(dict)代表着参数的一种组合情况。

参数化数据驱动

现在,我们已经实现了在YAML/JSON测试用例文件中对参数进行配置,从.csv数据源文件中解析出参数列表,并且生成所有可能的组合情况。最后还差一步,就是如何使用参数值来驱动测试用例的执行。

听上去很高大上,但实际却异常简单,直接对照着代码来说吧。

对于每一组参数组合情况来说,我们完全可以将其视为当前用例集运行时定义的变量值。而在 HttpRunner 中每一次运行测试用例集的时候都需要对runner.Runner做一次初始化,里面会用到定义的变量(即config_dict["variables"]),那么,我们完全可以在每次初始化的时候将组合好的参数作为变量传进去,假如存在同名的变量,就进行覆盖。

这样一来,我们就可以使用所有的参数组合情况来依次驱动测试用例的执行,并且每次执行时都采用了不同的参数,从而也就实现了参数化数据驱动的目的。

效果展示

最后我们再来看下实际的运行效果吧。

假设我们有一个获取token的接口,我们需要使用 user_agent 和 app_version 这两个参数来进行参数化数据驱动。

YAML 测试用例的描述形式如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- config:
name: "user management testset."
parameters:
- user_agent: Random
- app_version: Sequential
variables:
- user_agent: 'iOS/10.3'
- device_sn: ${gen_random_string(15)}
- os_platform: 'ios'
- app_version: '2.8.6'
request:
base_url: $BASE_URL
headers:
Content-Type: application/json
device_sn: $device_sn

- test:
name: get token with $user_agent and $app_version
api: get_token($user_agent, $device_sn, $os_platform, $app_version)
extract:
- token: content.token
validate:
- "eq": ["status_code", 200]
- "len_eq": ["content.token", 16]

其中,user_agent 和 app_version 的数据源列表分别为:

1
2
3
4
user_agent
iOS/10.1
iOS/10.2
iOS/10.3
1
2
3
app_version
2.8.5
2.8.6

那么,经过笛卡尔积组合,应该总共有6种参数组合情况,并且 user_agent 为乱序取值,app_version 为顺序取值。

最终的测试结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ hrun tests/data/demo_parameters.yml

Running tests...
----------------------------------------------------------------------
get token with iOS/10.2 and 2.8.5 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 13 ms, response_length: 46 bytes
OK (0.014845)s
get token with iOS/10.2 and 2.8.6 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 2 ms, response_length: 46 bytes
OK (0.003909)s
get token with iOS/10.1 and 2.8.5 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 3 ms, response_length: 46 bytes
OK (0.004090)s
get token with iOS/10.1 and 2.8.6 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 5 ms, response_length: 46 bytes
OK (0.006673)s
get token with iOS/10.3 and 2.8.5 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 3 ms, response_length: 46 bytes
OK (0.004775)s
get token with iOS/10.3 and 2.8.6 ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token
INFO:root: status_code: 200, response_time: 3 ms, response_length: 46 bytes
OK (0.004846)s
----------------------------------------------------------------------
Ran 6 tests in 0.046s

至此,我们就已经实现了参数化数据驱动的需求。对于测试用例中参数的描述形式,大家要是发现还有更加简洁优雅的方式,欢迎反馈给我。

最后,本文发表于 2018 年大年初一,祝大家新年快乐,狗年旺旺旺!

HttpRunner 通过 skip 机制实现对测试用例的分组执行控制

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

背景介绍

近期,某位同学对HttpRunner提了一个需求点:

能否支持类似unittest中的skip注解,方便灵活剔除某些用例,不执行。
目前在接口测试日常构建中,会遇到一些接口开发暂时屏蔽了或者降级,导致用例执行失败;所以想当遇到这些情况的时候,能够临时剔除掉某些用例不执行;等后续恢复后,再去掉,然后恢复执行。

针对这种情况,HttpRunner的确没有直接支持。之所以说是没有直接支持,是因为在HttpRunner中存在times关键字,可以指定某个test的运行次数。

例如,如下test中指定了times为3,那么该test就会运行3次。

1
2
3
4
5
- test:
name: demo
times: 3
request: {...}
validate: [...]

假如要实现临时屏蔽掉某些test,那么就可以将对应test的times设置为0。

这虽然也能勉强实现需求,但是这跟直接将临时不运行的test注释掉没什么区别,都需要对测试用例内容进行改动,使用上很是不方便。

考虑到该需求的普遍性,HttpRunner的确应该增加对该种情况的支持。

在这方面,unittest已经有了清晰的定义,有三种常用的装饰器可以控制单元测试用例是否被执行:

  • @unittest.skip(reason):无条件跳过当前测试用例
  • @unittest.skipIf(condition, reason):当条件表达式的值为true时跳过当前测试用例
  • @unittest.skipUnless(condition, reason):当条件表达式的值为false时跳过当前测试用例

该功能完全满足我们的需求,因此,我们可以直接复用其概念,尝试实现同样的功能。

实现方式

目标明确了,那需要怎么实现呢?

首先,我们先看下unittest中这三个函数是怎么实现的;这三个函数定义在unittest/case.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
29
30
31
32
33
34
35
36
37
38
39
40
class SkipTest(Exception):
"""
Raise this exception in a test to skip it.

Usually you can use TestCase.skipTest() or one of the skipping decorators
instead of raising this directly.
"""
pass

def skip(reason):
"""
Unconditionally skip a test.
"""
def decorator(test_item):
if not isinstance(test_item, (type, types.ClassType)):
@functools.wraps(test_item)
def skip_wrapper(*args, **kwargs):
raise SkipTest(reason)
test_item = skip_wrapper

test_item.__unittest_skip__ = True
test_item.__unittest_skip_why__ = reason
return test_item
return decorator

def skipIf(condition, reason):
"""
Skip a test if the condition is true.
"""
if condition:
return skip(reason)
return _id

def skipUnless(condition, reason):
"""
Skip a test unless the condition is true.
"""
if not condition:
return skip(reason)
return _id

不难看出,核心有两点:

  • 对于skip,只需要在该测试用例中raise SkipTest(reason),而SkipTest是unittest/case.py中定义的一个异常类;
  • 对于skipIf和skipUnless,相比于skip,主要是需要指定一个条件表达式(condition),然后根据该表达式的实际值来决定是否skip当前测试用例。

明确了这两点之后,我们要如何在HttpRunner中实现同样的功能,思路应该就比较清晰了。

因为HttpRunner同样也是采用unittest来组织和驱动测试用例执行的,而具体的执行控制部分都是在httprunner/runner.py的_run_test方法中;同时,在_run_test方法中会传入testcase_dict,也就是具体测试用例的全部信息。

那么,最简单的做法,就是在YAML/JSON测试用例中,新增skip/skipIf/skipUnless参数,然后在_run_test方法中根据参数内容来决定是否执行raise SkipTest(reason)。

例如,在YAML测试用例中,我们可以按照如下形式新增skip字段,其中对应的值部分就是我们需要的reason。

1
2
3
4
5
- test:
name: demo
skip: "skip this test unconditionally"
request: {...}
validate: [...]

接下来在_run_test方法,要处理就十分简单,只需要判断testcase_dict中是否包含skip字段,假如包含,则执行raise SkipTest(reason)即可。

1
2
3
4
5
6
7
8
def _run_test(self, testcase_dict):
...

if "skip" in testcase_dict:
skip_reason = testcase_dict["skip"]
raise SkipTest(skip_reason)

...

这对于skip机制来做,完全满足需求;但对于skipIf/skipUnless,可能就会麻烦些,因为我们的用例是在YAML/JSON文本格式的文件中,没法像在unittest中执行condition那样的Python表达式。

嗯?谁说在YAML/JSON中就不能执行函数表达式的?在HttpRunner中,我们已经实现了该功能,即:

  • 在debugtalk.py中定义函数,例如func(a, b)
  • 在YAML/JSON中通过${func(a,b)}对函数进行调用

在此基础上,我们要实现skipIf/skipUnless就很简单了;很自然地,我们可以想到采用如下形式来进行描述。

1
2
3
4
5
- test:
name: create user which existed (skip if condition)
skipIf: ${skip_test_in_production_env()}
request: {...}
validate: [...]

其中,skip_test_in_production_env定义在debugtalk.py文件中。

1
2
3
4
def skip_test_in_production_env():
""" skip this test in production environment
"""
return os.environ["TEST_ENV"] == "PRODUCTION"

然后,在_run_test方法中,我们只需要判断testcase_dict中是否包含skipIf字段,假如包含,则将其对应的函数表达式取出,运行得到其结果,最后再根据运算结果来判断是否执行raise SkipTest(reason)。对函数表达式进行解析的方法在httprunner/context.py的exec_content_functions函数中,具体实现方式可阅读之前的文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
def _run_test(self, testcase_dict):
...

if "skip" in testcase_dict:
skip_reason = testcase_dict["skip"]
raise SkipTest(skip_reason)
elif "skipIf" in testcase_dict:
skip_if_condition = testcase_dict["skipIf"]
if self.context.exec_content_functions(skip_if_condition):
skip_reason = "{} evaluate to True".format(skip_if_condition)
raise SkipTest(skip_reason)

...

skipUnless与skipIf类似,不再重复。

通过该种方式,我们就可以实现在不对测试用例文件做任何修改的情况下,通过外部方式(例如设定环境变量的值)就可以控制是否执行某些测试用例。

效果展示

skip/skipIf/skipUnless机制实现后,我们对测试用例的执行控制就更加灵活方便了。

例如,我们可以很容易地实现如下常见的测试场景:

  • 对测试用例进行分组,P0/P1/P2等,然后根据实际需求选择执行哪些用例
  • 通过环境变量来控制是否执行某些用例

更重要的是,我们无需对测试用例文件进行任何修改。

在HttpRunner项目中存在一个示例文件,httprunner/tests/data/demo_testset_cli.yml,大家可以此作为参考。

在运行该测试集后,生成的测试报告如下所示。

我的 2017 年终总结

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

2017年已经结束了,为了给以后的自己留点回忆,还是写篇博客记录下吧。

先从工作说起吧。这一年,我依然在大疆的互联网事业部做测试开发岗位。有点变化的是,年初是自己一个人做,然后逐步有新的伙伴加入,成立了测试开发小组,并担任了小组负责人。简单地说,就是除了纯技术,也开始兼具了一点技术管理的角色,这对于我个人也是一个挺大的转变。看过我个人博客自我介绍的同学都知道,“在墙角安静地写代码才是我的最爱”,所以我对于做管理是一直都挺抗拒的。不过,后来通过阅读一些互联网前辈的文章(主要有池建强老师、左耳朵耗子皓叔、硅谷女神安姐等),再加上leader的循序善诱,我的想法也发生了一些转变。我渐渐地也开始认同,技术管理与做好技术并不冲突,但发挥团队的力量,却可以做更大的事情,产生更大的价值。同时,担任技术管理也意味着多了一份责任,自己不能再由着个人喜好去摸索一些看似酷炫实则无用的“黑科技”,而应时刻关注技术转化的业务价值,这不管是对于公司还是个人,都是至关重要的。

在大疆除了测试开发工作,我还负责一些重要节点的性能压测工作。今年最大的一次压测应该就是准备5月24日的Spark新品发布了。在这个项目中,我担任性能测试总负责人,负责PC商城、手机商城、官网、直播系统、支付中心等相关电商系统的整体性能测试工作。当然,那段时间加班也挺多的,以至于我后面出差到达纽约发布会现场都不用倒时差了。说到出差,感谢领导和同事的信任,让我去发布会现场做直播系统的技术支持,我也有机会第一次去到美国,这个我一直特别想去体验的国家。现在回想起来,当时也真是人品爆发,在办美国商务签证的时候很顺利地就通过了(可怜我的另两位同事,他们虽然都准备得非常充分,但后来去办签证的时候都进了审核);另外,当时自己因为疏忽,忘了eVUS这么一个东西,结果去登机时才被告知必须要提前完成eVUS的登记审核,搞得我一脸懵逼,所幸当时立即提交申请后居然很快就审批通过了,不然差点就真错过了。

当时到纽约完成了新品发布会的工作后,考虑到机会难得,我也申请了两天的假期,在纽约市区(主要就是曼哈顿区)转了下,因为时间有限,去的也都是闻名已久的地标,例如时代广场、世贸中心、帝国大厦、中央公园、自然历史博物馆等,以及远距离看了下自由女神像的侧面。对于纽约,印象是极其深刻的,如果让我用一个词来概括,那就是“大城市”,虽然在国内去的地方也不少,意大利法国的首都也去旅游过,但到纽约后真的有种进城了的感觉。当时在返程的飞机上趁着兴奋劲,还写了篇记录文章,《纽约出差之城市印象》,写了近两千字还没有收尾,结果后来拖延证一犯就一直没发出来。除了逛纽约,当时也跟多年未见的同学聚了下。一个是高中同学,在纽约大学任教,在他的带领下我也逛了下纽约大学,可惜当时天气不是特别好,没能见到草地上满是穿着比基尼晒太阳的妹子,甚是遗憾。另一个是大学室友,本来是在波士顿工作的,结果也带着媳妇儿(同是大学校友)开车五六个小时到纽约聚了下。哦对,我们聚会都是吃的中餐厅。在异国他乡与多年未见的同学相聚,叙叙旧聊聊人生,感觉还是蛮不错的。

今年除了到美国纽约,我还到了其它几个国家地区。还是纽约出差那次,因为国内直达的航班机票价格太高,所以我选择了中转的方式;当时就从香港机场乘机,先飞到了韩国的首尔机场,然后再转机到纽约的肯尼迪机场。当时在韩国首尔机场停歇了四五个小时,虽然没有出机场,但是在机场里面逛的地方也挺多的,除了尝下韩国本土特色小吃外,还碰到了换班的空姐,一大波韩国空姐陆陆续续从眼前走过,场景甚是壮观。除了那次出差,在年初春节的时候,跟媳妇儿和朋友媳妇一起到意大利、法国旅行度蜜月,大概12天的自由行,游玩的城市有罗马、梵蒂冈、佛罗伦萨、米兰和巴黎。当时也是第一次出国,新鲜感蛮大的,还写了一篇游记,《春节旅行之意法印象》。

再说回技术方面。今年做了一个自己还算比较满意的开源项目,HttpRunner(起初叫做ApiTestEngine),核心特色就是基于现有成熟的Python开源项目requests和Locust,打造了一套HTTP测试框架,可以实现只需采用YAML/JSON格式维护一套脚本,就可同时实现自动化测试和性能测试。回顾今年的技术博客,有16篇文章都是围绕HttpRunner写的,《HttpRunner 开发博客》,可见自己在这上面投入的精力还是非常大的。值得欣慰的是,该框架产生的收效还不错,除了在大疆内部的多个项目中投入了使用,当前已知的有好几个其它公司的测试同学也都在使用这个框架。也源于开源,我在开发该框架的时候收到了非常多的反馈和建议,这对HttpRunner的持续优化迭代产生了非常巨大的帮助。从情感上讲,HttpRunner就如同自己的亲骨肉,里面融入了我自身非常多的对测试的思考,后续我也将继续不断优化HttpRunner,期望它能有朝一日在测试届大放异彩。

今年,我也在公司内外做过一些分享。一个是六月份的时候,当时公司新进了一批实习生,当时分配给我的任务是对我们质量部的岗位进行介绍,给实习生们讲解下我们测试工程师的工作日常情况。由于面向的都是新人,而且各种岗位的都有,因此也只能是科普介绍了。讲完后,我又整理下内容写了篇博客文章,《【科普】互联网测试岗位的工作日常》,阅读量居然还挺高。另一次分享是大疆与TesterHome合办的测试沙龙,我作为其中一位分享者,演讲的主题也是围绕着HttpRunner,题目是《低成本实现系统接口测试 – 自动化、性能、持续集成&线上监控》,从现场互动上来看,还是挺不错的。沙龙活动之后,TOP100的某位主编联系到我,希望我能在2017年第6届全球软件案例研究峰会上做了分享,当时想着也是个锻炼的机会,也就提交了案例,并且入了榜单;不过后来主办方的做法有些让人呵呵,在此我也不想多提了,总之最后我没去现场(虽然赠送给我一张全程票),后续应该也不会考虑与他们合作了。关于分享这块儿,比较遗憾的是错过了TesterHome的测试开发大会,五月份的时候思寒问我topic的时候,当时忙于公司的新品发布会没时间准备,就此错过了与诸多大佬见面的机会,只能看2018年是否还有机会了。

今年另一件比较有意思的事情,签订了一份图书出版合同。六月份的时候,电子工业出版社博文视点的一位编辑跟我联系,说看到我博客上的文章还不错,问我是否有兴趣出版图书。当时我既惊讶又惊喜,因为写书这事儿我之前从来没敢想过,所以一时心里也没底儿。好在陈编也给了我很多肯定和鼓励,同时我也想挑战一下自己,最后就答应了出版的事儿,并最终与博文视点签订了合同,书名暂定为《互联网系统测试精要:自动化、性能、持续集成》。合同签订后,我开始后悔自己太过乐观了,写书比我想象中难得多。因为写书只能是业余时间,有时工作太忙就完全顾不上了,等过一段时间再想提笔的时候发现手感灵感全没了。是的,别说是写书了,就是写博客,隔段时间不写再想捡起来也是异常痛苦。有过这段经历,我对书籍更多了一分敬畏,先不说书的内容质量,光是作者坚持下去的这份毅力,也是难能可贵的。还好出版社也没有给我压力,在这个过程中也给了我不少鼓励。当时签约的交稿日期是2017年12月中旬,但实际完成率嘛,嗯,希望我能在2018年尽早完成吧。

再说点生活上的事儿吧。今年我买了人生中的第一辆车,从此也算是有房有车了,虽然房子远在广州山区,车也不是啥好车。在买车之前,我基本对车完全不懂,可能除了奔驰宝马奥迪的车标能认出来外,其它一概不知。当时要去4S店看车之前,担心被销售看出啥也不懂的尴尬,还在汽车之家上好生科普了一番,总算对汽车有了点了解。然后就是一番小纠结,先是犹豫买SUV还是轿车,听闻20万以下的城市SUV也就那样后就决定买轿车;然后考虑轿车买A级还是B级,想到近几年也不会换车,还是一次性买个宽敞点的吧,就决定买B级车;然后就在几个品牌的B级车里选了,丰田凯美瑞、本田雅阁、雪佛兰迈锐宝、马自达阿特兹、别克君威都有看过,最后被马自达的信仰洗脑了,再加上阿特兹的颜值,从而就选定了阿特兹这款车,而且选的是骚气的魂动红,应该比较符合我闷骚的气质吧。另外,由于深圳和广州的车牌都需要摇号,拍卖价格又太高,所以就选择了在老家上牌,幸运的是选中的车牌还比较满意,DBG256,跟我个人的职业也比较搭。在车技这块儿,虽然我的驾照满六年都换过一次证了,但这些年摸车的确比较少,当时为了找手感,还提前在58同城上约了一个陪驾服务,师傅是一位号称有18年驾龄的资深美女。所幸经过一段时间的熟悉,现在车技已经好多了,至少刚开始时那些停车就不小心剐蹭到旁边的奔驰、上高速就手心冒汗这些心理障碍已经差不多没了。

2017年,一不小心就写了这么多。最后的压轴戏,当然是我刚出生的小坚果宝宝啦。刚当上爸爸,咋说呢,心情有些复杂,既兴奋又紧张。虽然是足月,而且还晚了两天才出生,但当第一次看到小家伙的时候,还是会感觉有些惊慌失措,从此我便多了一层父亲的身份,需要肩负更多的责任。还记得多年前曾和一位领导兼前辈聊人生,他跟我说工作并不是生活的全部,下班回家逗逗儿子也是挺有意思的。当时我还不能完全理解,现在我终于有了切身的体会,看着孩子的喜怒哀乐,陪伴的孩子的成长,本身就是一件非常幸福的事情。而我自身,也需要做好爸爸的榜样,言传身教还是很重要的。对于宝宝的未来,我也并没有太多期待,健康快乐地成长就好了,过两年再大点的时候,顺便再把Python学会了,嗯,徒手反转二叉树啥的技能也得学下。

最后按照惯例,再展望下即将来临的2018年吧。

1、坚持写作。博客公众号文章的更新频率得提升下,平均每月3~5篇还是要保证的。另外就是尽快把签约的书稿结了,不然就算出版社不催,我也不好意思了。

2、在工作上有更多的成长和产出。新的一年就迈入而立之年了,离“中年危机”也更近了一分,焦虑是没用的,踏踏实实快速成长吧。

3、学会生活,锻炼身体,陪伴家人,工作是长跑,讲究的是可持续发展。去年的展望也写了这句,但做的并不好,希望2018年能有所改善。

4、再借用下习主席的新年寄语,逢山开路,遇水搭桥,不管有啥困难,终将可以克服的。

成长轨迹

  • 《我的 2016 年终总结》
  • 《我的 2015 年终总结》

HttpRunner 的测试用例分层机制

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

背景描述

在HttpRunner中,测试用例引擎最大的特色就是支持YAML/JSON格式的用例描述形式。

采用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
26
27
28
29
30
31
32
33
34
- config:
name: demo-login-logoff
variable_binds:
- UserName: test001
- Password: 123456
request:
base_url: http://xxx.debugtalk.com
headers:
Accept: application/json
User-Agent: iOS/10.3

- test:
name: Login
request:
url: /api/v1/Account/Login
method: POST
json:
UserName: $UserName
Pwd: $Password
VerCode: ""
validators:
- eq: ["status_code", 200]
- eq: ["content.IsSuccess", True]
- eq: ["content.Code", 200]

- test:
name: Logoff
request:
url: /api/v1/Account/LoginOff
method: GET
validators:
- eq: ["status_code", 200]
- eq: ["content.IsSuccess", True]
- eq: ["content.Code", 200]

相信大家已经对该种用例描述形式十分熟悉了。不过,该种描述形式的问题在于,接口通常会出现在多个测试场景中,而每次都需要对接口进行定义描述,包括请求的URL、Header、Body、以及预期响应值等,这就会产生大量的重复。

例如,在某个项目中存在三个测试场景:

  • 场景A:注册新账号(API_1/2)、登录新注册的账号(API_3/4/5)、查看登录状态(API_6);
  • 场景B:登录已有账号(API_3/4/5)、注销登录(API_7/8);
  • 场景C:注销登录(API_7/8)、查看登录状态(API_6)、注册新账号(API_1/2)。

按照常规的接口测试用例编写方式,我们需要创建3个场景文件,然后在各个文件中分别描述三个测试场景相关的接口信息。示意图如下所示。

在本例中,接口(API_1/2/6)在场景A和场景C中都进行了定义;接口(API_3/4/5)在场景A和场景B中都进行了定义;接口(API_7/8)在场景B和场景C中都进行了定义。可以预见,当测试场景增多以后,接口定义描述的维护就会变得非常困难和繁琐。

接口的分层定义描述

那要如何进行优化呢?

其实也很简单,在编程语言中,如果出现重复代码块,我们通常会将其封装为类或方法,然后在需要时进行调用,以此来消除重复。同样地,我们也可以将项目的API进行统一定义,里面包含API的请求和预期响应描述,然后在测试场景中进行引用即可。

示意图如下所示。

具体地,我们可以约定将项目的所有API接口定义放置在api目录下,并在api目录中按照项目的系统模块来组织接口的定义;同时,将测试场景放置到testcases目录中。

此时测试用例文件的目录结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
✗ tree tests
tests
├── api
│ └── v1
│ ├── Account.yml
│ ├── BusinessTrip.yml
│ ├── Common.yml
│ └── Leave.yml
├── debugtalk.py
└── testcases
├── scenario_A.yml
├── scenario_B.yml
└── scenario_C.yml

而对于API接口的定义,与之前的描述方式基本一致,只做了两点调整:

  • 接口定义块(block)的标识为api;
  • 接口定义块中包含def字段,形式为api_name(*args),作为接口的唯一标识ID;需要注意的是,即使api没有参数,也需要带上括号,api_name();这和编程语言中定义函数是一样的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- api:
def: api_v1_Account_Login_POST($UserName, $Password)
request:
url: /api/v1/Account/Login
method: POST
json:
UserName: $UserName
Pwd: $Password
VerCode: ""
validators:
- eq: ["status_code", 200]
- eq: ["content.IsSuccess", True]
- eq: ["content.Code", 200]

- api:
def: api_v1_Account_LoginOff_GET()
request:
url: /api/v1/Account/LoginOff
method: GET
validators:
- eq: ["status_code", 200]
- eq: ["content.IsSuccess", True]
- eq: ["content.Code", 200]

有了接口的定义描述后,我们编写测试场景时就可以直接引用接口定义了。

同样是背景描述中的登录注销场景,测试用例就描述为变为如下形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- config:
name: demo
variable_binds:
- UserName: test001
- Password: 123456
request:
base_url: http://xxx.debugtalk.com
headers:
Accept: application/json
User-Agent: iOS/10.3

- test:
name: Login
api: api_v1_Account_Login_POST($UserName, $Password)

- test:
name: Logoff
api: api_v1_Account_LoginOff_GET()

不难看出,对API接口进行分层定义后,我们在测试用例场景中引用接口定义时,与编程语言里面调用函数的形式基本完全一样,只需要指定接口的名称,以及所需传递的参数值;同样的,即使没有参数,也需要带上括号。

实现接口的分层定义描述后,我们就可以避免接口的重复定义。但是,我们回过头来看之前的案例,发现仍然会存在一定的重复。

如上图所示,场景A和场景C都包含了注册新账号(API_1/2)和查看登录状态(API_6),场景A和场景B都包含了登录已有账号(API_3/4/5),场景B和场景C都包含了注销登录(API_7/8)。

虽然我们已经将接口的定义描述抽离出来,避免了重复的定义;但是在实际业务场景中,某些功能(例如登录、注销)会在多个场景中重复出现,而该功能又涉及到多个接口的组合调用,这同样也会出现大量的重复。

接口的模块化封装

玩过积木的同学可能就会想到,我们也可以将系统的常用功能封装为模块(suite),只需要在模块中定义一次,然后就可以在测试场景中重复进行引用,从而避免了模块功能的重复描述。

具体地,我们可以约定将项目的所有模块定义放置在suite目录下,并在suite目录中按照项目的功能来组织模块的定义。

后续,我们在testcases目录中描述测试场景时,就可同时引用接口定义和模块定义了;模块和接口的混合调用,必将为我们编写测试场景带来极大的灵活性。

此时测试用例文件的目录结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
✗ tree tests
tests
├── api
│ └── v1
│ ├── Account.yml
│ ├── BusinessTrip.yml
│ ├── Common.yml
│ └── Leave.yml
├── debugtalk.py
├── suite
│ ├── BusinessTravelApplication
│ │ ├── approve-application.yml
│ │ ├── executive-application.yml
│ │ ├── reject-application.yml
│ │ └── submit-application.yml
│ └── LeaveApplication
│ ├── approve.yml
│ ├── cancel.yml
│ └── submit-application.yml
└── testcases
├── scenario_A.yml
├── scenario_B.yml
└── scenario_C.yml

需要注意的是,我们在组织测试用例描述的文件目录结构时,遵循约定大于配置的原则:

  • API接口定义必须放置在api目录下
  • 模块定义必须放置在suite目录下
  • 测试场景文件必须放置在testcases目录下
  • 相关的函数定义放置在debugtalk.py中

至此,我们实现了测试用例的接口-模块-场景分层,从而彻底避免了重复定义描述。

脚手架工具

得益于约定大于配置的原则,在HttpRunner中实现了一个脚手架工具,可以快速创建项目的目录结构。该想法来源于Django的django-admin.py startproject project_name。

使用方式也与Django类似,只需要通过--startproject指定新项目的名称即可。

1
2
3
4
5
6
7
8
$ hrun --startproject helloworld
INFO:root: Start to create new project: /Users/Leo/MyProjects/helloworld
INFO:root: created folder: /Users/Leo/MyProjects/helloworld
INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests
INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests/api
INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests/suite
INFO:root: created folder: /Users/Leo/MyProjects/helloworld/tests/testcases
INFO:root: created file: /Users/Leo/MyProjects/helloworld/tests/debugtalk.py

运行之后,就会在指定的目录中生成新项目的目录结构,接下来,我们就可以按照测试用例的接口-模块-场景分层原则往里面添加用例描述信息了。

总结

如果看到这里你还不明白测试用例分层的必要性,那也没关系,测试用例分层不是必须的,你还是可以按照之前的方式组织测试用例。不过当你某一天发现需要进行分层管理时,你会发现它就在那里,很实用。

最后,在HttpRunner项目的examples/HelloWorld目录中,包含了一份完整的分层测试用例示例,相信会对大家有所帮助。

HttpRunner 的结果校验器优化

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

在测试用例中,包含预期结果这么一项,用于辅助测试人员执行测试用例时判断系统的功能是否正常。而在自动化测试中,我们的目标是让测试用例自动执行,因此自动化测试用例中同样需要包含预期结果一项,只不过系统响应结果不再由人工来进行判断,而是交由测试工具或框架来实现。

这部分功能对应的就是测试结果校验器(validator),基本上能称得上自动化测试工具或框架的都包含该功能特性。

设计之初

HttpRunner在设计之初,结果校验器(validator)的实现比较简单。

对于每一个test,可以指定0个或多个校验项,放置在validate中。在自动化测试执行的时候,会在发起HTTP请求、解析结果响应之后,逐个检查各个校验项,若存在任意校验项不通过的情况,则该test将终止并被标记为失败。

1
2
3
4
5
6
7
8
9
10
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: GET
extract:
- token: content.token
validate:
- {"check": "status_code", "comparator": "eq", "expect": 200}
- {"check": "content.token", "comparator": "len_eq", "expect": 16}

如上例所示,每一个校验项均为一个json结构,里面包含check、expect、comparator三个属性字段。其中,check对应着要检查的字段,expect对应着检查字段预期的值,这两项是必须指定的;comparator字段对应着比较方法,若不指定,则默认采用eq,即检查字段与预期值相等。

为了实现尽可能强大的检查功能,check属性值可通过链式操作精确指定具体的字段,comparator也内置实现了大量的检查功能。

举个例子可能会更清晰些。假如某结构的响应结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// status code: 200

// response headers
{
"Content-Type": "application/json"
}

// response body content
{
"success": False,
"person": {
"name": {
"first_name": "Leo",
"last_name": "Lee",
},
"age": 29,
"cities": ["Guangzhou", "Shenzhen"]
}
}

那么假如我们要检查status code,check就可以指定为status_code;假如要检查response headers中的Content-Type,check就可以指定为headers.content-type;假如要检查response body中的first_name,check就可以指定为content.person.name.first_name。可以看出,假如下一层级为字典结构,那么就可以通过.运算符指定下一层级的key,依次类推。

对于字段内容为列表list的情况略有不同,我们需要通过序号来指定具体检查哪一项内容。例如,Guangzhou对应的检查项为content.person.cities.0,Shenzhen对应的检查项为content.person.cities.1。

在比较方式(comparator)方面,HttpRunner除了eq,还内置了大量的检查方法。例如,我们可以通过gt、ge、lt、le等比较数值大小,通过len_eq、len_gt、len_lt等比较长度是否相等(列表、字典、字符串均适用),通过contains、contained_by来判断包含关系,通过startswith、endswith判断字符串的开头结尾,甚至通过regex_match来判断是否满足正则匹配等。详细的比较方式还有许多,需要时可查看comparator表格。

存在的局限性

在大多数情况下,HttpRunner的结果校验器(validator)是够用的。不过问题在于,框架不可能为用户实现所有的检查方法,假如用户需要某些特殊的检查方法时,HttpRunner就没法实现了。

这的确是一个问题,之前Junho2010提的issue #29中举了一个例子,应该也算是比较有代表性。

发送请求时的数据使用了随机生成,然后需要比较结果中的数据是否是和这个相关(通过某个算法转换)。比如我输入的是321,我的结果是(3+2+1) * avg(3+2+1)这种转化,目前的comparator是比较难于实现的。

要解决这个问题,最好的方式应该是在HttpRunner中实现自定义结果校验器的机制;用户在有需要的时候,可以自己编写校验函数,然后在validate中引用校验函数。之前也介绍过HttpRunner的热加载机制,《约定大于配置:ApiTestEngine实现热加载机制》,自定义结果校验器应该也是可以采用这种方式来实现的。

第二个需要优化的点,HttpRunner的结果校验器还不支持变量引用,会造成某些场景下的局限性。例如,testwangchao曾提过一个issue #52:

接口response内,会返回数据库内的自增ID。ID校验的时候,希望expected为参数化的值。

1
2
validate:
- {"check": "content.data.table_list.0.id", "expected": "$id"}

另外,在《ApiTestEngine,不再局限于API的测试》一文中有介绍过,结果提取器(extract)新增实现了通过正则表达式对任意文本响应内容的字段提取。考虑到结果校验器(validate)也需要先从结果响应中提取出特定字段才能与预期值进行比较,在具体实现上完全可以复用同一部分代码,因此在validate的check部分也可以进行统一化处理。

经过前面的局限性问题描述,我们的改造目标也明确了,主要有三个方面:

  • 新增支持自定义结果校验器
  • 结果校验器中实现变量引用
  • 结果校验内容新增支持正则表达式提取

改造结果

具体的改造过程就不写了,有兴趣的同学可以直接阅读源码,重点查看httprunner/context.py中的parse_validator、do_validation和validate三个函数。

经过优化后,改造目标中的三项功能都实现了。为了更好地展现改造后的结果校验器,此处将结合实例进行演示。

新增支持自定义结果校验器

先来看第一个优化项,新增支持自定义结果校验器。

假设我们需要使用HTTP响应状态码各个数字的和来进行校验,例如,201状态码对应的数字和为3,503状态码对应的数字和为8。该实例只是为了演示用,实际上并不会用到这样的校验方式。

首先,该种校验方式在HttpRunner中并没有内置,因此需要我们自己来实现。实现方式与热加载机制相同,只需要将自定义的校验函数放置到当前YAML/JSON文件同级或者父级目录的debugtalk.py中。

对于自定义的校验函数,需要遵循三个规则:

  • 自定义校验函数需放置到debugtalk.py中
  • 参数有两个:第一个为原始数据,第二个为原始数据经过运算后得到的预期结果值
  • 在校验函数中通过assert将实际运算结果与预期结果值进行比较

对于前面提到的演示案例,我们就可以在debugtalk.py中编写如下校验函数。

1
2
3
4
5
6
7
8
9
def sum_status_code(status_code, expect_sum):
""" sum status code digits
e.g. 400 => 4, 201 => 3
"""
sum_value = 0
for digit in str(status_code):
sum_value += int(digit)

assert sum_value == expect_sum

然后,在YAML/JSON格式测试用例的validate中,我们就可以将校验函数名称sum_status_code作为comparator进行使用了。

1
2
3
4
5
6
7
8
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: GET
validate:
- {"check": "status_code", "comparator": "eq", "expect": 200}
- {"check": "status_code", "comparator": "sum_status_code", "expect": 2}

由此可见,自定义的校验函数sum_status_code与HttpRunner内置的校验方法eq在使用方式上完全相同,应该没有理解上的难度。

结果校验器中实现变量引用

对于第二个优化项,结果校验器中实现变量引用。在使用方式上我们应该与request中的变量引用一致,即通过$var的方式来引用变量var。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: GET
variables:
- expect_status_code: 200
- token_len: 16
extract:
- token: content.token
validate:
- {"check": "status_code", "comparator": "eq", "expect": "$expect_status_code"}
- {"check": "content.token", "comparator": "len_eq", "expect": "$token_len"}
- {"check": "$token", "comparator": "len_eq", "expect": "$token_len"}

通过以上示例可以看出,在结果校验器validate中,check和expect均可实现实现变量的引用;而引用的变量,可以来自四种类型:

  • 当前test中定义的variables,例如expect_status_code
  • 当前test中提取(extract)的结果变量,例如token
  • 当前测试用例集testset中,先前test中提取(extract)的结果变量
  • 当前测试用例集testset中,全局配置config中定义的变量

而check字段除了可以引用变量,以及保留了之前的链式操作定位字段(例如上例中的content.token)外,还新增了采用正则表达式提取内容的方式,也就是第三个优化项。

结果校验内容新增支持正则表达式提取

假设如下接口的响应结果内容为LB123abcRB789,那么要提取出abc部分进行校验,就可以采用如下描述方式。

1
2
3
4
5
6
7
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: GET
validate:
- {"check": "LB123(.*)RB789", "comparator": "eq", "expect": "abc"}

可见在使用方式上与在结果提取器(extract)中完全相同。

结果校验器的进一步简化

最后,为了进一步简化结果校验的描述,我在validate中新增实现了一种描述方式。

简化后的描述方式与原始方式对比如下:

1
2
3
validate:
- comparator_name: [check_item, expect_value]
- {"check": check_item, "comparator": comparator_name, "expect": expect_value}

同样是前面的例子,采用新的描述方式后会更加简洁。而两种方式表达的含义是完全等价的。

1
2
3
4
5
6
7
8
9
10
11
- test:
name: get token
request:
url: http://127.0.0.1:5000/api/get-token
method: GET
validate:
- eq: ["status_code", $expect_status_code]
- sum_status_code: ["status_code", 2]
- len_eq: ["$token", $token_len]
- len_eq: ["content.token", 16]
- eq: ["LB123(.*)RB789", "abc"]

当然,此次优化保证了与历史版本的兼容,之前编写的测试用例脚本的运行是完全不会受到任何影响的。

HttpRunner 支持 HAR 意味着什么?

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

HttpRunner开始支持HAR啦!!!

如果你还没有体会到这三个感叹号的含义,那们你可能对HAR还不了解。

HAR 是什么?

HAR的全称为HTTP Archive,是W3C(World Wide Web Consortium)发布的一个通用标准。简单地说,HAR是一个约定的JSON文件格式,用于记录HTTP请求交互的所有内容,包括请求响应的详细记录和性能度量数据。

虽然当前HAR标准还处于Draft状态,但它已经被业界广泛地采用了,许多我们日常使用的工具都已支持HAR。在下面罗列的工具中,相信大家都已经比较熟悉了。

  • Fiddler
  • Charles Web Proxy
  • Google Chrome
  • Firebug
  • HttpWatch
  • Firefox
  • Internet Explorer 9
  • Microsoft Edge
  • Paw
  • Restlet Client

可以看出,工具覆盖了主流的抓包工具、浏览器和接口测试工具。这些工具都支持HAR标准,可以将录制得到的数据包导出为.har的文件。

假如我们可以将HAR格式转换为HttpRunner的自动化测试用例,这就相当于HttpRunner可以和非常多的工具结合使用,并获得了接口录制和用例生成功能,灵活性和易用性都将得到极大的提升。

那么,将HAR格式转换为HttpRunner的自动化测试用例是否可行呢?

我们不妨先研究下HAR的格式。

HAR 格式详解

通过如上列出的任意一款工具,都可以将录制得到的数据包导出为.har的文件。我们采用文本编辑器打开.har文件后,会发现是一个JSON的数据结构。

默认情况下,.har文件的JSON数据结构是经过压缩的,直接看可能不够直观。推荐大家可以在文本编辑器中安装Prettify JSON的插件,然后就可以将压缩后的JSON数据一键转换为美观的格式。

更好的方式是,我们可以直接查看W3C编写的HAR格式标准。

通过文档可知,HAR是只有一个key的JSON数据结构,并且key值只能为log;而log的值也为一个JSON结构,里面的key包括:version、creator、browser、pages、entries、comment。

1
2
3
4
5
6
7
8
9
10
{
"log": {
"version": "",
"creator": {},
"browser": {},
"pages": [],
"entries": [],
"comment": ""
}
}

其中,version、creator和entries是必有字段,不管是哪款工具导出的.har文件,肯定都会包含这三个字段。而我们在转换生成自动化测试用例时,只需获取HTTP请求和响应的内容,这些全都包含在entries里面,因此我们只需要关注entries的内容即可。

entries字段对应的值为一个列表型数据结构,里面的值按照请求时间进行排序,罗列出各个HTTP请求的详细内容。具体地,HTTP请求记录的信息如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"entries": [
{
"pageref": "page_0",
"startedDateTime": "2009-04-16T12:07:23.596Z",
"time": 50,
"request": {...},
"response": {...},
"cache": {...},
"timings": {},
"serverIPAddress": "10.0.0.1",
"connection": "52492",
"comment": ""
},
]

由此可见,记录的HTTP信息非常全面,包含了HTTP请求交互过程中的所有内容。

而从生成自动化测试用例的角度来看,我们并不需要那么多信息,我们只需从中提取关键信息即可。

编写自动化测试用例,最关键的信息是要知道接口的请求URL、请求方法、请求headers、请求数据等,这些都包含在request字段对应的字典中。

1
2
3
4
5
6
7
8
9
10
11
12
"request": {
"method": "GET",
"url": "http://www.example.com/path/?param=value",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [],
"queryString" : [],
"postData" : {},
"headersSize" : 150,
"bodySize" : 0,
"comment" : ""
}

根据这些信息,我们就可以完成HTTP请求的构造。

当请求发送出去后,我们要想实现自动化地判断接口响应是否正确,我们还需要设置一些断言。而与HTTP响应相关的所有信息全都包含在response字段对应的字典中。

1
2
3
4
5
6
7
8
9
10
11
12
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [],
"content": {},
"redirectURL": "",
"headersSize" : 160,
"bodySize" : 850,
"comment" : ""
}

从通用性的角度考虑,我们会判断HTTP响应的状态码是否正确,这对应着status字段;如果我们还想在接口业务层面具有更多的判断,我们还会判断响应内容中的一些关键字段是否符合预期,这对应着content字段。

1
2
3
4
5
6
7
"content": {
"size": 33,
"compression": 0,
"mimeType": "text/html; charset=utf-8",
"text": "\n",
"comment": ""
}

对于content字段,可能会稍微复杂一些,因为接口响应内容的格式可能多种多样。

例如,响应内容可能text/html页面的形式,也可能是application/json的形式,具体类型可以查看mimeType得到,而具体的内容存储在text字段中。

另外,有时候响应数据还可能是经过编码的,用的最多的编码方式为base64。我们可以根据encoding字段获取得到具体的编码形式,然后采用对应的解码方式对text进行解码,最终获得原始的响应内容。

1
2
3
4
5
6
"content": {
"size": 63,
"mimeType": "application/json; charset=utf-8",
"text": "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=",
"encoding": "base64"
},

以上面的content为例,我们通过encoding查看到编码形式为base64,并通过text字段获取到编码后的内容;那么我们就可以采用base64的解码函数,转换得到原始的内容。

1
2
3
>>> import base64
>>> base64.b64decode(text)
b'{"IsSuccess":true,"Code":200,"Value":{"BlnResult":true}}'

同时,我们根据mimeType可以得到响应内容application/json数据类型,那么就可以对其再进行json.loads操作,最终得到可供程序处理的JSON数据结构。

通过上述对HAR格式的详细介绍,可以看出HAR格式十分清晰,在对其充分了解的基础上,再编写测试用例转换工具就很简单了。

har2case

编码过程没有太多值得说的,直接看最终成品吧。

最终产出的工具就是har2case,是一个命令行工具,可以直接将.har文件转换为YAML或JSON格式的自动化测试用例。

当前har2case已经上传到PYPI上了,通过pip或easy_install即可安装。

1
2
3
$ pip install har2case
# or
$ easy_install har2case

使用方式很简单,只需在har2case命令后分别带上HAR源文件路径和目标生成的YAML/JSON路径即可。

1
2
3
4
5
$ har2case tests/data/demo.har demo.yml
INFO:root:Generate YAML testset successfully: demo.yml

$ har2case tests/data/demo.har demo.json
INFO:root:Generate JSON testset successfully: demo.json

可以看出,具体是生成YAML还是JSON格式的问题,取决于指定目标文件的后缀:后缀为.yml或.yaml则生成YAML文件,后缀为.json则生成JSON文件。

如果不指定目标文件也行,则会默认生成JSON文件,文件名称和路径与.har源文件相同。

1
2
$ har2case tests/data/demo.har
INFO:root:Generate JSON testset successfully: tests/data/demo.json

具体的使用方式可以通过执行har2case -h查看。

在大多数情况下,生成的用例可直接在HttpRunner中使用,当然,是做接口自动化测试、接口性能测试,还是持续集成线上监控,这都取决于你。

不过,假如录制的场景中包含动态关联的情况,即后续接口请求参数依赖于前面接口的响应,并且每次调用接口时参数都会动态变化,那么就需要人工再对生成的脚本进行关联处理,甚至包括编写一些自定义函数等。

后续计划

读到这里,相信大家应该能体会到文章开头那三个感叹号的含义了,我也的确是带着难以言表的兴奋之情发布这个新功能的。

经过小范围的实际使用,效果很是不错,接口自动化测试用例的编写效率得到了极大的提升。而且,由于HAR本身的开放性,留给用户的选择非常多。

即便如此,我觉得HttpRunner的易用性还可以得到更大的提升。

当前,我规划了两项新特性将在近期完成:

  • 支持PostMan:将Postman Collection Format格式转换为HttpRunner支持的YAML/JSON测试用例;
  • 支持Swagger:将Swagger定义的API转换为HttpRunner支持的YAML/JSON测试用例。

等这两个新特性完成之后,相信HttpRunner会更上一个台阶。

如果你们有什么更好的想法,欢迎联系我。

ApiTestEngine 正式更名为 HttpRunner

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

在《ApiTestEngine,不再局限于API的测试》一文的末尾,我提到随着ApiTestEngine的发展,它的实际功能特性和名字已经不大匹配,需要考虑改名了。

经过慎重考虑,最终决定将ApiTestEngine正式更名为HttpRunner。

名字的由来

为什么选择HttpRunner这个名字呢?

在改名之前,我的想法很明确,就是要在新名字中体现该工具最核心的两个特点:

  • 该工具可实现任意基于HTTP协议接口的测试(自动化测试、持续集成、线上监控都是以此作为基础)
  • 该工具可同时实现性能测试(这是区别于其它工具的最大卖点)

围绕着这两点,我开始踏上了纠结的取名之路。

首先想到的,ApiTestEngine实现HTTP请求是依赖于Python Requests,实现性能测试是依赖于Locust,而Locust同样依赖于Python Requests。可以说,ApiTestEngine完全是构建在Python Requests之上的,后续无论怎么进化,这一层关系应该都不会变。

考虑到Python Requests的slogan是:

Python HTTP Requests for Humans™

因此,我想在ApiTestEngine的新名字中应该包含HTTP。

那如何体现性能测试呢?

想到的关键词就load、perf、meter这些(来源于LoadRunner,NeoLoad,JMeter),但又不能直接用,因为名字中带有这些词让人感觉就只是性能测试工具。而且,还要考虑跟HTTP这个词进行搭配。

最终,感觉runner这个词比较合适,一方面这来源于LoadRunner,大众的认可度可能会比较高;同时,这个词用在自动化测试和性能测试上都不会太牵强。

更重要的是,HttpRunner这个组合词当前还没有人用过,不管是PyPI还是GitHub,甚至域名都是可注册状态。

所以,就认定HttpRunner这个名字了。

相关影响

ApiTestEngine更名为HttpRunner之后,会对用户产生哪些影响呢?

先说结论,没有任何不好的影响!

在链接访问方面,受益于GitHub仓库链接的自动重定向机制,仓库在改名或者过户(Transfer ownership)之后,访问原有链接会自动实现重定向,因此之前博客中的链接也都不会受到影响。

新的仓库地址:https://github.com/HttpRunner/HttpRunner

在使用的命令方面,HttpRunner采用httprunner作为新的命令代替原有的ate命令;当然,为了考虑兼容性,HttpRunner对ate命令也进行了保留,因此httprunner和ate命令同时可用,并完全等价。在性能测试方面,locusts命令保持不变。

1
2
3
$ httprunner -V
HttpRunner version: 0.8.1b
PyUnitReport version: 0.1.3b

既然是全新的名字,新的篇章必然也得有一些新的东西。

为了方面用户安装,HttpRunner已托管至PyPI;后续大家可以方便的采用pip命令进行安装。

1
$ pip install HttpRunner

同时,HttpRunner新增了大量使用说明文档(之前的博客主要都是开发过程记录),并托管到专业的readthedocs上面。在文档语言方面,英文优先,中文相对滞后。

访问网址:

  • 英文:http://httprunner.readthedocs.io/
  • 中文(滞后):http://httprunner-cn.readthedocs.io/

另外,为了具有更高的逼格,同时购入域名httprunner.top,后续将作为项目的主页地址。当前还处于实名认证中,预计2~3个工作日后就可以访问了。

关于项目改名这事儿,就说到这儿吧,希望你们也喜欢。

Hello World, HttpRunner.

ApiTestEngine,不再局限于 API 的测试

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

背景

从编写《接口自动化测试的最佳工程实践(ApiTestEngine)》至今,已经快半年了。在这一段时间内,ApiTestEngine经过持续迭代,也已完全实现了当初预设的目标。

然而,在设计ApiTestEngine之初只考虑了面向最常规的API接口类型,即HTTP响应内容为JSON数据结构的类型。那么,如果HTTP接口响应内容不是JSON,而是XML或SOAP,甚至为HTML呢?

答案是,不支持!

不支持的原因是什么呢?

其实,不管是何种业务类型或者技术架构的系统接口,我们在对其进行测试时都可以拆分为三步:

  • 发起接口请求(Request)
  • 解析接口响应(Parse Response)
  • 校验测试结果(Validation)

而ApiTestEngine不支持XML/HTML类型的接口,问题恰恰是出现在解析接口响应和校验测试结果这两个环节。考虑到校验测试结果环节是依赖于解析接口响应,即需要先从接口响应结果中解析出具体的字段,才能实现与预期结果的校验检测,因此,制约ApiTestEngine无法支持XML/HTML类型接口的根本原因在于无法支持对XML/HTML的解析。

也因为这个原因,ApiTestEngine存在局限性,没法推广到公司内部的所有项目组。遇到JSON类型以外的接口时,只能再使用别的测试工具,体验上很是不爽。

在经历了一段时间的不爽后,我开始重新思考ApiTestEngine的设计,希望使其具有更大的适用范围。通过前面的分析我们也不难看出,解决问题的关键在于实现针对XML/HTML的解析器。

JSON接口的解析

在实现XML/HTML的解析器之前,我们不妨先看下ApiTestEngine的JSON解析器是怎么工作的。

在JSON类型的数据结构中,无论结构有多么复杂,数据字段都只可能为如下三种数据类型之一:

  • 值(value)类型,包括数字、字符串等;该种数据类型的特点是不会再有下一层极的数据;
  • 字典(dict)类型;该种数据类型的特点是包含无序的下一层极的数据;
  • 列表(list)类型:该种数据类型的特点是包含有序的下一层极的数据。

基于这一背景,ApiTestEngine在实现JSON的字段提取器(extractor)时,就采用了点(.)的运算符。

例如,假如HTTP接口响应的headers和body为如下内容:

response headers:

1
2
3
4
{
"Content-Type": "application/json",
"Content-Length": 69
}

response body:

1
2
3
4
5
6
7
8
9
10
11
{
"success": false,
"person": {
"name": {
"first_name": "Leo",
"last_name": "Lee",
},
"age": 29,
"cities": ["Guangzhou", "Shenzhen"]
}
}

那么对应的字段提取方式就为:

1
2
3
4
5
6
7
8
9
"headers.content-type" => "application/json"
"headers.content-length" => 69
"body.success"/"content.success"/"text.success" => false

"content.person.name.first_name" => "Leo"
"content.person.age" => 29
"content.person.cities" => ["Guangzhou", "Shenzhen"]
"content.person.cities.0" => "Guangzhou"
"content.person.cities.1" => "Shenzhen"

可以看出,通过点(.)运算符,我们可以从上往下逐级定位到具体的字段:

  • 当下一级为字典时,通过.key来指定下一级的节点,例如.person,指定了content下的person节点;
  • 当下一级为列表时,通过.index来指定下一级的节点,例如.0,指定了cities下的第一个元素。

定位到具体字段后,我们也就可以方便地提取字段值供后续使用了,作为参数或者进行结果校验均可。

实现XML/HTML的解析器

从点(.)运算符的描述形式上来看,它和XML/HTML的xpath十分类似。既然如此,那我们针对XML/HTML类型的接口,是否可以基于xpath来实现解析器呢?

在大多数情况下的确可以。例如,针对如下HTML页面,当我们要获取标题信息时,我们就可以通过xpath来指定提取字段:body/h1

1
2
3
4
5
6
7
8
<html>
<body>
<h1>订单页面</h1>
<div>
<p>订单号:SA89193</p>
</div>
</body>
</html>

然而,如果我们想获取订单号(SA89193)时,使用xpath就没有办法了(通过body/div/p获取到的是订单号:SA89193,还需进一步地进行处理)。

那除了xpath,我们还能使用什么其它方法从XML/HTML中提取特定字段呢?

由于早些年对LoadRunner比较熟悉,因此我首先想到了LoadRunner的web_reg_save_param函数;在该函数中,我们可以通过指定左右边界(LB & RB)来查找字段,将其提取出来并保存到变量中供后续使用。借鉴这种方式虽然可行,但在描述方式上还是比较复杂,特别是在YAML测试用例的extract中描述的时候。

再一想,这种方式的底层实现不就是正则表达式么。而且我们通过Python脚本解析网页时,采用正则表达式来对目标字段进行匹配和提取,的确也是通用性非常强的方式。

例如,假设我们现在想从https://debugtalk.com首页中提取出座右铭,通过查看网页源代码,我们可以看到座右铭对应的位置。

1
<h2 class="blog-motto">探索一个软件工程师的无限可能</h2>

那么,要提取“探索一个软件工程师的无限可能”字符串时,我们就可以使用正则表达式r"blog-motto\">(.*)</h2>"进行匹配,然后使用regex的group将匹配内容提取出来。

对应的Python脚本实现如下所示。

1
2
3
4
5
6
>>> import re, requests
>>> resp = requests.get("https://debugtalk.com")
>>> content = resp.text
>>> matched = re.search(r"blog-motto\">(.*)</h2>", content)
>>> matched.group(1)
'探索一个软件工程师的无限可能'

思路确定后,实现起来就很快了。

此处省略256字。。。

最终,我在ApiTestEngine中新增实现了一个基于正则表达式的提取器。使用形式与JSON解析保持一致,只需要将之前的点(.)运算符更改为正则表达式即可。

还是前面提取座右铭的例子,我们就可以通过YAML格式来编写测试用例。

1
2
3
4
5
6
7
8
9
- test:
name: demo
request:
url: https://debugtalk.com/
method: GET
extract:
- motto: 'blog-motto\">(.*)</h2>'
validate:
- {"check": "status_code", "expected": 200}

需要说明的是,指定的正则表达式必须满足r".*\(.*\).*"的格式要求,必须并且只能有一个分组(即一对括号)。如果在同一段内容中需要提取多个字段,那就分多次匹配即可。

写在最后

实现了基于正则表达式的提取器后,我们就彻底实现了对任意格式HTTP响应内容的解析,不仅限于XML/HTML类型,对于任意基于HTTP协议的的接口,ApiTestEngine都可以适用了。当然,如果接口响应是JSON类型,我们虽然可以也使用正则表达式提取,但更建议采用原有的点(.)运算符形式,因为描述更清晰。

至此,ApiTestEngine可以说是真正意义上实现了,面向任意类型的HTTP协议接口,只需要编写维护一份YAML用例,即可同时实现接口自动化测试、性能测试、持续集成、线上监控的全测试类型覆盖!

现在看来,ApiTestEngine的名字与其实际功能有些不大匹配了,是该考虑改名了。

约定大于配置:ApiTestEngine实现热加载机制

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

背景描述

在ApiTestEngine中编写测试用例时,我们有时需要定义全局的变量,或者引用外部函数实现一些动态的计算逻辑。当前采用的方式是:

  • 若需定义全局的参数变量,则要在YAML/JSON的config中,使用variables定义变量;
  • 若需引用外部函数,则要在YAML/JSON的config中,使用import_module_items导入指定的Python模块。

虽然这种方式提供了极大的灵活性,但是对于用户来说可能会显得比较复杂。另外一方面,这种方式也会造成大量重复的情况。

例如,对于变量来说,假如我们的项目中存在100个测试场景,而每个场景中都需要将用户账号(test@ijd)作为全局变量来使用,那么在现有模式下,我们只能在这100个YAML/JSON文件的config中都采用如下方式定义一遍:

1
2
3
4
- config:
name: "smoketest for scenario A."
variables:
- username: test@ijd

同样的,对于外部函数来说,假如我们项目的100个测试场景都需要用到生成随机字符串的函数(gen_random_string),那么我们也不得不在这100个YAML/JSON文件的config中都导入一次该函数所在的Python模块(假设相对于工作目录的路径为extra/utils.py)。

1
2
3
4
- config:
name: "smoketest for scenario A."
import_module_items:
- extra.utils

由此可见,当测试场景越来越多以后,要维护好全局变量和外部函数,必定会是一个很大的工作量。

那么,如果既要能引用公共的变量和函数,又要减少重复的定义和导入,那要怎么做呢?

pytest 的 conftest.py

前段时间在接触pytest时,看到pytest支持conftest.py的插件机制,这是一种在测试文件中可以实现模块自动发现和热加载的机制。具体地,只要是在文件目录存在命名为conftest.py的文件,里面定义的hook函数都会在pytest运行过程中被导入,并可被测试用例进行调用。同时,conftest.py存在优先级策略,从测试用例所在目录到系统根目录的整个路径中,越靠近测试用例的conftest.py优先级越高。

其实这也是采用了约定大于配置(convention over configuration)的思想。约定大于配置是一种软件设计范式,旨在减少软件开发人员需做决定的数量,在遵从约定的过程中就不自觉地沿用了最佳工程实践。我个人也是比较喜欢这种方式的,所以在设计ApiTestEngine的时候,也借鉴了一些类似的思想。

受到该启发,我想也可以采用类似的思想,采用自动热加载的机制,解决背景描述中存在的重复定义和引用的问题。

既然是约定大于配置,那么我们首先就得定一个默认的Python模块名,类似于pytest的conftest.py。

这就是debugtalk.py。

debugtalk.py 的命名由来

为啥会采用debugtalk.py这个命名呢?

其实当时在想这个名字的时候也是耗费了很多心思,毕竟是要遵从约定大于配置的思想,因此在设计这个约定的命名时就格外谨慎,但始终没有想到一个既合适又满意的。

在我看来,这个命名应该至少满足如下两个条件:

  • 唯一性强
  • 简单易记

首先,约定的模块名应该具有较强的唯一性和较高的区分度,是用户通常都不会采用的命名;否则,可能就会出现测试用例在运行过程中,热加载时导入预期之外的Python模块。

但也不能仅仅为了具有区分度,就使用一个很长或者毫无意义的字符串作为模块名;毕竟还是要给用户使用的,总不能每次写用例时还要去查看下文档吧;所以命名简单易记便于用户使用也很重要。

也是因为这两个有点互相矛盾的原则,让我在设计命名时很是纠结。最终在拉同事讨论良久而无果的时候,同事说,不如就命名为debugtalk.py得了。

仔细一想,这命名还真符合要求。在唯一性方面,采用debugtalk.py在Google、Bing、Baidu等搜索引擎中采用精确匹配,基本没有无关信息,这样在后续遇到问题时,也容易搜索到已有的解决方案;而在简单易记方面,相信这个命名也不会太复杂。

当然,debugtalk.py只是作为框架默认加载的Python模块名,如果你不喜欢,也可以进行配置修改。

热加载机制实现原理

然后,再来讲解下热加载机制的实现。

其实原理也不复杂,从背景描述可以看出,我们期望实现的需求主要有两点:

  • 自动发现debugtalk.py函数模块,并且具有优先级策略;
  • 将debugtalk.py函数模块中的变量和函数导入到当前框架运行的内存空间。

将这两点与测试用例引擎的实现机制结合起来,ApiTestEngine在运行过程中的热加载机制应该就如下图所示。

这个流程图对热加载机制描述得已经足够清晰了,我再针对其中的几个点进行说明:

1、在初始化测试用例集(testset)的时候,除了将config中variables和import_module_items指定的变量和函数导入外,还会默认导入ate/built_in.py模块。之所以这么做,是因为对于大多数系统可能都会用到一些通用的函数,例如获取当前时间戳(get_timestamp)、生成随机字符串(gen_random_string)等。与其在每个项目中都单独去实现这些函数,不如就将其添加到框架中作为默认支持的函数(相当于框架层面的debugtalk.py),这样大家在项目中就不需要再重复做这些基础性工作了。

2、在ApiTestEngine框架中,存在测试用例(testcase)和测试用例集(testset)两个层面的作用域,两者的界限十分明确。这样设计的目的在于,我们既可以实现用例集层面的变量和函数的定义和导入,也可以保障各个用例之间的独立性,不至于出现作用域相互污染的情况。具体地,作用域在用例集初始化时定义或导入的变量和函数,会存储在用例集层面的作用域;而在运行每条测试用例时,会先继承(deepcopy)用例集层面的作用域,如果存在同名的变量或函数定义,则会对用例集层面的变量和函数进行覆盖,同时用例集层面的变量和函数也并不会被修改。

3、从热加载的顺序可以看出,查找变量或函数的顺序是从测试用例所在目录开始,沿着父路径逐层往上,直到系统的根目录。因此,我们可以利用这个优先级原则来组织我们的用例和依赖的Python函数模块。例如,我们可以将不同模块的测试用例集文件放在不同的文件夹下:针对各个模块独有的依赖函数和变量,可以放置在对应文件夹的debugtalk.py文件中;而整个项目公共的函数和变量,就可以放置到项目文件夹的debugtalk.py中。

文件组织结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
➜  project ✗ tree .
.
├── debugtalk.py
├── module_A
│   ├── __init__.py
│   ├── debugtalk.py
│   ├── testsetA1.yml
│   └── testsetA2.yml
└── module_B
├── __init__.py
├── debugtalk.py
├── testsetB1.yml
└── testsetB2.yml

这其中还有一点需要格外注意。因为我们在框架运行过程中需要将debugtalk.py作为函数模块进行导入,因此我们首先要保障debugtalk.py满足Python模块的要求,也就是在对应的文件夹中要包含__init__.py文件。

如果对热加载机制的实现感兴趣,可直接阅读框架源码,重点只需查看ate/utils.py中的三个函数:

  • search_conf_item(start_path, item_type, item_name)
  • get_imported_module_from_file(file_path)
  • filter_module(module, filter_type)

测试用例编写方式的变化

在新增热加载机制之后,编写测试用例的方式发生一些改变(优化),主要包括三点:

  • 导入Python模块的关键词改名为import_module_items(原名为import_module_functions);
  • 不再需要显式指定导入的Python模块路径,变更为热加载机制自动发现;
  • Python模块中的变量也会被导入,公共变量可放置在Python模块中,而不再必须通过variables定义。

考虑到兼容性问题,框架升级的同时也保留了对原有测试用例编写方式的支持,因此框架升级对已有测试用例的正常运行也不会造成影响。不过,我还是强烈建议大家采用最新的用例编写方式,充分利用热加载机制带来的便利。

写在最后

现在回过头来看ApiTestEngine的演进历程,以及之前写的关于ApiTestEngine设计方面的文章,会发现当初的确是有一些考虑不周全的地方。也许这也是编程的乐趣所在吧,在前行的道路中,总会有新的感悟和新的收获,迭代优化的过程,就仿佛是在打磨一件艺术品。

这种感觉,甚好!

123…8
solomiss

solomiss

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