From 6c9c1241512f138979c3df2571a6c2ecb869765d Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 13 Jun 2017 11:54:29 +0800 Subject: [PATCH 001/354] Initial commit --- LICENSE | 21 +++++++++++++++++++++ README.md | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..fed1ee1ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Leo Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..d013435b5 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# apitest.yml +API tests maintained in YAML format. From c203583ddd9ddb5f1103ec3f1a5a782b1e134198 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 16 Jun 2017 12:12:43 +0800 Subject: [PATCH 002/354] create api server for test --- .gitignore | 9 ++++ test/__init__.py | 2 + test/api_server.py | 48 +++++++++++++++++++++ test/test_apiserver.py | 94 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 .gitignore create mode 100644 test/__init__.py create mode 100644 test/api_server.py create mode 100644 test/test_apiserver.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..58c55ee73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.pyc +__pycache__ +.DS_Store +*/tmp/* +build/* +dist/* +*.egg-info +.python-version +logs/% \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 000000000..91d07df53 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,2 @@ +from gevent import monkey +monkey.patch_all() \ No newline at end of file diff --git a/test/api_server.py b/test/api_server.py new file mode 100644 index 000000000..a99d92f77 --- /dev/null +++ b/test/api_server.py @@ -0,0 +1,48 @@ +import json +from flask import Flask +from flask import request, make_response + +app = Flask(__name__) + +""" storage all users' data +data structure: + users_dict = { + 'uid1': { + 'uid': 'uid1', + 'name': 'name1', + 'password': 'pwd1' + }, + 'uid2': { + 'uid': 'uid2', + 'name': 'name2', + 'password': 'pwd2' + } + } +""" +users_dict = {} + +@app.route('/api/user/clear') +def clear_users(): + users_dict.clear() + return "ok" + +@app.route('/api/user/add', methods=['POST']) +def add_user(): + user = request.get_json() + users_dict[user["uid"]] = user + return "ok" + +@app.route('/api/user/') +def get_user(uid): + user = users_dict.get(uid, {}) + response = make_response(json.dumps(user)) + response.headers["Content-Type"] = "application/json" + return response + +@app.route('/api/user/', methods=['DELETE']) +def delete_user(uid): + user = users_dict.pop(uid, None) + if user: + return "ok" + else: + return "not_existed" diff --git a/test/test_apiserver.py b/test/test_apiserver.py new file mode 100644 index 000000000..18b89c520 --- /dev/null +++ b/test/test_apiserver.py @@ -0,0 +1,94 @@ +import gevent +import gevent.pywsgi +import requests +import unittest +from . import api_server + +class TestApiServer(unittest.TestCase): + """ + Test case class that sets up an HTTP server which can be used within the tests + """ + def setUp(self): + super(TestApiServer, self).setUp() + self._api_server = gevent.pywsgi.WSGIServer(("127.0.0.1", 0), api_server.app, log=None) + gevent.spawn(lambda: self._api_server.serve_forever()) + gevent.sleep(0.01) + self.host = "http://127.0.0.1:%i" % self._api_server.server_port + + def tearDown(self): + super(TestApiServer, self).tearDown() + self._api_server.stop_accepting() + self._api_server.stop() + + def clear_users(self): + url = "%s/api/user/clear" % self.host + resp = requests.get(url) + return resp + + def add_user(self, uid, name, password): + url = "%s/api/user/add" % self.host + data = { + 'uid': uid, + 'name': name, + 'password': password + } + resp = requests.post(url, json=data) + return resp + + def test_clear_users(self): + resp = self.clear_users() + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.text, "ok") + + def test_add_user_not_existed(self): + self.clear_users() + resp = self.add_user(1000, 'leo', '123456') + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.text, "ok") + + url = "%s/api/user/1000" % self.host + resp = requests.get(url) + self.assertEqual(200, resp.status_code) + self.assertNotEqual(resp.json(), {}) + + def test_add_user_existed(self): + self.clear_users() + resp = self.add_user(1000, 'leo', '123456') + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.text, "ok") + + self.add_user(1000, 'leo2', '123456') + url = "%s/api/user/1000" % self.host + resp = requests.get(url) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['name'], 'leo2') + + def test_get_user_not_existed(self): + self.clear_users() + url = "%s/api/user/1000" % self.host + resp = requests.get(url) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json(), {}) + + def test_get_user_existed(self): + self.clear_users() + self.add_user(1000, 'leo', '123456') + url = "%s/api/user/1000" % self.host + resp = requests.get(url) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['name'], 'leo') + + def test_delete_user_not_existed(self): + self.clear_users() + url = "%s/api/user/1000" % self.host + resp = requests.delete(url) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.text, "not_existed") + + def test_delete_user_existed(self): + self.clear_users() + resp = self.add_user(1000, 'leo', '123456') + url = "%s/api/user/1000" % self.host + resp = requests.delete(url) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.text, "ok") From 0948d6eea12a76af655768ee5c4565ae2cb58872 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 19 Jun 2017 00:15:13 +0800 Subject: [PATCH 003/354] add README and docs --- README.md | 20 ++++++- docs/background.md | 44 ++++++++++++++ docs/features-intro.md | 127 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 docs/background.md create mode 100644 docs/features-intro.md diff --git a/README.md b/README.md index d013435b5..4ae456974 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ -# apitest.yml -API tests maintained in YAML format. +# ApiTestEngine + +## 核心特性 + +- 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 +- 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML` +- 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 +- 接口测试用例具有可复用性,便于创建复杂测试场景 +- 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 +- 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等 +- 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) +- 具有可扩展性,便于扩展实现Web平台化 + +## 阅读更多 + +- [《背景介绍》](docs/background.md) +- [《特性拆解介绍》](docs/features-intro.md) +- [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) diff --git a/docs/background.md b/docs/background.md new file mode 100644 index 000000000..f2659b703 --- /dev/null +++ b/docs/background.md @@ -0,0 +1,44 @@ +## 背景 + +当前市面上存在的接口测试工具已经非常多,常见的如`Postman`、`JMeter`、`RobotFramework`等,相信大多数测试人员都有使用过,至少从接触到的大多数简历的描述上看是这样的。除了这些成熟的工具,也有很多有一定技术能力的测试(开发)人员自行开发了一些接口测试框架,质量也是参差不齐。 + +但是,当我打算在项目组中推行接口自动化测试时,搜罗了一圈,也没有找到一款特别满意的工具或框架,总是与理想中的构想存在一定的差距。 + +那么理想中的接口自动化测试框架应该是怎样的呢? + +测试工具(框架)脱离业务使用场景都是耍流氓!所以我们不妨先来看下日常工作中的一些常见场景。 + +- 测试或开发人员在定位问题的时候,想调用某个接口查看其是否响应正常; +- 测试人员在手工测试某个功能点的时候,需要一个订单号,而这个订单号可以通过顺序调用多个接口实现下单流程; +- 测试人员在开始版本功能测试之前,可以先检测下系统的所有接口是否工作正常,确保接口正常后再开始手工测试; +- 开发人员在提交代码前需要检测下新代码是否对系统的已有接口产生影响; +- 项目组需要每天定时检测下测试环境所有接口的工作情况,确保当天的提交代码没有对主干分支的代码造成破坏; +- 项目组需要定时(30分钟)检测下生产环境所有接口的工作情况,以便及时发现生产环境服务不可用的情况; +- 项目组需要不定期对核心业务场景进行性能测试,期望能减少人力投入,直接复用接口测试中的工作成果。 + +可以看到,以上罗列的场景大家应该都很熟悉,这都是我们在日常工作中经常需要去做的事情。但是在没有一款合适工具的情况下,效率往往十分低下,或者就是某些重要工作压根就没有开展,例如接口回归测试、线上接口监控等。 + +先说下最简单的手工调用接口测试。可能有人会说,`Postman`就可以满足需求啊。的确,`Postman`作为一款通用的接口测试工具,它可以构造接口请求,查看接口响应,从这个层面上来说,它是满足了接口测试的功能需求。但是在具体的项目中,使用`Postman`并不是那么高效。 + +不妨举个最常见的例子。 + +> 某个接口的请求参数非常多,并且接口请求要求有`MD5`签名校验;签名的方式为在Headers中包含一个`sign`参数,该参数值通过对`URL`、`Method`、`Body`的拼接字符串进行`MD5`计算后得到。 + +回想下我们要对这个接口进行测试时是怎么做的。首先,我们需要先参照接口文档的描述,手工填写完所有接口参数;然后,按照签名校验方式,对所有参数值进行拼接得到一个字符串,在另一个MD5计算工具计算得到其MD5值,将签名值填入`sign`参数;最后,才是发起接口请求,查看接口响应,并人工检测响应是否正常。最坑爹的是,我们每次需要调用这个接口的时候,以上工作就得重新来一遍。这样的实际结果是,面对参数较多或者需要签名验证的接口时,测试人员可能会选择忽略不进行接口测试。 + +除了单个接口的调用,很多时候我们也需要组合多个接口进行调用。例如测试人员在测试物流系统时,经常需要一个特定组合条件下生成的订单号。而由于订单号关联的业务较多,很难直接在数据库中生成,因此当前业务测试人员普遍采取的做法,就是每次需要订单号时模拟下单流程,顺序调用多个相应的接口来生成需要的订单号。可以想象,在手工调用单个接口都如此麻烦的情况下,每次都要手工调用多个接口会有多么的费时费力。 + +再说下接口自动化调用测试。这一块儿大多接口测试框架都支持,普遍的做法就是通过代码编写接口测试用例,或者采用数据驱动的方式,然后在支持命令行(CLI)调用的情况下,就可以结合`Jenkins`或者`crontab`实现持续集成,或者定时接口监控的功能。 + +思路是没有问题的,问题在于实际项目中的推动落实情况。要说自动化测试用例最靠谱的维护方式,还是直接通过代码编写测试用例,可靠且不失灵活性,这也是很多经历过惨痛教训的老手的感悟,甚至网络上还出现了一些反测试框架的言论。但问题在于项目中的测试人员并不是都会写代码,也不是对其强制要求就能马上学会的。这种情况下,要想在具体项目中推动接口自动化测试就很难,就算我可以帮忙写一部分,但是很多时候接口测试用例也是要结合业务逻辑场景的,我也的确是没法在这方面投入太多时间,毕竟对接的项目实在太多。所以也是基于这类原因,很多测试框架提倡采用数据驱动的方式,将业务测试用例和执行代码分离。不过由于很多时候业务场景比较复杂,大多数框架测试用例模板引擎的表达能力不足,很难采用简洁的方式对测试场景进行描述,从而也没法很好地得到推广使用。 + +可以列举的问题还有很多,这些也的确都是在互联网企业的日常测试工作中真实存在的痛点。 + +基于以上背景,我产生了开发[`ApiTestEngine`][ApiTestEngine]的想法。 + +对于[`ApiTestEngine`][ApiTestEngine]的定位,与其说它是一个工具或框架,它更多的应该是一套接口自动化测试的最佳工程实践,而`简洁优雅实用`应该是它最核心的特点。 + +当然,每位工程师对`最佳工程实践`的理念或多或少都会存在一些差异,也希望大家能多多交流,在思维的碰撞中共同进步。 + + +[ApiTestEngine]: https://github.com/debugtalk/ApiTestEngine \ No newline at end of file diff --git a/docs/features-intro.md b/docs/features-intro.md new file mode 100644 index 000000000..11b96b4d6 --- /dev/null +++ b/docs/features-intro.md @@ -0,0 +1,127 @@ +## 特性拆解介绍 + +> 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 + +个人偏好,编程语言选择Python。而采用Python实现HTTP请求,最好的方式就是采用[`Requests`][Requests]库了,简洁优雅,功能强大。 + +> 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML` + +要实现测试用例与代码的分离,最好的做法就是做一个测试用例加载引擎和一个测试用例执行引擎,这也是之前在做[`AppiumBooster`][AppiumBooster]框架的时候总结出来的最优雅的实现方式。当然,这里需要事先对测试用例制定一个标准的数据结构规范,作为测试用例加载引擎和测试用例执行引擎的桥梁。 + +需要说明的是,测试用例数据结构必须包含接口测试用例完备的信息要素,包括接口请求的信息内容(URL、Headers、Method等参数),以及预期的接口请求响应结果(StatusCode、ResponseHeaders、ResponseContent)。 + +这样做的好处在于,不管测试用例采用什么形式进行描述([`YAML`][YAML]、JSON、CSV、Excel、XML等),也不管测试用例是否采用了业务分层的组织思想,只要在测试用例加载引擎中实现对应的转换器,都可以将业务测试用例转换为标准的测试用例数据结构。而对于测试用例执行引擎而言,它无需关注测试用例的具体描述形式,只需要从标准的测试用例数据结构中获取到测试用例信息要素,包括接口请求信息和预期接口响应信息,然后构造并发起HTTP请求,再将HTTP请求的响应结果与预期结果进行对比判断即可。 + +至于为什么明确说明支持[`YAML`][YAML],这是因为个人认为这是最佳的测试用例描述方式,表达简洁不累赘,同时也能包含非常丰富的信息。当然,这只是个人喜好,如果喜欢采用别的方式,只需要扩展实现对应的转换器即可。 + +> 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 + +测试用例与框架代码分离以后,对业务逻辑测试场景的描述重任就落在测试用例上了。比如我们选择采用[`YAML`][YAML]来描述测试用例,那么我们就应该能在[`YAML`][YAML]中描述各种复杂的业务场景。 + +那么怎么理解这个“表现力”呢? + +简单的参数值传参应该都容易理解,我们举几个相对复杂但又比较常见的例子。 + +- 接口请求参数中要包含当前的时间戳; +- 接口请求参数中要包含一个16位的随机字符串; +- 接口请求参数中包含签名校验,需要对多个请求参数进行拼接后取md5值; +- 接口响应头(Headers)中要包含一个`X-ATE-V`头域,并且需要判断该值是否大于100; +- 接口响应结果中包含一个字符串,需要校验字符串中是否包含10位长度的订单号; +- 接口响应结果为一个多层嵌套的json结构体,需要判断某一层的某一个元素值是否为True。 + +可以看出,以上几个例子都是没法直接在测试用例里面描述参数值的。如果是采用Python脚本来编写测试用例还好解决,只需要通过Python函数实现即可。但是现在测试用例和框架代码分离了,我们没法在[`YAML`][YAML]里面执行Python函数,这该怎么办呢? + +答案就是,定义函数转义符,实现自定义模板。 + +这种做法其实也不难理解,也算是模板语言通用的方式。例如,我们将`${}`定义为转义符,那么在`{}`内的内容就不再当做是普通的字符串,而应该转义为变量值,或者执行函数得到实际结果。当然,这个需要我们在测试用例执行引擎进行适配实现,最简单方式就是提取出`${}`中的字符串,通过`eval`计算得到表达式的值。如果要实现更复杂的功能,我们也可以将接口测试中常用的一些功能封装为一套关键字,然后在编写测试用例的时候使用这些关键字。 + +> 接口测试用例具有可复用性,便于创建复杂测试场景 + +很多情况下,系统的接口都是有业务逻辑关联的。例如,要请求调用登录接口,需要先请求获取验证码的接口,然后在登录请求中带上获取到的验证码;而要请求数据查询的接口,又要在请求参数中包含登录接口返回的session值。这个时候,我们如果针对每一个要测的业务逻辑,都单独描述要请求的接口,那么就会造成大量的重复描述,测试用例的维护也十分臃肿。 + +比较好的做法是,将每一个接口调用单独封装为一条测试用例,然后在描述业务测试场景时,选择对应的接口,按照顺序拼接为业务场景测试用例,就像搭积木一般。如果你之前读过[`AppiumBooster`][AppiumBooster]的介绍,应该还会联想到,我们可以将常用的功能组成模块用例集,然后就可以在更高的层面对模块用例集进行组装,实现更复杂的测试场景。 + +不过,这里有一个非常关键的问题需要解决,就是如何在接口测试用例之前传参的问题。其实实现起来也不复杂,我们可以在接口请求响应结果中指定一个变量名,然后将接口返回关键值提取出来后赋值给那个变量;然后在其它接口请求参数中,传入这个`${变量名}`即可。 + +> 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 + +通过背景中的例子可以看出,需要使用接口测试工具的场景很多,除了定时地对所有接口进行自动化测试检测外,很多时候在手工测试的时候也需要采用接口测试工具进行辅助,也就是`半手工+半自动化`的模式。 + +而业务测试人员在使用测试工具的时候,遇到的最大问题在于除了需要关注业务功能本身,还需要花费很多时间去处理技术实现细节上的东西,例如签名校验这类情况,而且往往后者在重复操作中占用的时间更多。 + +这个问题的确是没法避免的,毕竟不同系统的接口千差万别,不可能存在一款工具可以自动处理所有情况。但是我们可以尝试将接口的技术细节实现和业务参数进行拆分,让业务测试人员只需要关注业务参数部分。 + +具体地,我们可以针对每一个接口配置一个模板,将其中与业务功能无关的参数以及技术细节封装起来,例如签名校验、时间戳、随机值等,而与业务功能相关的参数配置为可传参的模式。 + +这样做的好处在于,与业务功能无关的参数以及技术细节我们只需要封装配置一次,而且这个工作可以由开发人员或者测试开发人员来实现,减轻业务测试人员的压力;接口模板配置好后,测试人员只需要关注与业务相关的参数即可,结合业务测试用例,就可以在接口模板的基础上很方便地配置生成多个接口测试用例。 + +> 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等 + +测试结果统计报告,应该遵循简洁而不简单的原则。“简洁”,是因为大多数时候我们只需要在最短的时间内判断所有接口是否运行正常即可。而“不简单”,是因为当存在执行失败的测试用例时,我们期望能获得接口测试时尽可能详细的数据,包括测试时间、请求参数、响应内容、接口响应耗时等。 + +之前在读`locust`源码时,其对[`HTTP`客户端](https://github.com/locustio/locust/blob/master/locust/clients.py +)的封装方式给我留下了深刻的印象。它采用的做法是,继承`requests.Session`类,在子类`HttpSession`中重写覆盖了`request`方法,然后在`request`方法中对`requests.Session.request`进行了一层封装。 + +```python +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`][Requests]库的强大功能,同时也能将请求的响应时间、响应体大小等原始性能数据进行保存,实现可谓十分优雅。 + +受到该处启发,要保存接口的详细请求响应数据也可采用同样的方式。例如,要保存`Response`的`Headers`、`Body`只需要增加如下两行代码: + +```python +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]库做接口测试时,请求客户端其实也是`requests.Session`类的实例,只是我们通常用的是`requests`的简化用法。 + +以下两种用法是等价的。 + +```python +resp = requests.get('http://debugtalk.com') + +# 等价于 +client = requests.Session() +resp = client.get('http://debugtalk.com') +``` + +有了这一层关系以后,要在接口自动化测试和性能测试之间切换就很容易了。在接口测试框架内,可以通过如下方式初始化`HTTP`客户端。 + +```python +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平台功能。 + + +[AppiumBooster]: https://github.com/debugtalk/AppiumBooster +[Requests]: http://docs.python-requests.org/en/master/ +[YAML]: http://pyyaml.org/ \ No newline at end of file From 7efac3de4374d5002c773466dc80c6de14efdacb Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 19 Jun 2017 16:19:17 +0800 Subject: [PATCH 004/354] update api_server and its tests --- test/api_server.py | 100 +++++++++++++++++++++++++++++++------ test/test_apiserver.py | 111 ++++++++++++++++++++++++++--------------- 2 files changed, 158 insertions(+), 53 deletions(-) diff --git a/test/api_server.py b/test/api_server.py index a99d92f77..c980c5c1e 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -8,12 +8,10 @@ data structure: users_dict = { 'uid1': { - 'uid': 'uid1', 'name': 'name1', 'password': 'pwd1' }, 'uid2': { - 'uid': 'uid2', 'name': 'name2', 'password': 'pwd2' } @@ -21,28 +19,102 @@ """ users_dict = {} -@app.route('/api/user/clear') +@app.route('/api/users') +def get_users(): + users_list = [user for uid, user in users_dict.items()] + users = { + 'success': True, + 'count': len(users_list), + 'items': users_list + } + response = make_response(json.dumps(users)) + response.headers["Content-Type"] = "application/json" + return response + +@app.route('/api/users', methods=['DELETE']) def clear_users(): users_dict.clear() - return "ok" + result = { + 'success': True + } + response = make_response(json.dumps(result)) + response.headers["Content-Type"] = "application/json" + return response -@app.route('/api/user/add', methods=['POST']) -def add_user(): +@app.route('/api/users/', methods=['POST']) +def create_user(uid): user = request.get_json() - users_dict[user["uid"]] = user - return "ok" + if uid not in users_dict: + result = { + 'success': True, + 'msg': "user created successfully." + } + status_code = 201 + users_dict[uid] = user + else: + result = { + 'success': False, + 'msg': "user already existed." + } + status_code = 500 -@app.route('/api/user/') + response = make_response(json.dumps(result), status_code) + response.headers["Content-Type"] = "application/json" + return response + +@app.route('/api/users/') def get_user(uid): user = users_dict.get(uid, {}) - response = make_response(json.dumps(user)) + if user: + result = { + 'success': True, + 'data': user + } + status_code = 200 + else: + result = { + 'success': False, + 'data': user + } + status_code = 404 + + response = make_response(json.dumps(result), status_code) + response.headers["Content-Type"] = "application/json" + return response + +@app.route('/api/users/', methods=['PUT']) +def update_user(uid): + user = users_dict.get(uid, {}) + if user: + user = request.get_json() + success = True + status_code = 200 + else: + success = False + status_code = 404 + + result = { + 'success': success, + 'data': user + } + response = make_response(json.dumps(result), status_code) response.headers["Content-Type"] = "application/json" return response -@app.route('/api/user/', methods=['DELETE']) +@app.route('/api/users/', methods=['DELETE']) def delete_user(uid): - user = users_dict.pop(uid, None) + user = users_dict.pop(uid, {}) if user: - return "ok" + success = True + status_code = 200 else: - return "not_existed" + success = False + status_code = 404 + + result = { + 'success': success, + 'data': user + } + response = make_response(json.dumps(result), status_code) + response.headers["Content-Type"] = "application/json" + return response diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 18b89c520..fc4aa27c3 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -14,6 +14,7 @@ def setUp(self): gevent.spawn(lambda: self._api_server.serve_forever()) gevent.sleep(0.01) self.host = "http://127.0.0.1:%i" % self._api_server.server_port + self.api_client = requests.Session() def tearDown(self): super(TestApiServer, self).tearDown() @@ -21,74 +22,106 @@ def tearDown(self): self._api_server.stop() def clear_users(self): - url = "%s/api/user/clear" % self.host - resp = requests.get(url) - return resp + url = "%s/api/users" % self.host + return self.api_client.delete(url) - def add_user(self, uid, name, password): - url = "%s/api/user/add" % self.host + def get_users(self): + url = "%s/api/users" % self.host + return self.api_client.get(url) + + def create_user(self, uid, name, password): + url = "%s/api/users/%d" % (self.host, uid) + data = { + 'name': name, + 'password': password + } + return self.api_client.post(url, json=data) + + def get_user(self, uid): + url = "%s/api/users/%d" % (self.host, uid) + return self.api_client.get(url) + + def update_user(self, uid, name, password): + url = "%s/api/users/%d" % (self.host, uid) data = { - 'uid': uid, 'name': name, 'password': password } - resp = requests.post(url, json=data) - return resp + return self.api_client.put(url, json=data) + + def delete_user(self, uid): + url = "%s/api/users/%d" % (self.host, uid) + return self.api_client.delete(url) def test_clear_users(self): resp = self.clear_users() self.assertEqual(200, resp.status_code) - self.assertEqual(resp.text, "ok") + self.assertEqual(True, resp.json()['success']) - def test_add_user_not_existed(self): + def test_create_user_not_existed(self): self.clear_users() - resp = self.add_user(1000, 'leo', '123456') - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.text, "ok") + resp = self.create_user(1000, 'user1', '123456') + self.assertEqual(201, resp.status_code) - url = "%s/api/user/1000" % self.host - resp = requests.get(url) + def test_create_user_existed(self): + self.clear_users() + resp = self.create_user(1000, 'user1', '123456') + resp = self.create_user(1000, 'user1', '123456') + self.assertEqual(500, resp.status_code) + + def test_get_users_empty(self): + self.clear_users() + resp = self.get_users() self.assertEqual(200, resp.status_code) - self.assertNotEqual(resp.json(), {}) + self.assertEqual(resp.json()['count'], 0) - def test_add_user_existed(self): + def test_get_users_not_empty(self): self.clear_users() - resp = self.add_user(1000, 'leo', '123456') + resp = self.create_user(1000, 'user1', '123456') + resp = self.get_users() self.assertEqual(200, resp.status_code) - self.assertEqual(resp.text, "ok") + self.assertEqual(resp.json()['count'], 1) - self.add_user(1000, 'leo2', '123456') - url = "%s/api/user/1000" % self.host - resp = requests.get(url) + resp = self.create_user(1001, 'user2', '123456') + resp = self.get_users() self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['name'], 'leo2') + self.assertEqual(resp.json()['count'], 2) def test_get_user_not_existed(self): self.clear_users() - url = "%s/api/user/1000" % self.host - resp = requests.get(url) - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json(), {}) + resp = self.get_user(1000) + self.assertEqual(404, resp.status_code) + self.assertEqual(resp.json()['success'], False) def test_get_user_existed(self): self.clear_users() - self.add_user(1000, 'leo', '123456') - url = "%s/api/user/1000" % self.host - resp = requests.get(url) + self.create_user(1000, 'user1', '123456') + resp = self.get_user(1000) self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['name'], 'leo') + self.assertEqual(resp.json()['success'], True) - def test_delete_user_not_existed(self): + def test_update_user_not_existed(self): self.clear_users() - url = "%s/api/user/1000" % self.host - resp = requests.delete(url) + resp = self.update_user(1000, 'user1', '123456') + self.assertEqual(404, resp.status_code) + self.assertEqual(resp.json()['success'], False) + + def test_update_user_existed(self): + self.clear_users() + self.create_user(1000, 'user1', '123456') + resp = self.update_user(1000, 'user2', '123456') self.assertEqual(200, resp.status_code) - self.assertEqual(resp.text, "not_existed") + self.assertEqual(resp.json()['data']['name'], 'user2') + + def test_delete_user_not_existed(self): + self.clear_users() + resp = self.delete_user(1000) + self.assertEqual(404, resp.status_code) + self.assertEqual(resp.json()['success'], False) def test_delete_user_existed(self): self.clear_users() - resp = self.add_user(1000, 'leo', '123456') - url = "%s/api/user/1000" % self.host - resp = requests.delete(url) + self.create_user(1000, 'leo', '123456') + resp = self.delete_user(1000) self.assertEqual(200, resp.status_code) - self.assertEqual(resp.text, "ok") + self.assertEqual(resp.json()['success'], True) From a95ff218c06567b9e50405b126f8c87e1df1b75f Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 13:25:59 +0800 Subject: [PATCH 005/354] run api_server in multiprocessing.Thread other than gevent.pywsgi.WSGIServer --- test/__init__.py | 2 -- test/test_apiserver.py | 30 +++++++++++------------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/test/__init__.py b/test/__init__.py index 91d07df53..e69de29bb 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,2 +0,0 @@ -from gevent import monkey -monkey.patch_all() \ No newline at end of file diff --git a/test/test_apiserver.py b/test/test_apiserver.py index fc4aa27c3..14320ab01 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -1,7 +1,7 @@ -import gevent -import gevent.pywsgi -import requests +import multiprocessing +import time import unittest +import requests from . import api_server class TestApiServer(unittest.TestCase): @@ -10,16 +10,17 @@ class TestApiServer(unittest.TestCase): """ def setUp(self): super(TestApiServer, self).setUp() - self._api_server = gevent.pywsgi.WSGIServer(("127.0.0.1", 0), api_server.app, log=None) - gevent.spawn(lambda: self._api_server.serve_forever()) - gevent.sleep(0.01) - self.host = "http://127.0.0.1:%i" % self._api_server.server_port + self.api_server_process = multiprocessing.Process( + target=api_server.app.run + ) + self.api_server_process.start() + time.sleep(0.1) + self.host = "http://127.0.0.1:5000" self.api_client = requests.Session() def tearDown(self): super(TestApiServer, self).tearDown() - self._api_server.stop_accepting() - self._api_server.stop() + self.api_server_process.terminate() def clear_users(self): url = "%s/api/users" % self.host @@ -59,24 +60,21 @@ def test_clear_users(self): self.assertEqual(True, resp.json()['success']) def test_create_user_not_existed(self): - self.clear_users() resp = self.create_user(1000, 'user1', '123456') self.assertEqual(201, resp.status_code) + self.assertEqual(True, resp.json()['success']) def test_create_user_existed(self): - self.clear_users() resp = self.create_user(1000, 'user1', '123456') resp = self.create_user(1000, 'user1', '123456') self.assertEqual(500, resp.status_code) def test_get_users_empty(self): - self.clear_users() resp = self.get_users() self.assertEqual(200, resp.status_code) self.assertEqual(resp.json()['count'], 0) def test_get_users_not_empty(self): - self.clear_users() resp = self.create_user(1000, 'user1', '123456') resp = self.get_users() self.assertEqual(200, resp.status_code) @@ -88,39 +86,33 @@ def test_get_users_not_empty(self): self.assertEqual(resp.json()['count'], 2) def test_get_user_not_existed(self): - self.clear_users() resp = self.get_user(1000) self.assertEqual(404, resp.status_code) self.assertEqual(resp.json()['success'], False) def test_get_user_existed(self): - self.clear_users() self.create_user(1000, 'user1', '123456') resp = self.get_user(1000) self.assertEqual(200, resp.status_code) self.assertEqual(resp.json()['success'], True) def test_update_user_not_existed(self): - self.clear_users() resp = self.update_user(1000, 'user1', '123456') self.assertEqual(404, resp.status_code) self.assertEqual(resp.json()['success'], False) def test_update_user_existed(self): - self.clear_users() self.create_user(1000, 'user1', '123456') resp = self.update_user(1000, 'user2', '123456') self.assertEqual(200, resp.status_code) self.assertEqual(resp.json()['data']['name'], 'user2') def test_delete_user_not_existed(self): - self.clear_users() resp = self.delete_user(1000) self.assertEqual(404, resp.status_code) self.assertEqual(resp.json()['success'], False) def test_delete_user_existed(self): - self.clear_users() self.create_user(1000, 'leo', '123456') resp = self.delete_user(1000) self.assertEqual(200, resp.status_code) From 502f2e4ef71214410e160e0cf15d949f12cdac04 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 14:22:52 +0800 Subject: [PATCH 006/354] move api_server start and terminate to class method in order to start api_server only once --- test/test_apiserver.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 14320ab01..9aef84ac7 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -8,19 +8,26 @@ class TestApiServer(unittest.TestCase): """ Test case class that sets up an HTTP server which can be used within the tests """ - def setUp(self): - super(TestApiServer, self).setUp() - self.api_server_process = multiprocessing.Process( + @classmethod + def setUpClass(cls): + cls.api_server_process = multiprocessing.Process( target=api_server.app.run ) - self.api_server_process.start() + cls.api_server_process.start() time.sleep(0.1) + + @classmethod + def tearDownClass(cls): + cls.api_server_process.terminate() + + def setUp(self): + super(TestApiServer, self).setUp() self.host = "http://127.0.0.1:5000" self.api_client = requests.Session() + self.clear_users() def tearDown(self): super(TestApiServer, self).tearDown() - self.api_server_process.terminate() def clear_users(self): url = "%s/api/users" % self.host From 0fd8f95e34f3cbfffc6cb97435d7dca186c9462c Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 19:43:44 +0800 Subject: [PATCH 007/354] utils: testcases loader --- ate/__init__.py | 0 ate/exception.py | 16 ++++++++++++++++ ate/utils.py | 22 ++++++++++++++++++++++ test/data/demo.json | 43 +++++++++++++++++++++++++++++++++++++++++++ test/data/demo.yml | 29 +++++++++++++++++++++++++++++ test/test_utils.py | 31 +++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 ate/__init__.py create mode 100644 ate/exception.py create mode 100644 ate/utils.py create mode 100644 test/data/demo.json create mode 100644 test/data/demo.yml create mode 100644 test/test_utils.py diff --git a/ate/__init__.py b/ate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ate/exception.py b/ate/exception.py new file mode 100644 index 000000000..6c7473774 --- /dev/null +++ b/ate/exception.py @@ -0,0 +1,16 @@ +#coding: utf-8 +from termcolor import colored + +class MyBaseError(BaseException): + def __init__(self, msg): + self.msg = msg + self.color_msg = colored(msg, 'red', attrs=['bold']) + + def __repr__(self): + return self.msg + + def __str__(self): + return self.color_msg + +class ParamsError(MyBaseError): + pass diff --git a/ate/utils.py b/ate/utils.py new file mode 100644 index 000000000..e5ebb75c6 --- /dev/null +++ b/ate/utils.py @@ -0,0 +1,22 @@ +import json +import yaml +import os.path +from ate.exception import ParamsError + +def load_yaml_file(yaml_file): + with open(yaml_file, 'r+') as stream: + return yaml.load(stream) + +def load_json_file(json_file): + with open(json_file) as data_file: + return json.load(data_file) + +def load_testcases(testcase_file_path): + file_suffix = os.path.splitext(testcase_file_path)[1] + if file_suffix == '.json': + return load_json_file(testcase_file_path) + elif file_suffix in ['.yaml', '.yml']: + return load_yaml_file(testcase_file_path) + else: + # '' or other suffix + raise ParamsError("Bad testcase file name!") diff --git a/test/data/demo.json b/test/data/demo.json new file mode 100644 index 000000000..f011c9edb --- /dev/null +++ b/test/data/demo.json @@ -0,0 +1,43 @@ +[ + { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "cookies": {}, + "json": { + "name": "user1", + "password": "123456" + } + }, + "response": { + "status_code": 201, + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "name": "create user which existed", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "response": { + "status_code": 500, + "headers": { + "Content-Type": "application/json" + } + } + } +] \ No newline at end of file diff --git a/test/data/demo.yml b/test/data/demo.yml new file mode 100644 index 000000000..242663a4f --- /dev/null +++ b/test/data/demo.yml @@ -0,0 +1,29 @@ +- + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + json: + name: user1 + password: 123456 + response: + status_code: 201 + headers: + Content-Type: application/json + +- + name: create user which existed + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + json: + name: user1 + password: 123456 + response: + status_code: 500 + headers: + Content-Type: application/json \ No newline at end of file diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 000000000..e87007e5e --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,31 @@ +import os +import unittest +from ate import utils +from ate import exception + +class TestUtils(unittest.TestCase): + + def test_load_testcases_bad_filepath(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo') + with self.assertRaises(exception.ParamsError): + utils.load_testcases(testcase_file_path) + + def test_load_json_testcases(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') + testcases = utils.load_testcases(testcase_file_path) + self.assertEqual(len(testcases), 2) + self.assertIn('name', testcases[0]) + self.assertIn('request', testcases[0]) + self.assertIn('response', testcases[0]) + self.assertIn('url', testcases[0]['request']) + self.assertIn('method', testcases[0]['request']) + + def test_load_yaml_testcases(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.yml') + testcases = utils.load_testcases(testcase_file_path) + self.assertEqual(len(testcases), 2) + self.assertIn('name', testcases[0]) + self.assertIn('request', testcases[0]) + self.assertIn('response', testcases[0]) + self.assertIn('url', testcases[0]['request']) + self.assertIn('method', testcases[0]['request']) From b45ef317cd9f65362404baebd5fd16f46f1cb718 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 19:47:18 +0800 Subject: [PATCH 008/354] add requirements.txt --- README.md | 6 ++++++ requirements.txt | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 4ae456974..ffed3d287 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ - 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) - 具有可扩展性,便于扩展实现Web平台化 +## Install + +```bash +$ pip install -r requirements.txt +``` + ## 阅读更多 - [《背景介绍》](docs/background.md) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..6fc40297c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests +termcolor +flask \ No newline at end of file From 5383b1deead1658774721e836feff90a97d34245 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:04:10 +0800 Subject: [PATCH 009/354] create ApiServerUnittest for public use --- test/base.py | 20 ++++++++++++++++++++ test/test_apiserver.py | 22 ++-------------------- 2 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 test/base.py diff --git a/test/base.py b/test/base.py new file mode 100644 index 000000000..1813a89be --- /dev/null +++ b/test/base.py @@ -0,0 +1,20 @@ +import multiprocessing +import time +import unittest +from . import api_server + +class ApiServerUnittest(unittest.TestCase): + """ + Test case class that sets up an HTTP server which can be used within the tests + """ + @classmethod + def setUpClass(cls): + cls.api_server_process = multiprocessing.Process( + target=api_server.app.run + ) + cls.api_server_process.start() + time.sleep(0.1) + + @classmethod + def tearDownClass(cls): + cls.api_server_process.terminate() diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 9aef84ac7..73ae3bc37 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -1,25 +1,7 @@ -import multiprocessing -import time -import unittest import requests -from . import api_server - -class TestApiServer(unittest.TestCase): - """ - Test case class that sets up an HTTP server which can be used within the tests - """ - @classmethod - def setUpClass(cls): - cls.api_server_process = multiprocessing.Process( - target=api_server.app.run - ) - cls.api_server_process.start() - time.sleep(0.1) - - @classmethod - def tearDownClass(cls): - cls.api_server_process.terminate() +from .base import ApiServerUnittest +class TestApiServer(ApiServerUnittest): def setUp(self): super(TestApiServer, self).setUp() self.host = "http://127.0.0.1:5000" From fdc7824de5873c3b27d2c9e7de45e01d147351dc Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:06:36 +0800 Subject: [PATCH 010/354] add parse_response_object --- ate/utils.py | 7 +++++++ test/test_utils.py | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index e5ebb75c6..e10a2586c 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -20,3 +20,10 @@ def load_testcases(testcase_file_path): else: # '' or other suffix raise ParamsError("Bad testcase file name!") + +def parse_response_object(resp_obj): + return { + 'status_code': resp_obj.status_code, + 'headers': resp_obj.headers, + 'content': resp_obj.content + } diff --git a/test/test_utils.py b/test/test_utils.py index e87007e5e..0a87197b8 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,9 +1,10 @@ import os -import unittest +import requests from ate import utils from ate import exception +from .base import ApiServerUnittest -class TestUtils(unittest.TestCase): +class TestUtils(ApiServerUnittest): def test_load_testcases_bad_filepath(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo') @@ -29,3 +30,11 @@ def test_load_yaml_testcases(self): self.assertIn('response', testcases[0]) self.assertIn('url', testcases[0]['request']) self.assertIn('method', testcases[0]['request']) + + def test_parse_response_object(self): + url = "http://127.0.0.1:5000/api/users" + resp_obj = requests.get(url) + parse_result = utils.parse_response_object(resp_obj) + self.assertIn('status_code', parse_result) + self.assertIn('headers', parse_result) + self.assertIn('content', parse_result) From 5834594d6c061e8f6c2e7f103bebfd395927760a Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:24:38 +0800 Subject: [PATCH 011/354] parse_response_object: load response content as json format if possible --- ate/utils.py | 7 ++++++- test/api_server.py | 4 ++++ test/test_utils.py | 16 +++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index e10a2586c..f9519fc34 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -22,8 +22,13 @@ def load_testcases(testcase_file_path): raise ParamsError("Bad testcase file name!") def parse_response_object(resp_obj): + try: + resp_content = resp_obj.json() + except json.decoder.JSONDecodeError: + resp_content = resp_obj.text + return { 'status_code': resp_obj.status_code, 'headers': resp_obj.headers, - 'content': resp_obj.content + 'content': resp_content } diff --git a/test/api_server.py b/test/api_server.py index c980c5c1e..c5c041871 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -19,6 +19,10 @@ """ users_dict = {} +@app.route('/') +def index(): + return "Hello World!" + @app.route('/api/users') def get_users(): users_list = [user for uid, user in users_dict.items()] diff --git a/test/test_utils.py b/test/test_utils.py index 0a87197b8..871302d79 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -31,10 +31,24 @@ def test_load_yaml_testcases(self): self.assertIn('url', testcases[0]['request']) self.assertIn('method', testcases[0]['request']) - def test_parse_response_object(self): + def test_parse_response_object_json(self): url = "http://127.0.0.1:5000/api/users" resp_obj = requests.get(url) parse_result = utils.parse_response_object(resp_obj) self.assertIn('status_code', parse_result) self.assertIn('headers', parse_result) self.assertIn('content', parse_result) + self.assertIn('Content-Type', parse_result['headers']) + self.assertIn('Content-Length', parse_result['headers']) + self.assertIn('success', parse_result['content']) + + def test_parse_response_object_text(self): + url = "http://127.0.0.1:5000/" + resp_obj = requests.get(url) + parse_result = utils.parse_response_object(resp_obj) + self.assertIn('status_code', parse_result) + self.assertIn('headers', parse_result) + self.assertIn('content', parse_result) + self.assertIn('Content-Type', parse_result['headers']) + self.assertIn('Content-Length', parse_result['headers']) + self.assertTrue(str, type(parse_result['content'])) From 06839ac51c3661569c70305fb59006e533af4db7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:37:21 +0800 Subject: [PATCH 012/354] add travis badge --- .travis.yml | 17 +++++++++++++++++ README.md | 2 ++ 2 files changed, 19 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..bcca02d26 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: python +matrix: + include: + - python: 3.6 + env: TOXENV=py36 + - python: 3.5 + env: TOXENV=py35 + - python: 3.4 + env: TOXENV=py34 + - python: 3.3 + env: TOXENV=py33 + - python: 2.7 + env: TOXENV=py27 +install: + - pip install tox +script: + - tox diff --git a/README.md b/README.md index ffed3d287..c8bb5126a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ApiTestEngine +[![Build Status](https://travis-ci.org/debugtalk/ApiTestEngine.svg?branch=master)](https://travis-ci.org/debugtalk/ApiTestEngine) + ## 核心特性 - 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 From 5f04f1be3abff78b39f7947abaa1c76c54062945 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:42:25 +0800 Subject: [PATCH 013/354] add tox.ini --- tox.ini | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tox.ini diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..cc934750f --- /dev/null +++ b/tox.ini @@ -0,0 +1,10 @@ +[tox] +envlist = py27, py33, py34, py35, py36 + +[testenv] +deps = + requests + termcolor + flask +commands = + python -m unittest discover From e5d6ac73712c44e0f6b50f2a8a8b60fc9e7170c6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:49:04 +0800 Subject: [PATCH 014/354] remove tox --- .travis.yml | 23 +++++++++-------------- tox.ini | 10 ---------- 2 files changed, 9 insertions(+), 24 deletions(-) delete mode 100644 tox.ini diff --git a/.travis.yml b/.travis.yml index bcca02d26..02b1e44af 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,12 @@ +sudo: false language: python -matrix: - include: - - python: 3.6 - env: TOXENV=py36 - - python: 3.5 - env: TOXENV=py35 - - python: 3.4 - env: TOXENV=py34 - - python: 3.3 - env: TOXENV=py33 - - python: 2.7 - env: TOXENV=py27 +python: + - 2.7 + - 3.3 + - 3.4 + - 3.5 + - 3.6 install: - - pip install tox + - pip install -r requirements.txt script: - - tox + - python -m unittest discover diff --git a/tox.ini b/tox.ini deleted file mode 100644 index cc934750f..000000000 --- a/tox.ini +++ /dev/null @@ -1,10 +0,0 @@ -[tox] -envlist = py27, py33, py34, py35, py36 - -[testenv] -deps = - requests - termcolor - flask -commands = - python -m unittest discover From 50f120f9d77d9e463a6ee955cc3134848967c5b9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 20:52:28 +0800 Subject: [PATCH 015/354] bugfix: add PyYAML to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6fc40297c..2536011cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests termcolor -flask \ No newline at end of file +flask +PyYAML \ No newline at end of file From f07172c3d23382550cbf13140f8eb7f3a1c7d1ec Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 20 Jun 2017 21:04:00 +0800 Subject: [PATCH 016/354] bugfix: make compatible with python 2.7/3.3/3.4 --- README.md | 4 ++++ ate/utils.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c8bb5126a..63c9f223b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ $ pip install -r requirements.txt ``` +## Supported Python Versions + +Python 2.7, 3.3, 3.4, 3.5, and 3.6. + ## 阅读更多 - [《背景介绍》](docs/background.md) diff --git a/ate/utils.py b/ate/utils.py index f9519fc34..8698f4b33 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -24,7 +24,7 @@ def load_testcases(testcase_file_path): def parse_response_object(resp_obj): try: resp_content = resp_obj.json() - except json.decoder.JSONDecodeError: + except ValueError: resp_content = resp_obj.text return { From d5d73d70a98200774d2055e75e34c80a32836973 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 11:43:21 +0800 Subject: [PATCH 017/354] apiserver: add get_response_with_status_code --- test/api_server.py | 4 ++++ test/test_apiserver.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/test/api_server.py b/test/api_server.py index c5c041871..6ff7589db 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -23,6 +23,10 @@ def index(): return "Hello World!" +@app.route('/status_code/') +def get_response_with_status_code(status_code): + return "Status Code: %d" % status_code, status_code + @app.route('/api/users') def get_users(): users_list = [user for uid, user in users_dict.items()] diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 73ae3bc37..02d6b0838 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -1,4 +1,5 @@ import requests +import random from .base import ApiServerUnittest class TestApiServer(ApiServerUnittest): @@ -106,3 +107,9 @@ def test_delete_user_existed(self): resp = self.delete_user(1000) self.assertEqual(200, resp.status_code) self.assertEqual(resp.json()['success'], True) + + def test_get_response_with_status_code(self): + status_code = random.randint(200, 511) + url = "%s/status_code/%d" % (self.host, status_code) + resp = self.api_client.get(url) + self.assertEqual(status_code, resp.status_code) From 9a18fd326d0d0a64974b85790e2bb61a4a0b7b1e Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 12:17:51 +0800 Subject: [PATCH 018/354] diff http response: status code --- ate/utils.py | 13 +++++++++++++ test/test_utils.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 8698f4b33..c3ea3433e 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -32,3 +32,16 @@ def parse_response_object(resp_obj): 'headers': resp_obj.headers, 'content': resp_content } + +def diff_response(resp_obj, expected_resp_json): + diff_content = {} + resp_info = parse_response_object(resp_obj) + + expected_status_code = expected_resp_json.get('status_code', 200) + if resp_info['status_code'] != int(expected_status_code): + diff_content['status_code'] = { + 'value': resp_info['status_code'], + 'expected': expected_status_code + } + + return diff_content diff --git a/test/test_utils.py b/test/test_utils.py index 871302d79..f5cc4a062 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,4 +1,5 @@ import os +import random import requests from ate import utils from ate import exception @@ -52,3 +53,27 @@ def test_parse_response_object_text(self): self.assertIn('Content-Type', parse_result['headers']) self.assertIn('Content-Length', parse_result['headers']) self.assertTrue(str, type(parse_result['content'])) + + def test_diff_response_status_code_equal(self): + status_code = random.randint(200, 511) + url = "http://127.0.0.1:5000/status_code/%d" % status_code + resp_obj = requests.get(url) + expected_resp_json = { + 'status_code': status_code + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + def test_diff_response_status_code_not_equal(self): + status_code = random.randint(200, 511) + url = "http://127.0.0.1:5000/status_code/%d" % status_code + resp_obj = requests.get(url) + expected_resp_json = { + 'status_code': 512 + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + print('diff_content', diff_content) + self.assertIn('value', diff_content['status_code']) + self.assertIn('expected', diff_content['status_code']) + self.assertEqual(diff_content['status_code']['value'], status_code) + self.assertEqual(diff_content['status_code']['expected'], 512) From 03042639318d41afb072bb3e0e4bca656b44d51f Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 13:19:31 +0800 Subject: [PATCH 019/354] apiserver: add get_response_with_headers --- test/api_server.py | 9 +++++++++ test/test_apiserver.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/test/api_server.py b/test/api_server.py index 6ff7589db..6c01e2ec4 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -27,6 +27,15 @@ def index(): def get_response_with_status_code(status_code): return "Status Code: %d" % status_code, status_code +@app.route('/response_headers', methods=['POST']) +def get_response_with_headers(): + headers_dict = request.get_json() + content = "Response headers: %s" % json.dumps(headers_dict) + response = make_response(content) + for header_key, header_value in headers_dict.items(): + response.headers[header_key] = header_value + return response + @app.route('/api/users') def get_users(): users_list = [user for uid, user in users_dict.items()] diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 02d6b0838..09ef56509 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -113,3 +113,13 @@ def test_get_response_with_status_code(self): url = "%s/status_code/%d" % (self.host, status_code) resp = self.api_client.get(url) self.assertEqual(status_code, resp.status_code) + + def test_get_response_with_headers(self): + headers = { + 'abc': 123, + 'def': 456 + } + url = "%s/response_headers" % self.host + resp = self.api_client.post(url, json=headers) + self.assertIn('abc', resp.headers) + self.assertIn('123', resp.headers['abc']) From 1bd2281be673a2000e7ce7e3dba1208a64ddf489 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 13:39:08 +0800 Subject: [PATCH 020/354] apiserver: get_customized_response, including status code and headers --- test/api_server.py | 19 ++++++++++--------- test/test_apiserver.py | 23 ++++++++++++++--------- test/test_utils.py | 16 ++++++++++++---- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/test/api_server.py b/test/api_server.py index 6c01e2ec4..b65c659d7 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -23,17 +23,18 @@ def index(): return "Hello World!" -@app.route('/status_code/') -def get_response_with_status_code(status_code): - return "Status Code: %d" % status_code, status_code - -@app.route('/response_headers', methods=['POST']) -def get_response_with_headers(): - headers_dict = request.get_json() - content = "Response headers: %s" % json.dumps(headers_dict) - response = make_response(content) +@app.route('/customize-response', methods=['POST']) +def get_customized_response(): + expected_resp_json = request.get_json() + status_code = expected_resp_json.get('status_code', 200) + headers_dict = expected_resp_json.get('headers', {}) + body = expected_resp_json.get('body', "") + content = "Response: %s" % json.dumps(expected_resp_json) + response = make_response(content, status_code) + for header_key, header_value in headers_dict.items(): response.headers[header_key] = header_value + return response @app.route('/api/users') diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 09ef56509..36076abf1 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -108,18 +108,23 @@ def test_delete_user_existed(self): self.assertEqual(200, resp.status_code) self.assertEqual(resp.json()['success'], True) - def test_get_response_with_status_code(self): + def test_get_customized_response_status_code(self): status_code = random.randint(200, 511) - url = "%s/status_code/%d" % (self.host, status_code) - resp = self.api_client.get(url) + url = "%s/customize-response" % self.host + expected_response = { + 'status_code': status_code, + } + resp = self.api_client.post(url, json=expected_response) self.assertEqual(status_code, resp.status_code) - def test_get_response_with_headers(self): - headers = { - 'abc': 123, - 'def': 456 + def test_get_customized_response_headers(self): + expected_response = { + 'headers': { + 'abc': 123, + 'def': 456 + } } - url = "%s/response_headers" % self.host - resp = self.api_client.post(url, json=headers) + url = "%s/customize-response" % self.host + resp = self.api_client.post(url, json=expected_response) self.assertIn('abc', resp.headers) self.assertIn('123', resp.headers['abc']) diff --git a/test/test_utils.py b/test/test_utils.py index f5cc4a062..40f07b277 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -56,8 +56,12 @@ def test_parse_response_object_text(self): def test_diff_response_status_code_equal(self): status_code = random.randint(200, 511) - url = "http://127.0.0.1:5000/status_code/%d" % status_code - resp_obj = requests.get(url) + url = "http://127.0.0.1:5000/customize-response" + response_dict = { + 'status_code': status_code, + } + resp_obj = requests.post(url, json=response_dict) + expected_resp_json = { 'status_code': status_code } @@ -66,8 +70,12 @@ def test_diff_response_status_code_equal(self): def test_diff_response_status_code_not_equal(self): status_code = random.randint(200, 511) - url = "http://127.0.0.1:5000/status_code/%d" % status_code - resp_obj = requests.get(url) + url = "http://127.0.0.1:5000/customize-response" + response_dict = { + 'status_code': status_code, + } + resp_obj = requests.post(url, json=response_dict) + expected_resp_json = { 'status_code': 512 } From 5e03acba5f39e20c11c724d73dac5eb3ec49dea7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 15:56:01 +0800 Subject: [PATCH 021/354] diff http response: headers --- ate/utils.py | 13 +++++++++ test/test_utils.py | 71 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index c3ea3433e..abaafde61 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -44,4 +44,17 @@ def diff_response(resp_obj, expected_resp_json): 'expected': expected_status_code } + expected_headers = expected_resp_json.get('headers', {}) + for header_key, expected_header_value in expected_headers.items(): + header_value = resp_info['headers'].get(header_key, None) + if str(header_value) != str(expected_header_value): + + if 'headers' not in diff_content: + diff_content['headers'] = {} + + diff_content['headers'][header_key] = { + 'value': header_value, + 'expected': str(expected_header_value) + } + return diff_content diff --git a/test/test_utils.py b/test/test_utils.py index 40f07b277..f62d9b2b2 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -56,11 +56,12 @@ def test_parse_response_object_text(self): def test_diff_response_status_code_equal(self): status_code = random.randint(200, 511) - url = "http://127.0.0.1:5000/customize-response" - response_dict = { - 'status_code': status_code, - } - resp_obj = requests.post(url, json=response_dict) + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'status_code': status_code, + } + ) expected_resp_json = { 'status_code': status_code @@ -70,18 +71,66 @@ def test_diff_response_status_code_equal(self): def test_diff_response_status_code_not_equal(self): status_code = random.randint(200, 511) - url = "http://127.0.0.1:5000/customize-response" - response_dict = { - 'status_code': status_code, - } - resp_obj = requests.post(url, json=response_dict) + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'status_code': status_code, + } + ) expected_resp_json = { 'status_code': 512 } diff_content = utils.diff_response(resp_obj, expected_resp_json) - print('diff_content', diff_content) self.assertIn('value', diff_content['status_code']) self.assertIn('expected', diff_content['status_code']) self.assertEqual(diff_content['status_code']['value'], status_code) self.assertEqual(diff_content['status_code']['expected'], 512) + + def test_diff_response_headers_equal(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'abc': 123, + 'def': 456 + } + } + ) + + expected_resp_json = { + 'headers': { + 'abc': 123, + 'def': '456' + } + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + def test_diff_response_headers_not_equal(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'a': 123, + 'b': '456', + 'c': '789' + } + } + ) + + expected_resp_json = { + 'headers': { + 'a': '123', + 'b': '457', + 'd': 890 + } + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['headers'], + { + 'b': {'expected': '457', 'value': '456'}, + 'd': {'expected': '890', 'value': None} + } + ) From 908a2e522a61426395ca856d3e5a2253e1c4829a Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 16:03:12 +0800 Subject: [PATCH 022/354] change varaible name: response content => response body --- ate/utils.py | 6 +++--- test/test_utils.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index abaafde61..d7bedbab6 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -23,14 +23,14 @@ def load_testcases(testcase_file_path): def parse_response_object(resp_obj): try: - resp_content = resp_obj.json() + resp_body = resp_obj.json() except ValueError: - resp_content = resp_obj.text + resp_body = resp_obj.text return { 'status_code': resp_obj.status_code, 'headers': resp_obj.headers, - 'content': resp_content + 'body': resp_body } def diff_response(resp_obj, expected_resp_json): diff --git a/test/test_utils.py b/test/test_utils.py index f62d9b2b2..b1226b352 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -38,10 +38,10 @@ def test_parse_response_object_json(self): parse_result = utils.parse_response_object(resp_obj) self.assertIn('status_code', parse_result) self.assertIn('headers', parse_result) - self.assertIn('content', parse_result) + self.assertIn('body', parse_result) self.assertIn('Content-Type', parse_result['headers']) self.assertIn('Content-Length', parse_result['headers']) - self.assertIn('success', parse_result['content']) + self.assertIn('success', parse_result['body']) def test_parse_response_object_text(self): url = "http://127.0.0.1:5000/" @@ -49,10 +49,10 @@ def test_parse_response_object_text(self): parse_result = utils.parse_response_object(resp_obj) self.assertIn('status_code', parse_result) self.assertIn('headers', parse_result) - self.assertIn('content', parse_result) + self.assertIn('body', parse_result) self.assertIn('Content-Type', parse_result['headers']) self.assertIn('Content-Length', parse_result['headers']) - self.assertTrue(str, type(parse_result['content'])) + self.assertTrue(str, type(parse_result['body'])) def test_diff_response_status_code_equal(self): status_code = random.randint(200, 511) From b72725e139dd53cd1bc778151b27abe9d0eafc78 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 17:20:50 +0800 Subject: [PATCH 023/354] diff http response: body --- ate/utils.py | 42 ++++++++++++++---- test/api_server.py | 5 +-- test/test_utils.py | 105 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index d7bedbab6..6004f55b5 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -33,6 +33,19 @@ def parse_response_object(resp_obj): 'body': resp_body } +def diff_json(current_json, expected_json): + json_diff = {} + + for key, expected_value in expected_json.items(): + value = current_json.get(key, None) + if str(value) != str(expected_value): + json_diff[key] = { + 'value': value, + 'expected': expected_value + } + + return json_diff + def diff_response(resp_obj, expected_resp_json): diff_content = {} resp_info = parse_response_object(resp_obj) @@ -45,16 +58,29 @@ def diff_response(resp_obj, expected_resp_json): } expected_headers = expected_resp_json.get('headers', {}) - for header_key, expected_header_value in expected_headers.items(): - header_value = resp_info['headers'].get(header_key, None) - if str(header_value) != str(expected_header_value): + headers_diff = diff_json(resp_info['headers'], expected_headers) + if headers_diff: + diff_content['headers'] = headers_diff - if 'headers' not in diff_content: - diff_content['headers'] = {} + expected_body = expected_resp_json.get('body', None) - diff_content['headers'][header_key] = { - 'value': header_value, - 'expected': str(expected_header_value) + if expected_body is None: + body_diff = {} + elif type(expected_body) != type(resp_info['body']): + body_diff = { + 'value': resp_info['body'], + 'expected': expected_body + } + elif isinstance(expected_body, str): + if expected_body != resp_info['body']: + body_diff = { + 'value': resp_info['body'], + 'expected': expected_body } + elif isinstance(expected_body, dict): + body_diff = diff_json(resp_info['body'], expected_body) + + if body_diff: + diff_content['body'] = body_diff return diff_content diff --git a/test/api_server.py b/test/api_server.py index b65c659d7..c762c36af 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -28,9 +28,8 @@ def get_customized_response(): expected_resp_json = request.get_json() status_code = expected_resp_json.get('status_code', 200) headers_dict = expected_resp_json.get('headers', {}) - body = expected_resp_json.get('body', "") - content = "Response: %s" % json.dumps(expected_resp_json) - response = make_response(content, status_code) + body = expected_resp_json.get('body', {}) + response = make_response(json.dumps(body), status_code) for header_key, header_value in headers_dict.items(): response.headers[header_key] = header_value diff --git a/test/test_utils.py b/test/test_utils.py index b1226b352..182c6d7df 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -131,6 +131,109 @@ def test_diff_response_headers_not_equal(self): diff_content['headers'], { 'b': {'expected': '457', 'value': '456'}, - 'd': {'expected': '890', 'value': None} + 'd': {'expected': 890, 'value': None} + } + ) + + def test_diff_response_body_equal(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': { + 'success': True, + 'count': 10 + } + } + ) + + # expected response body is not specified + expected_resp_json = {} + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + # response body is the same as expected response body + expected_resp_json = { + 'body': { + 'success': True, + 'count': '10' + } + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + def test_diff_response_body_not_equal_type_unmatch(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': { + 'success': True, + 'count': 10 + } + } + ) + + # response body content type not match + expected_resp_json = { + 'body': "ok" + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['body'], + { + 'value': {'success': True, 'count': 10}, + 'expected': 'ok' + } + ) + + def test_diff_response_body_not_equal_string_unmatch(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': "success" + } + ) + + # response body content type matched to be string, while value unmatch + expected_resp_json = { + 'body': "ok" + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['body'], + { + 'value': 'success', + 'expected': 'ok' + } + ) + + def test_diff_response_body_not_equal_json_unmatch(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': { + 'success': False + } + } + ) + + # response body is the same as expected response body + expected_resp_json = { + 'body': { + 'success': True, + 'count': 10 + } + } + diff_content = utils.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['body'], + { + 'success': { + 'value': False, + 'expected': True + }, + 'count': { + 'value': None, + 'expected': 10 + } } ) From 3550f1debd9209ad0be5f4110a73fac64e8509ee Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 18:26:02 +0800 Subject: [PATCH 024/354] TestRunner: run single testcase --- ate/runner.py | 21 +++++++++++ test/test_runner.py | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 ate/runner.py create mode 100644 test/test_runner.py diff --git a/ate/runner.py b/ate/runner.py new file mode 100644 index 000000000..8895072f0 --- /dev/null +++ b/ate/runner.py @@ -0,0 +1,21 @@ +import requests +from ate import utils, exception + +class TestRunner(object): + + def __init__(self): + self.client = requests.Session() + + def run_single_testcase(self, testcase): + req_kwargs = testcase['request'] + + try: + url = req_kwargs.pop('url') + method = req_kwargs.pop('method') + except KeyError: + raise exception.ParamsError("Params Error") + + resp_obj = self.client.request(url=url, method=method, **req_kwargs) + diff_content = utils.diff_response(resp_obj, testcase['response']) + success = False if diff_content else True + return success, diff_content diff --git a/test/test_runner.py b/test/test_runner.py new file mode 100644 index 000000000..aed8973d4 --- /dev/null +++ b/test/test_runner.py @@ -0,0 +1,92 @@ +import os +import random +import requests +from ate import runner, exception +from .base import ApiServerUnittest + +class TestUtils(ApiServerUnittest): + + def setUp(self): + self.test_runner = runner.TestRunner() + self.clear_users() + + def clear_users(self): + url = "http://127.0.0.1:5000/api/users" + return requests.delete(url) + + def test_run_single_testcase_success(self): + testcase = { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "response": { + "status_code": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + 'success': True, + 'msg': 'user created successfully.' + } + } + } + success, _ = self.test_runner.run_single_testcase(testcase) + self.assertTrue(success) + + def test_run_single_testcase_fail(self): + testcase = { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "response": { + "status_code": 200, + "headers": { + "Content-Type": "html/text" + }, + "body": { + 'success': False, + 'msg': "user already existed." + } + } + } + success, diff_content = self.test_runner.run_single_testcase(testcase) + self.assertFalse(success) + self.assertEqual( + diff_content['status_code'], + {'expected': 200, 'value': 201} + ) + self.assertEqual( + diff_content['headers'], + {'Content-Type': {'expected': 'html/text', 'value': 'application/json'}} + ) + self.assertEqual( + diff_content['body'], + { + 'msg': { + 'expected': 'user already existed.', + 'value': 'user created successfully.' + }, + 'success': { + 'expected': False, + 'value': True + } + } + ) From 3b380bf464b89053598ba7da3ca6fea6f7ee6208 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 18:43:15 +0800 Subject: [PATCH 025/354] TestRunner: run testcase suite --- ate/runner.py | 6 ++++++ test/data/demo.json | 8 ++++++++ test/test_runner.py | 37 +++++++++++-------------------------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 8895072f0..e28c4bf82 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -19,3 +19,9 @@ def run_single_testcase(self, testcase): diff_content = utils.diff_response(resp_obj, testcase['response']) success = False if diff_content else True return success, diff_content + + def run_testcase_suite(self, testcase_sets): + return [ + self.run_single_testcase(testcase) + for testcase in testcase_sets + ] diff --git a/test/data/demo.json b/test/data/demo.json index f011c9edb..1df96f26d 100644 --- a/test/data/demo.json +++ b/test/data/demo.json @@ -17,6 +17,10 @@ "status_code": 201, "headers": { "Content-Type": "application/json" + }, + "body": { + "success": true, + "msg": "user created successfully." } } }, @@ -37,6 +41,10 @@ "status_code": 500, "headers": { "Content-Type": "application/json" + }, + "body":{ + "success": false, + "msg": "user already existed." } } } diff --git a/test/test_runner.py b/test/test_runner.py index aed8973d4..54437087d 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -1,7 +1,7 @@ import os import random import requests -from ate import runner, exception +from ate import runner, exception, utils from .base import ApiServerUnittest class TestUtils(ApiServerUnittest): @@ -15,31 +15,9 @@ def clear_users(self): return requests.delete(url) def test_run_single_testcase_success(self): - testcase = { - "name": "create user which does not exist", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "response": { - "status_code": 201, - "headers": { - "Content-Type": "application/json" - }, - "body": { - 'success': True, - 'msg': 'user created successfully.' - } - } - } - success, _ = self.test_runner.run_single_testcase(testcase) + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') + testcases = utils.load_testcases(testcase_file_path) + success, _ = self.test_runner.run_single_testcase(testcases[0]) self.assertTrue(success) def test_run_single_testcase_fail(self): @@ -90,3 +68,10 @@ def test_run_single_testcase_fail(self): } } ) + + def test_run_testcase_suite_success(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') + testcases = utils.load_testcases(testcase_file_path) + result = self.test_runner.run_testcase_suite(testcases) + self.assertEqual(len(result), 2) + self.assertEqual(result, [(True, {}), (True, {})]) From 50f5373c41f7e40b58b853110544dd8c4d2c7076 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 21 Jun 2017 19:17:29 +0800 Subject: [PATCH 026/354] TestRunner: add testcase suite writen in YAML --- test/data/demo.yml | 8 +++++++- test/test_runner.py | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/test/data/demo.yml b/test/data/demo.yml index 242663a4f..18cc08ebf 100644 --- a/test/data/demo.yml +++ b/test/data/demo.yml @@ -12,6 +12,9 @@ status_code: 201 headers: Content-Type: application/json + body: + success: true + msg: user created successfully. - name: create user which existed @@ -26,4 +29,7 @@ response: status_code: 500 headers: - Content-Type: application/json \ No newline at end of file + Content-Type: application/json + body: + success: false + msg: user already existed. \ No newline at end of file diff --git a/test/test_runner.py b/test/test_runner.py index 54437087d..6e1f0e7c1 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -69,9 +69,16 @@ def test_run_single_testcase_fail(self): } ) - def test_run_testcase_suite_success(self): + def test_run_testcase_suite_json_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') testcases = utils.load_testcases(testcase_file_path) result = self.test_runner.run_testcase_suite(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) + + def test_run_testcase_suite_yaml_success(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.yml') + testcases = utils.load_testcases(testcase_file_path) + result = self.test_runner.run_testcase_suite(testcases) + self.assertEqual(len(result), 2) + self.assertEqual(result, [(True, {}), (True, {})]) From 567eec21fdec8340a44354bf7c3a9b535676b787 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 22 Jun 2017 18:37:48 +0800 Subject: [PATCH 027/354] ApiServer: add support MD5 authentication --- test/api_server.py | 39 +++++++++++++++++++++++++++++++++++++-- test/base.py | 25 +++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/test/api_server.py b/test/api_server.py index c762c36af..05f224f13 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -1,6 +1,8 @@ +import hashlib import json -from flask import Flask -from flask import request, make_response +from functools import wraps + +from flask import Flask, make_response, request app = Flask(__name__) @@ -19,11 +21,38 @@ """ users_dict = {} +AUTHENTICATION = False +TOKEN = "debugtalk" + +def validate_request(func): + + @wraps(func) + def wrapper(*args, **kwds): + if not AUTHENTICATION: + return func(*args, **kwds) + + try: + req_headers = request.headers + req_authorization = req_headers['Authorization'] + random_str = req_headers['Random'] + data = request.data.decode("utf-8") + authorization_str = "".join([TOKEN, data, random_str]) + authorization = hashlib.md5(authorization_str.encode('utf-8')).hexdigest() + assert authorization == req_authorization + return func(*args, **kwds) + except (KeyError, AssertionError): + return "Authorization failed!", 403 + + return wrapper + + @app.route('/') +@validate_request def index(): return "Hello World!" @app.route('/customize-response', methods=['POST']) +@validate_request def get_customized_response(): expected_resp_json = request.get_json() status_code = expected_resp_json.get('status_code', 200) @@ -37,6 +66,7 @@ def get_customized_response(): return response @app.route('/api/users') +@validate_request def get_users(): users_list = [user for uid, user in users_dict.items()] users = { @@ -49,6 +79,7 @@ def get_users(): return response @app.route('/api/users', methods=['DELETE']) +@validate_request def clear_users(): users_dict.clear() result = { @@ -59,6 +90,7 @@ def clear_users(): return response @app.route('/api/users/', methods=['POST']) +@validate_request def create_user(uid): user = request.get_json() if uid not in users_dict: @@ -80,6 +112,7 @@ def create_user(uid): return response @app.route('/api/users/') +@validate_request def get_user(uid): user = users_dict.get(uid, {}) if user: @@ -100,6 +133,7 @@ def get_user(uid): return response @app.route('/api/users/', methods=['PUT']) +@validate_request def update_user(uid): user = users_dict.get(uid, {}) if user: @@ -119,6 +153,7 @@ def update_user(uid): return response @app.route('/api/users/', methods=['DELETE']) +@validate_request def delete_user(uid): user = users_dict.pop(uid, {}) if user: diff --git a/test/base.py b/test/base.py index 1813a89be..c02d52879 100644 --- a/test/base.py +++ b/test/base.py @@ -1,14 +1,22 @@ +import hashlib import multiprocessing +import random +import string import time import unittest + from . import api_server + class ApiServerUnittest(unittest.TestCase): + """ Test case class that sets up an HTTP server which can be used within the tests """ - Test case class that sets up an HTTP server which can be used within the tests - """ + + authentication = False + @classmethod def setUpClass(cls): + api_server.AUTHENTICATION = cls.authentication cls.api_server_process = multiprocessing.Process( target=api_server.app.run ) @@ -18,3 +26,16 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): cls.api_server_process.terminate() + + def prepare_headers(self, data=""): + token = api_server.TOKEN + random_str = ''.join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(5)) + + authorization_str = "".join([token, data, random_str]) + authorization = hashlib.md5(authorization_str.encode('utf-8')).hexdigest() + headers = { + 'authorization': authorization, + 'random': random_str + } + return headers From 66f184e13f030a380e7d7cb6072e318a459809e9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 22 Jun 2017 19:51:50 +0800 Subject: [PATCH 028/354] ApiServer: add testcases for MD5 authentication --- test/test_apiserver_v2.py | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/test_apiserver_v2.py diff --git a/test/test_apiserver_v2.py b/test/test_apiserver_v2.py new file mode 100644 index 000000000..ab98cabd2 --- /dev/null +++ b/test/test_apiserver_v2.py @@ -0,0 +1,151 @@ +import json +import random +import requests + +from .base import ApiServerUnittest + + +class TestApiServerV2(ApiServerUnittest): + + authentication = True + + def setUp(self): + super(TestApiServerV2, self).setUp() + self.host = "http://127.0.0.1:5000" + self.api_client = requests.Session() + self.clear_users() + + def tearDown(self): + super(TestApiServerV2, self).tearDown() + + def test_index(self): + headers = self.prepare_headers() + resp = self.api_client.get(self.host, headers=headers) + self.assertEqual(200, resp.status_code) + + def clear_users(self): + url = "%s/api/users" % self.host + return self.api_client.delete(url, headers=self.prepare_headers()) + + def get_users(self): + url = "%s/api/users" % self.host + return self.api_client.get(url, headers=self.prepare_headers()) + + def create_user(self, uid, name, password): + url = "%s/api/users/%d" % (self.host, uid) + data = { + 'name': name, + 'password': password + } + headers = self.prepare_headers(json.dumps(data)) + return self.api_client.post(url, headers=headers, json=data) + + def get_user(self, uid): + url = "%s/api/users/%d" % (self.host, uid) + return self.api_client.get(url, headers=self.prepare_headers()) + + def update_user(self, uid, name, password): + url = "%s/api/users/%d" % (self.host, uid) + data = { + 'name': name, + 'password': password + } + headers = self.prepare_headers(json.dumps(data)) + return self.api_client.put(url, headers=headers, json=data) + + def delete_user(self, uid): + url = "%s/api/users/%d" % (self.host, uid) + return self.api_client.delete(url, headers=self.prepare_headers()) + + def test_clear_users(self): + resp = self.clear_users() + self.assertEqual(200, resp.status_code) + self.assertEqual(True, resp.json()['success']) + + def test_create_user_not_existed(self): + resp = self.create_user(1000, 'user1', '123456') + self.assertEqual(201, resp.status_code) + self.assertEqual(True, resp.json()['success']) + + def test_create_user_existed(self): + resp = self.create_user(1000, 'user1', '123456') + resp = self.create_user(1000, 'user1', '123456') + self.assertEqual(500, resp.status_code) + + def test_get_users_empty(self): + resp = self.get_users() + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['count'], 0) + + def test_get_users_not_empty(self): + resp = self.create_user(1000, 'user1', '123456') + resp = self.get_users() + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['count'], 1) + + resp = self.create_user(1001, 'user2', '123456') + resp = self.get_users() + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['count'], 2) + + def test_get_user_not_existed(self): + resp = self.get_user(1000) + self.assertEqual(404, resp.status_code) + self.assertEqual(resp.json()['success'], False) + + def test_get_user_existed(self): + self.create_user(1000, 'user1', '123456') + resp = self.get_user(1000) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['success'], True) + + def test_update_user_not_existed(self): + resp = self.update_user(1000, 'user1', '123456') + self.assertEqual(404, resp.status_code) + self.assertEqual(resp.json()['success'], False) + + def test_update_user_existed(self): + self.create_user(1000, 'user1', '123456') + resp = self.update_user(1000, 'user2', '123456') + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['data']['name'], 'user2') + + def test_delete_user_not_existed(self): + resp = self.delete_user(1000) + self.assertEqual(404, resp.status_code) + self.assertEqual(resp.json()['success'], False) + + def test_delete_user_existed(self): + self.create_user(1000, 'leo', '123456') + resp = self.delete_user(1000) + self.assertEqual(200, resp.status_code) + self.assertEqual(resp.json()['success'], True) + + def test_get_customized_response_status_code(self): + status_code = random.randint(200, 511) + url = "%s/customize-response" % self.host + expected_response = { + 'status_code': status_code, + } + resp = self.api_client.post( + url, + headers=self.prepare_headers(json.dumps(expected_response)), + json=expected_response + ) + self.assertEqual(status_code, resp.status_code) + + def test_get_customized_response_headers(self): + expected_response = { + 'headers': { + 'abc': 123, + 'def': 456 + } + } + url = "%s/customize-response" % self.host + resp = self.api_client.post( + url, + headers=self.prepare_headers(json.dumps(expected_response)), + json=expected_response + ) + self.assertIn('abc', resp.headers) + self.assertIn('123', resp.headers['abc']) From e833531e3c263e722011dbea14f6918271884541 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 22 Jun 2017 23:08:01 +0800 Subject: [PATCH 029/354] move public functions to utils.py --- ate/utils.py | 16 +++++++++++++++- test/base.py | 10 +++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 6004f55b5..de99935fd 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,8 +1,22 @@ +import hashlib import json -import yaml import os.path +import random +import string + +import yaml + from ate.exception import ParamsError + +def gen_random_string(str_len): + return ''.join( + random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) + +def gen_md5(str_list): + authorization_str = "".join(str_list) + return hashlib.md5(authorization_str.encode('utf-8')).hexdigest() + def load_yaml_file(yaml_file): with open(yaml_file, 'r+') as stream: return yaml.load(stream) diff --git a/test/base.py b/test/base.py index c02d52879..40376c839 100644 --- a/test/base.py +++ b/test/base.py @@ -1,10 +1,8 @@ -import hashlib import multiprocessing -import random -import string import time import unittest +from ate import utils from . import api_server @@ -29,11 +27,9 @@ def tearDownClass(cls): def prepare_headers(self, data=""): token = api_server.TOKEN - random_str = ''.join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(5)) + random_str = utils.gen_random_string(5) + authorization = utils.gen_md5([token, data, random_str]) - authorization_str = "".join([token, data, random_str]) - authorization = hashlib.md5(authorization_str.encode('utf-8')).hexdigest() headers = { 'authorization': authorization, 'random': random_str From 3e96ad0e8dca433d7a78a26d346381a8a2130cc7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 22 Jun 2017 23:13:59 +0800 Subject: [PATCH 030/354] improve exception notification --- ate/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/runner.py b/ate/runner.py index e28c4bf82..ce2ea3d88 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -13,7 +13,7 @@ def run_single_testcase(self, testcase): url = req_kwargs.pop('url') method = req_kwargs.pop('method') except KeyError: - raise exception.ParamsError("Params Error") + raise exception.ParamsError("URL or METHOD missed!") resp_obj = self.client.request(url=url, method=method, **req_kwargs) diff_content = utils.diff_response(resp_obj, testcase['response']) From 8c32d96c13302a2b49558673912d2ef1e90a5ca2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 22 Jun 2017 23:28:23 +0800 Subject: [PATCH 031/354] update README --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 63c9f223b..92c4da63f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 核心特性 - 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 -- 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML` +- 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML/JSON` - 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 - 接口测试用例具有可复用性,便于创建复杂测试场景 - 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 @@ -13,18 +13,25 @@ - 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) - 具有可扩展性,便于扩展实现Web平台化 +[《背景介绍》](docs/background.md) [《特性拆解介绍》](docs/features-intro.md) + ## Install ```bash $ pip install -r requirements.txt ``` +Run unittest to make sure everything is OK. + +```bash +$ python -m unittest discover +``` + ## Supported Python Versions -Python 2.7, 3.3, 3.4, 3.5, and 3.6. +Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. ## 阅读更多 -- [《背景介绍》](docs/background.md) -- [《特性拆解介绍》](docs/features-intro.md) - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) +- [《ApiTestEngine 演化之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) From ebed17d7414f39560cb7302f5e21bcb5eda97211 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 23 Jun 2017 14:43:04 +0800 Subject: [PATCH 032/354] use utils.gen_md5 function to generate md5 value --- test/api_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api_server.py b/test/api_server.py index 05f224f13..1bf8368f5 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -3,6 +3,7 @@ from functools import wraps from flask import Flask, make_response, request +from ate import utils app = Flask(__name__) @@ -36,8 +37,7 @@ def wrapper(*args, **kwds): req_authorization = req_headers['Authorization'] random_str = req_headers['Random'] data = request.data.decode("utf-8") - authorization_str = "".join([TOKEN, data, random_str]) - authorization = hashlib.md5(authorization_str.encode('utf-8')).hexdigest() + authorization = utils.gen_md5([TOKEN, data, random_str]) assert authorization == req_authorization return func(*args, **kwds) except (KeyError, AssertionError): From 8339b7f2ab87534439a52e35786c873971ec0eed Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 24 Jun 2017 11:19:20 +0800 Subject: [PATCH 033/354] handle_req_data: sort data with keys if request data is json type --- README.md | 1 + ate/utils.py | 17 +++++++++++++++++ test/api_server.py | 2 +- test/base.py | 1 + test/test_apiserver_v2.py | 9 ++++----- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 92c4da63f..01afbcbd4 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,4 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) - [《ApiTestEngine 演化之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) +- [《ApiTestEngine 演化之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) diff --git a/ate/utils.py b/ate/utils.py index de99935fd..8fb3646aa 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -17,6 +17,23 @@ def gen_md5(str_list): authorization_str = "".join(str_list) return hashlib.md5(authorization_str.encode('utf-8')).hexdigest() +def handle_req_data(data): + if not data: + return data + + if isinstance(data, str): + # check if data in str can be converted to dict + try: + data = json.loads(data) + except ValueError: + pass + + if isinstance(data, dict): + # sort data in dict with keys, then convert to str + data = json.dumps(data, sort_keys=True) + + return data + def load_yaml_file(yaml_file): with open(yaml_file, 'r+') as stream: return yaml.load(stream) diff --git a/test/api_server.py b/test/api_server.py index 1bf8368f5..f67b2db36 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -36,7 +36,7 @@ def wrapper(*args, **kwds): req_headers = request.headers req_authorization = req_headers['Authorization'] random_str = req_headers['Random'] - data = request.data.decode("utf-8") + data = utils.handle_req_data(request.data) authorization = utils.gen_md5([TOKEN, data, random_str]) assert authorization == req_authorization return func(*args, **kwds) diff --git a/test/base.py b/test/base.py index 40376c839..2f5d70fba 100644 --- a/test/base.py +++ b/test/base.py @@ -27,6 +27,7 @@ def tearDownClass(cls): def prepare_headers(self, data=""): token = api_server.TOKEN + data = utils.handle_req_data(data) random_str = utils.gen_random_string(5) authorization = utils.gen_md5([token, data, random_str]) diff --git a/test/test_apiserver_v2.py b/test/test_apiserver_v2.py index ab98cabd2..8df44d1c7 100644 --- a/test/test_apiserver_v2.py +++ b/test/test_apiserver_v2.py @@ -1,4 +1,3 @@ -import json import random import requests @@ -37,7 +36,7 @@ def create_user(self, uid, name, password): 'name': name, 'password': password } - headers = self.prepare_headers(json.dumps(data)) + headers = self.prepare_headers(data) return self.api_client.post(url, headers=headers, json=data) def get_user(self, uid): @@ -50,7 +49,7 @@ def update_user(self, uid, name, password): 'name': name, 'password': password } - headers = self.prepare_headers(json.dumps(data)) + headers = self.prepare_headers(data) return self.api_client.put(url, headers=headers, json=data) def delete_user(self, uid): @@ -129,7 +128,7 @@ def test_get_customized_response_status_code(self): } resp = self.api_client.post( url, - headers=self.prepare_headers(json.dumps(expected_response)), + headers=self.prepare_headers(expected_response), json=expected_response ) self.assertEqual(status_code, resp.status_code) @@ -144,7 +143,7 @@ def test_get_customized_response_headers(self): url = "%s/customize-response" % self.host resp = self.api_client.post( url, - headers=self.prepare_headers(json.dumps(expected_response)), + headers=self.prepare_headers(expected_response), json=expected_response ) self.assertIn('abc', resp.headers) From 9221265d32e6476109797101e05478fcb1fdb78e Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 24 Jun 2017 12:13:27 +0800 Subject: [PATCH 034/354] bugfix: In Python3, convert request.data(bytes) to str first --- ate/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 8fb3646aa..6f0ff6d0a 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -8,6 +8,12 @@ from ate.exception import ParamsError +try: + assert bytes is str + PYTHON_VERSION = 2 +except AssertionError: + PYTHON_VERSION = 3 + def gen_random_string(str_len): return ''.join( @@ -18,6 +24,11 @@ def gen_md5(str_list): return hashlib.md5(authorization_str.encode('utf-8')).hexdigest() def handle_req_data(data): + + if PYTHON_VERSION == 3 and isinstance(data, bytes): + # In Python3, convert bytes to str + data = data.decode('utf-8') + if not data: return data From 572b5111309a3c3c4424b22b3e389e16245391b5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 24 Jun 2017 12:25:41 +0800 Subject: [PATCH 035/354] change testcase name --- test/test_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_runner.py b/test/test_runner.py index 6e1f0e7c1..5a04cad66 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -4,7 +4,7 @@ from ate import runner, exception, utils from .base import ApiServerUnittest -class TestUtils(ApiServerUnittest): +class TestRunner(ApiServerUnittest): def setUp(self): self.test_runner = runner.TestRunner() From dc7d14439d208195e8eb91917ad6ea0052975c0f Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 24 Jun 2017 16:11:10 +0800 Subject: [PATCH 036/354] add testcases for TestRunner, with authentication enabled --- test/data/demo_auth.json | 54 ++++++++++++++++++++++++++++++++++++++++ test/data/demo_auth.yml | 39 +++++++++++++++++++++++++++++ test/test_runner_v2.py | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 test/data/demo_auth.json create mode 100644 test/data/demo_auth.yml create mode 100644 test/test_runner_v2.py diff --git a/test/data/demo_auth.json b/test/data/demo_auth.json new file mode 100644 index 000000000..a6c361c20 --- /dev/null +++ b/test/data/demo_auth.json @@ -0,0 +1,54 @@ +[ + { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "response": { + "status_code": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "success": true, + "msg": "user created successfully." + } + } + }, + { + "name": "create user which existed", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "response": { + "status_code": 500, + "headers": { + "Content-Type": "application/json" + }, + "body":{ + "success": false, + "msg": "user already existed." + } + } + } +] \ No newline at end of file diff --git a/test/data/demo_auth.yml b/test/data/demo_auth.yml new file mode 100644 index 000000000..2d9c1d1e5 --- /dev/null +++ b/test/data/demo_auth.yml @@ -0,0 +1,39 @@ +- + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: a83de0ff8d2e896dbd8efb81ba14e17d + random: A2dEx + json: + name: "user1" + password: "123456" + response: + status_code: 201 + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. + +- + name: create user which existed + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: a83de0ff8d2e896dbd8efb81ba14e17d + random: A2dEx + json: + name: "user1" + password: "123456" + response: + status_code: 500 + headers: + Content-Type: application/json + body: + success: false + msg: user already existed. \ No newline at end of file diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py new file mode 100644 index 000000000..269ab41a0 --- /dev/null +++ b/test/test_runner_v2.py @@ -0,0 +1,43 @@ +import os +import random +import requests +from ate import runner, exception, utils +from .base import ApiServerUnittest + +class TestRunnerV2(ApiServerUnittest): + + authentication = True + + def setUp(self): + self.test_runner = runner.TestRunner() + self.clear_users() + + def clear_users(self): + url = "http://127.0.0.1:5000/api/users" + return requests.delete(url, headers=self.prepare_headers()) + + def test_run_single_testcase_yaml(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.yml') + testcases = utils.load_testcases(testcase_file_path) + success, _ = self.test_runner.run_single_testcase(testcases[0]) + self.assertTrue(success) + + def test_run_single_testcase_json(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.json') + testcases = utils.load_testcases(testcase_file_path) + success, _ = self.test_runner.run_single_testcase(testcases[0]) + self.assertTrue(success) + + def test_run_testcase_auth_suite_yaml(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.yml') + testcases = utils.load_testcases(testcase_file_path) + result = self.test_runner.run_testcase_suite(testcases) + self.assertEqual(len(result), 2) + self.assertEqual(result, [(True, {}), (True, {})]) + + def test_run_testcase_auth_suite_json(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.json') + testcases = utils.load_testcases(testcase_file_path) + result = self.test_runner.run_testcase_suite(testcases) + self.assertEqual(len(result), 2) + self.assertEqual(result, [(True, {}), (True, {})]) From e81d682c505ff1fb2d6a82aeac16492c014aa80c Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 24 Jun 2017 19:44:40 +0800 Subject: [PATCH 037/354] gen_md5: pass in *args --- ate/utils.py | 5 ++--- test/api_server.py | 2 +- test/base.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 6f0ff6d0a..1add62d5f 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -19,9 +19,8 @@ def gen_random_string(str_len): return ''.join( random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) -def gen_md5(str_list): - authorization_str = "".join(str_list) - return hashlib.md5(authorization_str.encode('utf-8')).hexdigest() +def gen_md5(*str_args): + return hashlib.md5("".join(str_args).encode('utf-8')).hexdigest() def handle_req_data(data): diff --git a/test/api_server.py b/test/api_server.py index f67b2db36..b0cdd87f9 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -37,7 +37,7 @@ def wrapper(*args, **kwds): req_authorization = req_headers['Authorization'] random_str = req_headers['Random'] data = utils.handle_req_data(request.data) - authorization = utils.gen_md5([TOKEN, data, random_str]) + authorization = utils.gen_md5(TOKEN, data, random_str) assert authorization == req_authorization return func(*args, **kwds) except (KeyError, AssertionError): diff --git a/test/base.py b/test/base.py index 2f5d70fba..3368bc214 100644 --- a/test/base.py +++ b/test/base.py @@ -29,7 +29,7 @@ def prepare_headers(self, data=""): token = api_server.TOKEN data = utils.handle_req_data(data) random_str = utils.gen_random_string(5) - authorization = utils.gen_md5([token, data, random_str]) + authorization = utils.gen_md5(token, data, random_str) headers = { 'authorization': authorization, From 6a578e78516973511707c01d4da77eced73c86cb Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 25 Jun 2017 20:29:59 +0800 Subject: [PATCH 038/354] change demo testcase file name --- ...demo_auth.json => simple_demo_auth_hardcode.json} | 0 .../{demo_auth.yml => simple_demo_auth_hardcode.yml} | 0 test/data/{demo.json => simple_demo_no_auth.json} | 0 test/data/{demo.yml => simple_demo_no_auth.yml} | 0 test/test_runner.py | 6 +++--- test/test_runner_v2.py | 12 ++++++++---- test/test_utils.py | 6 ++++-- 7 files changed, 15 insertions(+), 9 deletions(-) rename test/data/{demo_auth.json => simple_demo_auth_hardcode.json} (100%) rename test/data/{demo_auth.yml => simple_demo_auth_hardcode.yml} (100%) rename test/data/{demo.json => simple_demo_no_auth.json} (100%) rename test/data/{demo.yml => simple_demo_no_auth.yml} (100%) diff --git a/test/data/demo_auth.json b/test/data/simple_demo_auth_hardcode.json similarity index 100% rename from test/data/demo_auth.json rename to test/data/simple_demo_auth_hardcode.json diff --git a/test/data/demo_auth.yml b/test/data/simple_demo_auth_hardcode.yml similarity index 100% rename from test/data/demo_auth.yml rename to test/data/simple_demo_auth_hardcode.yml diff --git a/test/data/demo.json b/test/data/simple_demo_no_auth.json similarity index 100% rename from test/data/demo.json rename to test/data/simple_demo_no_auth.json diff --git a/test/data/demo.yml b/test/data/simple_demo_no_auth.yml similarity index 100% rename from test/data/demo.yml rename to test/data/simple_demo_no_auth.yml diff --git a/test/test_runner.py b/test/test_runner.py index 5a04cad66..cd4aa033a 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -15,7 +15,7 @@ def clear_users(self): return requests.delete(url) def test_run_single_testcase_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) success, _ = self.test_runner.run_single_testcase(testcases[0]) self.assertTrue(success) @@ -70,14 +70,14 @@ def test_run_single_testcase_fail(self): ) def test_run_testcase_suite_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) result = self.test_runner.run_testcase_suite(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) def test_run_testcase_suite_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.yml') + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') testcases = utils.load_testcases(testcase_file_path) result = self.test_runner.run_testcase_suite(testcases) self.assertEqual(len(result), 2) diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index 269ab41a0..ba6b03bef 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -17,26 +17,30 @@ def clear_users(self): return requests.delete(url, headers=self.prepare_headers()) def test_run_single_testcase_yaml(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.yml') + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) success, _ = self.test_runner.run_single_testcase(testcases[0]) self.assertTrue(success) def test_run_single_testcase_json(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.json') + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) success, _ = self.test_runner.run_single_testcase(testcases[0]) self.assertTrue(success) def test_run_testcase_auth_suite_yaml(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.yml') + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) result = self.test_runner.run_testcase_suite(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) def test_run_testcase_auth_suite_json(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_auth.json') + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) result = self.test_runner.run_testcase_suite(testcases) self.assertEqual(len(result), 2) diff --git a/test/test_utils.py b/test/test_utils.py index 182c6d7df..4aacc8537 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -13,7 +13,8 @@ def test_load_testcases_bad_filepath(self): utils.load_testcases(testcase_file_path) def test_load_json_testcases(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.json') + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) self.assertEqual(len(testcases), 2) self.assertIn('name', testcases[0]) @@ -23,7 +24,8 @@ def test_load_json_testcases(self): self.assertIn('method', testcases[0]['request']) def test_load_yaml_testcases(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo.yml') + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_no_auth.yml') testcases = utils.load_testcases(testcase_file_path) self.assertEqual(len(testcases), 2) self.assertIn('name', testcases[0]) From ae9750b4a06a81983cd514f0092b9e55ea140fa9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 25 Jun 2017 23:04:14 +0800 Subject: [PATCH 039/354] TestcaseParser: parse testcase_template, replace all variables with bind value. --- ate/testcase.py | 68 ++++++++++++++++++++++++++++++++++++ test/test_testcase.py | 81 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 ate/testcase.py create mode 100644 test/test_testcase.py diff --git a/ate/testcase.py b/ate/testcase.py new file mode 100644 index 000000000..80b1c6aab --- /dev/null +++ b/ate/testcase.py @@ -0,0 +1,68 @@ +import re +from ate import exception + +class TestcaseParser(object): + + def __init__(self, variables_binds={}): + self.variables_binds = variables_binds + + def parse(self, testcase_template): + """ parse testcase_template, replace all variables with bind value. + variables marker: ${variable}. + @param testcase_template + "request": { + "url": "http://127.0.0.1:5000/api/users/${uid}", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "${authorization}", + "random": "${random}" + }, + "body": "${json}" + }, + "response": { + "status_code": "${expected_status}", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "success": True, + "msg": "user created successfully." + } + } + """ + return self.substitute(testcase_template) + + def substitute(self, content): + """ substitute content recursively, each variable will be replaced with bind value. + variables marker: ${variable}. + """ + if isinstance(content, str): + # check if content includes ${variable} + matched = re.match(r"(.*)\$\{(.*)\}(.*)", content) + if matched: + # this is a variable, and will replace with its bind value + variable_name = matched.group(2) + value = self.variables_binds.get(variable_name) + if value is None: + raise exception.ParamsError( + "%s is not defined in bind variables!" % variable_name) + if matched.group(1) or matched.group(3): + # e.g. /api/users/${uid} + return re.sub(r"\$\{.*\}", value, content) + + return value + + return content + + if isinstance(content, list): + return [self.substitute(item) for item in content] + + if isinstance(content, dict): + parsed_content = {} + for key, value in content.items(): + parsed_content[key] = self.substitute(value) + + return parsed_content + + return content diff --git a/test/test_testcase.py b/test/test_testcase.py new file mode 100644 index 000000000..5ae5aa55b --- /dev/null +++ b/test/test_testcase.py @@ -0,0 +1,81 @@ +import unittest + +from ate.testcase import TestcaseParser +from ate import exception + + +class TestcaseParserUnittest(unittest.TestCase): + + def setUp(self): + self.variables_binds = { + "uid": "1000", + "random": "A2dEx", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "json": { + "name": "user1", + "password": "123456" + }, + "expected_status": 201, + "expected_success": True + } + self.testcase_parser = TestcaseParser(self.variables_binds) + + def test_parse_testcase_template(self): + testcase = { + "request": { + "url": "http://127.0.0.1:5000/api/users/${uid}", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "${authorization}", + "random": "${random}" + }, + "body": "${json}" + }, + "response": { + "status_code": "${expected_status}", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "success": "${expected_success}", + "msg": "user created successfully." + } + } + } + parsed_testcase = self.testcase_parser.parse(testcase) + + self.assertEqual( + parsed_testcase["request"]["url"], + "http://127.0.0.1:5000/api/users/%s" % self.variables_binds["uid"] + ) + self.assertEqual( + parsed_testcase["request"]["headers"]["authorization"], + self.variables_binds["authorization"] + ) + self.assertEqual( + parsed_testcase["request"]["headers"]["random"], + self.variables_binds["random"] + ) + self.assertEqual( + parsed_testcase["request"]["body"], + self.variables_binds["json"] + ) + self.assertEqual( + parsed_testcase["response"]["status_code"], + self.variables_binds["expected_status"] + ) + self.assertEqual( + parsed_testcase["response"]["body"]["success"], + self.variables_binds["expected_success"] + ) + + def test_parse_testcase_template_miss_bind_variable(self): + testcase = { + "request": { + "url": "http://127.0.0.1:5000/api/users/${uid}", + "method": "${method}" + } + } + with self.assertRaises(exception.ParamsError): + self.testcase_parser.parse(testcase) From 7396bcd8539570a81b5cc8a37f2a4d5f020238fc Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 00:38:00 +0800 Subject: [PATCH 040/354] Context: Manages binding of variables --- ate/context.py | 74 ++++++++++++++++++++ test/data/demo_binds.yml | 38 +++++++++++ test/test_context.py | 144 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 ate/context.py create mode 100644 test/data/demo_binds.yml create mode 100644 test/test_context.py diff --git a/ate/context.py b/ate/context.py new file mode 100644 index 000000000..44e09e71a --- /dev/null +++ b/ate/context.py @@ -0,0 +1,74 @@ +import importlib + +class Context(object): + """ Manages binding of variables + """ + def __init__(self): + self.functions = dict() + self.variables = dict() # Maps variable name to value + + def import_requires(self, modules): + """ import required modules dynamicly + """ + for module_name in modules: + globals()[module_name] = importlib.import_module(module_name) + + def bind_functions(self, function_binds): + """ Bind named functions within the context + This allows for passing in self-defined functions in testing. + e.g. function_binds: + { + "add_one": lambda x: x + 1, + "add_two_nums": "lambda x, y: x + y" + } + """ + for func_name, function in function_binds.items(): + if isinstance(function, str): + function = eval(function) + self.functions[func_name] = function + + def bind_variables(self, variable_binds): + """ Bind named variables to value within the context. + This allows for passing in variables or functions. + e.g. variable_binds: + [ + {"TOKEN": "debugtalk"}, + {"random": {"func": "gen_random_string", "args": [5]}}, + {"json": {'name': 'user', 'password': '123456'}}, + {"md5": {"func": "gen_md5", "args": ["$TOKEN", "$json", "$random"]}} + ] + """ + for variable_bind_map in variable_binds: + for var_name, var_value in variable_bind_map.items(): + self.variables[var_name] = self.get_eval_value(var_value) + + def get_eval_value(self, data): + """ evaluate data recursively, each variable in data will be evaluated. + variable will always be a string started with $, such as $token + """ + if isinstance(data, str): + if data.startswith('$'): + # this is a variable, and will replace with its bind value + return self.variables.get(data[1:]) + return data + + if isinstance(data, list): + return [self.get_eval_value(item) for item in data] + + if isinstance(data, dict): + if "func" in data: + # this is a function, e.g. {"func": "gen_random_string", "args": [5]} + # function marker: "func" key in dict + # the function will be called, and its return value will be binded to the variable. + func_name = data['func'] + args = self.get_eval_value(data.get('args', [])) + kargs = self.get_eval_value(data.get('kargs', {})) + return self.functions[func_name](*args, **kargs) + else: + evaluated_data = {} + for key, value in data.items(): + evaluated_data[key] = self.get_eval_value(value) + + return evaluated_data + + return data diff --git a/test/data/demo_binds.yml b/test/data/demo_binds.yml new file mode 100644 index 000000000..f2ac4d0ab --- /dev/null +++ b/test/data/demo_binds.yml @@ -0,0 +1,38 @@ +- + variable_binds: + - TOKEN: "debugtalk" + +- + variable_binds: + - var: [1, 2, 3] + +- + variable_binds: + - data: {'name': 'user', 'password': '123456'} + +- + variable_binds: + - TOKEN: "debugtalk" + - token: $TOKEN + +- + function_binds: + add_one: "lambda x: x + 1" + add_two_nums: "lambda x, y: x + y" + variable_binds: + - add1: {"func": "add_one", "args": [2]} + - sum2nums: {"func": "add_two_nums", "args": [2, 3]} + +- + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: "{'name': 'user', 'password': '123456'}" + - authorization: {"func": "gen_md5", "args": [$TOKEN, $data, $random]} diff --git a/test/test_context.py b/test/test_context.py new file mode 100644 index 000000000..d7092a644 --- /dev/null +++ b/test/test_context.py @@ -0,0 +1,144 @@ +import os +import unittest + +from ate import utils +from ate.context import Context + + +class VariableBindsUnittest(unittest.TestCase): + + def setUp(self): + self.context = Context() + testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_binds.yml') + self.testcases = utils.load_testcases(testcase_file_path) + + def test_context_variable_string(self): + # testcase in JSON format + testcase1 = { + "variable_binds": [ + {"TOKEN": "debugtalk"} + ] + } + # testcase in YAML format + testcase2 = self.testcases[0] + + for testcase in [testcase1, testcase2]: + variable_binds = testcase['variable_binds'] + self.context.bind_variables(variable_binds) + + context_variables = self.context.variables + self.assertIn("TOKEN", context_variables) + self.assertEqual(context_variables["TOKEN"], "debugtalk") + + def test_context_variable_list(self): + testcase1 = { + "variable_binds": [ + {"var": [1, 2, 3]} + ] + } + testcase2 = self.testcases[1] + + for testcase in [testcase1, testcase2]: + variable_binds = testcase['variable_binds'] + self.context.bind_variables(variable_binds) + + context_variables = self.context.variables + self.assertIn("var", context_variables) + self.assertEqual(context_variables["var"], [1, 2, 3]) + + def test_context_variable_json(self): + testcase1 = { + "variable_binds": [ + {"data": {'name': 'user', 'password': '123456'}} + ] + } + testcase2 = self.testcases[2] + + for testcase in [testcase1, testcase2]: + variable_binds = testcase['variable_binds'] + self.context.bind_variables(variable_binds) + + context_variables = self.context.variables + self.assertIn("data", context_variables) + self.assertEqual( + context_variables["data"], + {'name': 'user', 'password': '123456'} + ) + + def test_context_variable_variable(self): + testcase1 = { + "variable_binds": [ + {"GLOBAL_TOKEN": "debugtalk"}, + {"token": "$GLOBAL_TOKEN"} + ] + } + testcase2 = self.testcases[3] + + for testcase in [testcase1, testcase2]: + variable_binds = testcase['variable_binds'] + self.context.bind_variables(variable_binds) + + context_variables = self.context.variables + self.assertIn("GLOBAL_TOKEN", context_variables) + self.assertEqual(context_variables["GLOBAL_TOKEN"], "debugtalk") + self.assertIn("token", context_variables) + self.assertEqual(context_variables["token"], "debugtalk") + + def test_context_variable_function_lambda(self): + testcase1 = { + "function_binds": { + "add_one": lambda x: x + 1, + "add_two_nums": lambda x, y: x + y + }, + "variable_binds": [ + {"add1": {"func": "add_one", "args": [2]}}, + {"sum2nums": {"func": "add_two_nums", "args": [2, 3]}} + ] + } + testcase2 = self.testcases[4] + + for testcase in [testcase1, testcase2]: + function_binds = testcase.get('function_binds', {}) + self.context.bind_functions(function_binds) + + variable_binds = testcase['variable_binds'] + self.context.bind_variables(variable_binds) + + context_variables = self.context.variables + self.assertIn("add1", context_variables) + self.assertEqual(context_variables["add1"], 3) + self.assertIn("sum2nums", context_variables) + self.assertEqual(context_variables["sum2nums"], 5) + + def test_context_variable_function_lambda_with_import(self): + testcase1 = { + "requires": ["random", "string", "hashlib"], + "function_binds": { + "gen_random_string": "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))", + "gen_md5": "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + }, + "variable_binds": [ + {"TOKEN": "debugtalk"}, + {"random": {"func": "gen_random_string", "args": [5]}}, + {"data": "{'name': 'user', 'password': '123456'}"}, + {"md5": {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]}} + ] + } + testcase2 = self.testcases[5] + + for testcase in [testcase1, testcase2]: + requires = testcase.get('requires', []) + self.context.import_requires(requires) + + function_binds = testcase.get('function_binds', {}) + self.context.bind_functions(function_binds) + + variable_binds = testcase['variable_binds'] + self.context.bind_variables(variable_binds) + + context_variables = self.context.variables + self.assertIn("random", context_variables) + self.assertIsInstance(context_variables["random"], str) + self.assertEqual(len(context_variables["random"]), 5) + self.assertIn("md5", context_variables) + self.assertEqual(len(context_variables["md5"]), 32) From 2fe161f82d2f0ccfabc0026816e30f8732fdd9c1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 10:55:17 +0800 Subject: [PATCH 041/354] TestcaseParser: variable binds of testcase parser instance cat be updated. --- ate/testcase.py | 7 ++++++- test/test_testcase.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ate/testcase.py b/ate/testcase.py index 80b1c6aab..194e338a8 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -6,7 +6,7 @@ class TestcaseParser(object): def __init__(self, variables_binds={}): self.variables_binds = variables_binds - def parse(self, testcase_template): + def parse(self, testcase_template, variables_binds={}): """ parse testcase_template, replace all variables with bind value. variables marker: ${variable}. @param testcase_template @@ -30,7 +30,12 @@ def parse(self, testcase_template): "msg": "user created successfully." } } + @param variables_binds + variable binds of testcase parser instance will be updated. """ + if variables_binds: + self.variables_binds.update(variables_binds) + return self.substitute(testcase_template) def substitute(self, content): diff --git a/test/test_testcase.py b/test/test_testcase.py index 5ae5aa55b..b6cfcb75c 100644 --- a/test/test_testcase.py +++ b/test/test_testcase.py @@ -79,3 +79,21 @@ def test_parse_testcase_template_miss_bind_variable(self): } with self.assertRaises(exception.ParamsError): self.testcase_parser.parse(testcase) + + def test_parse_testcase_with_new_variable_binds(self): + testcase = { + "request": { + "url": "http://127.0.0.1:5000/api/users/${uid}", + "method": "${method}" + } + } + new_variable_binds = { + "method": "GET" + } + parsed_testcase = self.testcase_parser.parse(testcase, new_variable_binds) + + self.assertIn("method", self.testcase_parser.variables_binds) + self.assertEqual( + parsed_testcase["request"]["method"], + new_variable_binds["method"] + ) From 967f2bbc3faaee45d6a75e12bdf0e42d18a712fe Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 11:51:08 +0800 Subject: [PATCH 042/354] TestRunner: run testcase writen in separate template --- ate/runner.py | 29 ++++++++++++- test/data/demo_template_separate.yml | 62 ++++++++++++++++++++++++++++ test/test_runner_v2.py | 9 ++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 test/data/demo_template_separate.yml diff --git a/ate/runner.py b/ate/runner.py index ce2ea3d88..0508d8500 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,12 +1,39 @@ import requests -from ate import utils, exception + +from ate import exception, utils +from ate.context import Context +from ate.testcase import TestcaseParser + class TestRunner(object): def __init__(self): self.client = requests.Session() + self.context = Context() + self.testcase_parser = TestcaseParser() + + def prepare(self, testcase): + """ prepare work before running test. + parse testcase with variables binds if it is a template. + """ + requires = testcase.get('requires', []) + self.context.import_requires(requires) + + function_binds = testcase.get('function_binds', {}) + self.context.bind_functions(function_binds) + + variable_binds = testcase.get('variable_binds', []) + self.context.bind_variables(variable_binds) + + parsed_testcase = self.testcase_parser.parse( + testcase, + variables_binds=self.context.variables + ) + return parsed_testcase def run_single_testcase(self, testcase): + testcase = self.prepare(testcase) + req_kwargs = testcase['request'] try: diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml new file mode 100644 index 000000000..c0d8568c8 --- /dev/null +++ b/test/data/demo_template_separate.yml @@ -0,0 +1,62 @@ + +- + name: create user which does not exist + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: '{"name": "user", "password": "123456"}' + - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - expected_status_code: 201 + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + data: "${data}" + response: + status_code: "${expected_status_code}" + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. + +- + name: create user which does not exist + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: '{"name": "user", "password": "123456"}' + - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - expected_status_code: 500 + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + data: "${data}" + response: + status_code: "${expected_status_code}" + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index ba6b03bef..72921b0d9 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -45,3 +45,12 @@ def test_run_testcase_auth_suite_json(self): result = self.test_runner.run_testcase_suite(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) + + def test_run_testcase_template_yaml(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/demo_template_separate.yml') + testcases = utils.load_testcases(testcase_file_path) + success, _ = self.test_runner.run_single_testcase(testcases[0]) + self.assertTrue(success) + success, _ = self.test_runner.run_single_testcase(testcases[1]) + self.assertFalse(success) From 20d173ce9a6ee51b10917d24ec71a8cae8ae0bad Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 11:58:07 +0800 Subject: [PATCH 043/354] TestRunner: change method name --- ate/runner.py | 8 ++++---- test/test_runner.py | 8 ++++---- test/test_runner_v2.py | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 0508d8500..a9dbc86a9 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -31,7 +31,7 @@ def prepare(self, testcase): ) return parsed_testcase - def run_single_testcase(self, testcase): + def run_test(self, testcase): testcase = self.prepare(testcase) req_kwargs = testcase['request'] @@ -47,8 +47,8 @@ def run_single_testcase(self, testcase): success = False if diff_content else True return success, diff_content - def run_testcase_suite(self, testcase_sets): + def run_testsets(self, testsets): return [ - self.run_single_testcase(testcase) - for testcase in testcase_sets + self.run_test(testcase) + for testcase in testsets ] diff --git a/test/test_runner.py b/test/test_runner.py index cd4aa033a..b8dd377ed 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -17,7 +17,7 @@ def clear_users(self): def test_run_single_testcase_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_single_testcase(testcases[0]) + success, _ = self.test_runner.run_test(testcases[0]) self.assertTrue(success) def test_run_single_testcase_fail(self): @@ -45,7 +45,7 @@ def test_run_single_testcase_fail(self): } } } - success, diff_content = self.test_runner.run_single_testcase(testcase) + success, diff_content = self.test_runner.run_test(testcase) self.assertFalse(success) self.assertEqual( diff_content['status_code'], @@ -72,13 +72,13 @@ def test_run_single_testcase_fail(self): def test_run_testcase_suite_json_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testcase_suite(testcases) + result = self.test_runner.run_testsets(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) def test_run_testcase_suite_yaml_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testcase_suite(testcases) + result = self.test_runner.run_testsets(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index 72921b0d9..b375d85b1 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -20,21 +20,21 @@ def test_run_single_testcase_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_single_testcase(testcases[0]) + success, _ = self.test_runner.run_test(testcases[0]) self.assertTrue(success) def test_run_single_testcase_json(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_single_testcase(testcases[0]) + success, _ = self.test_runner.run_test(testcases[0]) self.assertTrue(success) def test_run_testcase_auth_suite_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testcase_suite(testcases) + result = self.test_runner.run_testsets(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) @@ -42,7 +42,7 @@ def test_run_testcase_auth_suite_json(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testcase_suite(testcases) + result = self.test_runner.run_testsets(testcases) self.assertEqual(len(result), 2) self.assertEqual(result, [(True, {}), (True, {})]) @@ -50,7 +50,7 @@ def test_run_testcase_template_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/demo_template_separate.yml') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_single_testcase(testcases[0]) + success, _ = self.test_runner.run_test(testcases[0]) self.assertTrue(success) - success, _ = self.test_runner.run_single_testcase(testcases[1]) + success, _ = self.test_runner.run_test(testcases[1]) self.assertFalse(success) From 139036dbadcb646743048eb91c5e9921ce02df3a Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 14:43:06 +0800 Subject: [PATCH 044/354] TestcaseParser: add update_variables_binds --- ate/testcase.py | 13 +++++++------ test/test_testcase.py | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 194e338a8..c245a07b8 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -6,7 +6,13 @@ class TestcaseParser(object): def __init__(self, variables_binds={}): self.variables_binds = variables_binds - def parse(self, testcase_template, variables_binds={}): + def update_variables_binds(self, variables_mapping): + """ update variables binds with new mapping. + """ + if variables_mapping: + self.variables_binds.update(variables_mapping) + + def parse(self, testcase_template): """ parse testcase_template, replace all variables with bind value. variables marker: ${variable}. @param testcase_template @@ -30,12 +36,7 @@ def parse(self, testcase_template, variables_binds={}): "msg": "user created successfully." } } - @param variables_binds - variable binds of testcase parser instance will be updated. """ - if variables_binds: - self.variables_binds.update(variables_binds) - return self.substitute(testcase_template) def substitute(self, content): diff --git a/test/test_testcase.py b/test/test_testcase.py index b6cfcb75c..62f4a7318 100644 --- a/test/test_testcase.py +++ b/test/test_testcase.py @@ -90,7 +90,8 @@ def test_parse_testcase_with_new_variable_binds(self): new_variable_binds = { "method": "GET" } - parsed_testcase = self.testcase_parser.parse(testcase, new_variable_binds) + self.testcase_parser.update_variables_binds(new_variable_binds) + parsed_testcase = self.testcase_parser.parse(testcase) self.assertIn("method", self.testcase_parser.variables_binds) self.assertEqual( From 5ac8491c2d95b94987923cbee77bb7deeae82fdd Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 15:27:27 +0800 Subject: [PATCH 045/354] change testcase data structure, in order to distinguish config and test --- test/data/demo_binds.yml | 54 +++++----- test/data/demo_template_separate.yml | 120 +++++++++++------------ test/data/simple_demo_auth_hardcode.json | 88 +++++++++-------- test/data/simple_demo_auth_hardcode.yml | 76 +++++++------- test/data/simple_demo_no_auth.json | 82 ++++++++-------- test/data/simple_demo_no_auth.yml | 4 +- test/test_runner.py | 12 ++- test/test_runner_v2.py | 10 +- test/test_utils.py | 22 +++-- 9 files changed, 244 insertions(+), 224 deletions(-) diff --git a/test/data/demo_binds.yml b/test/data/demo_binds.yml index f2ac4d0ab..0de0a1352 100644 --- a/test/data/demo_binds.yml +++ b/test/data/demo_binds.yml @@ -1,38 +1,38 @@ - - variable_binds: - - TOKEN: "debugtalk" + variable_binds: + - TOKEN: "debugtalk" - - variable_binds: - - var: [1, 2, 3] + variable_binds: + - var: [1, 2, 3] - - variable_binds: - - data: {'name': 'user', 'password': '123456'} + variable_binds: + - data: {'name': 'user', 'password': '123456'} - - variable_binds: - - TOKEN: "debugtalk" - - token: $TOKEN + variable_binds: + - TOKEN: "debugtalk" + - token: $TOKEN - - function_binds: - add_one: "lambda x: x + 1" - add_two_nums: "lambda x, y: x + y" - variable_binds: - - add1: {"func": "add_one", "args": [2]} - - sum2nums: {"func": "add_two_nums", "args": [2, 3]} + function_binds: + add_one: "lambda x: x + 1" + add_two_nums: "lambda x, y: x + y" + variable_binds: + - add1: {"func": "add_one", "args": [2]} + - sum2nums: {"func": "add_two_nums", "args": [2, 3]} - - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: - - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} - - data: "{'name': 'user', 'password': '123456'}" - - authorization: {"func": "gen_md5", "args": [$TOKEN, $data, $random]} + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: "{'name': 'user', 'password': '123456'}" + - authorization: {"func": "gen_md5", "args": [$TOKEN, $data, $random]} diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index c0d8568c8..8fef557b1 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -1,62 +1,62 @@ -- - name: create user which does not exist - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: - - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} - - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} - - expected_status_code: 201 - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - data: "${data}" - response: - status_code: "${expected_status_code}" - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. +- test: + name: create user which does not exist + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: '{"name": "user", "password": "123456"}' + - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - expected_status_code: 201 + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + data: "${data}" + response: + status_code: "${expected_status_code}" + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. -- - name: create user which does not exist - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: - - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} - - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} - - expected_status_code: 500 - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - data: "${data}" - response: - status_code: "${expected_status_code}" - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. +- test: + name: create user which does not exist + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: '{"name": "user", "password": "123456"}' + - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - expected_status_code: 500 + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + data: "${data}" + response: + status_code: "${expected_status_code}" + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. diff --git a/test/data/simple_demo_auth_hardcode.json b/test/data/simple_demo_auth_hardcode.json index a6c361c20..fc30e0dd1 100644 --- a/test/data/simple_demo_auth_hardcode.json +++ b/test/data/simple_demo_auth_hardcode.json @@ -1,53 +1,57 @@ [ { - "name": "create user which does not exist", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" + "test": { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "json": { + "name": "user1", + "password": "123456" + } }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "response": { - "status_code": 201, - "headers": { - "Content-Type": "application/json" - }, - "body": { - "success": true, - "msg": "user created successfully." + "response": { + "status_code": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "success": true, + "msg": "user created successfully." + } } } }, { - "name": "create user which existed", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "response": { - "status_code": 500, - "headers": { - "Content-Type": "application/json" + "test": { + "name": "create user which existed", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "json": { + "name": "user1", + "password": "123456" + } }, - "body":{ - "success": false, - "msg": "user already existed." + "response": { + "status_code": 500, + "headers": { + "Content-Type": "application/json" + }, + "body":{ + "success": false, + "msg": "user already existed." + } } } } diff --git a/test/data/simple_demo_auth_hardcode.yml b/test/data/simple_demo_auth_hardcode.yml index 2d9c1d1e5..f8ccfda7a 100644 --- a/test/data/simple_demo_auth_hardcode.yml +++ b/test/data/simple_demo_auth_hardcode.yml @@ -1,39 +1,39 @@ -- - name: create user which does not exist - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: a83de0ff8d2e896dbd8efb81ba14e17d - random: A2dEx - json: - name: "user1" - password: "123456" - response: - status_code: 201 - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: a83de0ff8d2e896dbd8efb81ba14e17d + random: A2dEx + json: + name: "user1" + password: "123456" + response: + status_code: 201 + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. -- - name: create user which existed - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: a83de0ff8d2e896dbd8efb81ba14e17d - random: A2dEx - json: - name: "user1" - password: "123456" - response: - status_code: 500 - headers: - Content-Type: application/json - body: - success: false - msg: user already existed. \ No newline at end of file +- test: + name: create user which existed + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: a83de0ff8d2e896dbd8efb81ba14e17d + random: A2dEx + json: + name: "user1" + password: "123456" + response: + status_code: 500 + headers: + Content-Type: application/json + body: + success: false + msg: user already existed. \ No newline at end of file diff --git a/test/data/simple_demo_no_auth.json b/test/data/simple_demo_no_auth.json index 1df96f26d..6f5bb8338 100644 --- a/test/data/simple_demo_no_auth.json +++ b/test/data/simple_demo_no_auth.json @@ -1,50 +1,54 @@ [ { - "name": "create user which does not exist", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json" + "test": { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "cookies": {}, + "json": { + "name": "user1", + "password": "123456" + } }, - "cookies": {}, - "json": { - "name": "user1", - "password": "123456" - } - }, - "response": { - "status_code": 201, - "headers": { - "Content-Type": "application/json" - }, - "body": { - "success": true, - "msg": "user created successfully." + "response": { + "status_code": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "success": true, + "msg": "user created successfully." + } } } }, { - "name": "create user which existed", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "response": { - "status_code": 500, - "headers": { - "Content-Type": "application/json" + "test": { + "name": "create user which existed", + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "content-type": "application/json" + }, + "json": { + "name": "user1", + "password": "123456" + } }, - "body":{ - "success": false, - "msg": "user already existed." + "response": { + "status_code": 500, + "headers": { + "Content-Type": "application/json" + }, + "body":{ + "success": false, + "msg": "user already existed." + } } } } diff --git a/test/data/simple_demo_no_auth.yml b/test/data/simple_demo_no_auth.yml index 18cc08ebf..7eb49cbd6 100644 --- a/test/data/simple_demo_no_auth.yml +++ b/test/data/simple_demo_no_auth.yml @@ -1,4 +1,4 @@ -- +- test: name: create user which does not exist request: url: http://127.0.0.1:5000/api/users/1000 @@ -16,7 +16,7 @@ success: true msg: user created successfully. -- +- test: name: create user which existed request: url: http://127.0.0.1:5000/api/users/1000 diff --git a/test/test_runner.py b/test/test_runner.py index b8dd377ed..1ba78a780 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -14,10 +14,18 @@ def clear_users(self): url = "http://127.0.0.1:5000/api/users" return requests.delete(url) - def test_run_single_testcase_success(self): + def test_run_single_testcase_yaml_success(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testcases = utils.load_testcases(testcase_file_path) + testcase = testcases[0]["test"] + success, _ = self.test_runner.run_test(testcase) + self.assertTrue(success) + + def test_run_single_testcase_json_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_test(testcases[0]) + testcase = testcases[0]["test"] + success, _ = self.test_runner.run_test(testcase) self.assertTrue(success) def test_run_single_testcase_fail(self): diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index b375d85b1..113d357a0 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -20,14 +20,16 @@ def test_run_single_testcase_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_test(testcases[0]) + testcase = testcases[0]["test"] + success, _ = self.test_runner.run_test(testcase) self.assertTrue(success) def test_run_single_testcase_json(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_test(testcases[0]) + testcase = testcases[0]["test"] + success, _ = self.test_runner.run_test(testcase) self.assertTrue(success) def test_run_testcase_auth_suite_yaml(self): @@ -50,7 +52,7 @@ def test_run_testcase_template_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/demo_template_separate.yml') testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_test(testcases[0]) + success, _ = self.test_runner.run_test(testcases[0]["test"]) self.assertTrue(success) - success, _ = self.test_runner.run_test(testcases[1]) + success, _ = self.test_runner.run_test(testcases[1]["test"]) self.assertFalse(success) diff --git a/test/test_utils.py b/test/test_utils.py index 4aacc8537..960200743 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -17,22 +17,24 @@ def test_load_json_testcases(self): os.getcwd(), 'test/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) self.assertEqual(len(testcases), 2) - self.assertIn('name', testcases[0]) - self.assertIn('request', testcases[0]) - self.assertIn('response', testcases[0]) - self.assertIn('url', testcases[0]['request']) - self.assertIn('method', testcases[0]['request']) + testcase = testcases[0]["test"] + self.assertIn('name', testcase) + self.assertIn('request', testcase) + self.assertIn('response', testcase) + self.assertIn('url', testcase['request']) + self.assertIn('method', testcase['request']) def test_load_yaml_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_no_auth.yml') testcases = utils.load_testcases(testcase_file_path) self.assertEqual(len(testcases), 2) - self.assertIn('name', testcases[0]) - self.assertIn('request', testcases[0]) - self.assertIn('response', testcases[0]) - self.assertIn('url', testcases[0]['request']) - self.assertIn('method', testcases[0]['request']) + testcase = testcases[0]["test"] + self.assertIn('name', testcase) + self.assertIn('request', testcase) + self.assertIn('response', testcase) + self.assertIn('url', testcase['request']) + self.assertIn('method', testcase['request']) def test_parse_response_object_json(self): url = "http://127.0.0.1:5000/api/users" From 378147a83be71e0b1ba1bde01be93ab763e5ada9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 15:47:45 +0800 Subject: [PATCH 046/354] TestRunner: testsets can be configured in public config --- ate/runner.py | 83 +++++++++++++++++++++++----- test/data/demo_template_separate.yml | 4 +- test/data/demo_template_sets.yml | 56 +++++++++++++++++++ test/test_runner_v2.py | 21 ++++--- 4 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 test/data/demo_template_sets.yml diff --git a/ate/runner.py b/ate/runner.py index a9dbc86a9..68867a4ca 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -12,27 +12,53 @@ def __init__(self): self.context = Context() self.testcase_parser = TestcaseParser() - def prepare(self, testcase): - """ prepare work before running test. - parse testcase with variables binds if it is a template. + def pre_config(self, config_dict): + """ create/update variables binds + @param config_dict + { + "requires": ["random", "hashlib"], + "function_binds": { + "gen_random_string": \ + "lambda str_len: ''.join(random.choice(string.ascii_letters + \ + string.digits) for _ in range(str_len))", + "gen_md5": \ + "lambda *str_args: hashlib.md5(''.join(str_args).\ + encode('utf-8')).hexdigest()" + }, + "variable_binds": [ + {"TOKEN": "debugtalk"}, + {"random": {"func": "gen_random_string", "args": [5]}}, + ] + } + @return variables binds mapping + { + "TOKEN": "debugtalk", + "random": "A2dEx" + } """ - requires = testcase.get('requires', []) + requires = config_dict.get('requires', []) self.context.import_requires(requires) - function_binds = testcase.get('function_binds', {}) + function_binds = config_dict.get('function_binds', {}) self.context.bind_functions(function_binds) - variable_binds = testcase.get('variable_binds', []) + variable_binds = config_dict.get('variable_binds', []) self.context.bind_variables(variable_binds) - parsed_testcase = self.testcase_parser.parse( - testcase, - variables_binds=self.context.variables - ) + self.testcase_parser.update_variables_binds(self.context.variables) + + def parse_testcase(self, testcase): + """ parse testcase with variables binds if it is a template. + """ + self.pre_config(testcase) + + parsed_testcase = self.testcase_parser.parse(testcase) return parsed_testcase def run_test(self, testcase): - testcase = self.prepare(testcase) + """ run single testcase. + """ + testcase = self.parse_testcase(testcase) req_kwargs = testcase['request'] @@ -48,7 +74,34 @@ def run_test(self, testcase): return success, diff_content def run_testsets(self, testsets): - return [ - self.run_test(testcase) - for testcase in testsets - ] + """ run testcase suite. + @testsets + [ + { + "config": { + "requires": [], + "function_binds": {}, + "variable_binds": [] + } + }, + { + "test": { + "variable_binds": {}, # override + "request": {}, + "response": {} + } + } + ] + """ + results = [] + for item in testsets: + for key in item: + if key == "config": + config_dict = item[key] + self.pre_config(config_dict) + elif key == "test": + testcase = item[key] + result = self.run_test(testcase) + results.append(result) + + return results diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index 8fef557b1..1aa5b01ea 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -58,5 +58,5 @@ headers: Content-Type: application/json body: - success: true - msg: user created successfully. + success: false + msg: user already existed. diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml new file mode 100644 index 000000000..494cef6b7 --- /dev/null +++ b/test/data/demo_template_sets.yml @@ -0,0 +1,56 @@ +- config: + name: "create user testsets." + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: '{"name": "user", "password": "123456"}' + - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + +- test: + name: create user which does not exist + variable_binds: + - data: '{"name": "user", "password": "123456"}' + - expected_status_code: 201 + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + data: "${data}" + response: + status_code: "${expected_status_code}" + headers: + Content-Type: application/json + body: + success: true + msg: user created successfully. + +- test: + name: create user which does not exist + variable_binds: + - data: '{"name": "user", "password": "123456"}' + - expected_status_code: 500 + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + data: "${data}" + response: + status_code: "${expected_status_code}" + headers: + Content-Type: application/json + body: + success: false + msg: user already existed. diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index 113d357a0..61fce0a03 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -36,17 +36,17 @@ def test_run_testcase_auth_suite_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testsets(testcases) - self.assertEqual(len(result), 2) - self.assertEqual(result, [(True, {}), (True, {})]) + results = self.test_runner.run_testsets(testcases) + self.assertEqual(len(results), 2) + self.assertEqual(results, [(True, {}), (True, {})]) def test_run_testcase_auth_suite_json(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testsets(testcases) - self.assertEqual(len(result), 2) - self.assertEqual(result, [(True, {}), (True, {})]) + results = self.test_runner.run_testsets(testcases) + self.assertEqual(len(results), 2) + self.assertEqual(results, [(True, {}), (True, {})]) def test_run_testcase_template_yaml(self): testcase_file_path = os.path.join( @@ -55,4 +55,11 @@ def test_run_testcase_template_yaml(self): success, _ = self.test_runner.run_test(testcases[0]["test"]) self.assertTrue(success) success, _ = self.test_runner.run_test(testcases[1]["test"]) - self.assertFalse(success) + self.assertTrue(success) + + def test_run_testcase_template_sets_yaml(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/demo_template_sets.yml') + testcases = utils.load_testcases(testcase_file_path) + results = self.test_runner.run_testsets(testcases) + self.assertEqual(results, [(True, {}), (True, {})]) From c31a24d2330e53fae76bd880a85d0549b6e349f4 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 16:22:39 +0800 Subject: [PATCH 047/354] remove unused import --- test/test_runner.py | 1 - test/test_runner_v2.py | 1 - 2 files changed, 2 deletions(-) diff --git a/test/test_runner.py b/test/test_runner.py index 1ba78a780..235850ef7 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -1,5 +1,4 @@ import os -import random import requests from ate import runner, exception, utils from .base import ApiServerUnittest diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index 61fce0a03..3dd22322d 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -1,5 +1,4 @@ import os -import random import requests from ate import runner, exception, utils from .base import ApiServerUnittest From f2bc07ada5f7fcc666438ebc5a35746089b4d512 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 19:46:23 +0800 Subject: [PATCH 048/354] load_testcases_by_path --- ate/utils.py | 44 +++++++++++++++++++++++++++++++++ test/test_utils.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 1add62d5f..2dcd83c8e 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -125,3 +125,47 @@ def diff_response(resp_obj, expected_resp_json): diff_content['body'] = body_diff return diff_content + +def load_foler_files(folder_path): + """ load folder path, return all files in list format. + """ + file_list = [] + + for dirpath, dirnames, filenames in os.walk(folder_path): + for filename in filenames: + file_path = os.path.join(dirpath, filename) + file_list.append(file_path) + + return file_list + +def load_testcases_by_path(path): + """ load testcases from file path + @param path + path could be in several type: + - absolute/relative file path + - absolute/relative folder path + - list/set container with file(s) and/or folder(s) + @return all loaded testcases in a list + """ + testcases_list = [] + + if isinstance(path, (list, set)): + for file_path in set(path): + _testcases_list = load_testcases_by_path(file_path) + for testcase in _testcases_list: + testcases_list.append(testcase) + + return testcases_list + + if not os.path.isabs(path): + path = os.path.join(os.getcwd(), path) + + if os.path.isfile(path): + testcases = load_testcases(path) + testcases_list.extend(testcases) + + if os.path.isdir(path): + files = load_foler_files(path) + testcases_list.extend(load_testcases_by_path(files)) + + return testcases_list diff --git a/test/test_utils.py b/test/test_utils.py index 960200743..f2d5098ab 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -241,3 +241,64 @@ def test_diff_response_body_not_equal_json_unmatch(self): } } ) + + def test_load_foler_files(self): + folder = os.path.join(os.getcwd(), 'test') + files = utils.load_foler_files(folder) + file1 = os.path.join(os.getcwd(), 'test', 'test_utils.py') + file2 = os.path.join(os.getcwd(), 'test', 'data', 'demo_binds.yml') + self.assertIn(file1, files) + self.assertIn(file2, files) + + def test_load_testcases_by_path_files(self): + testcases_list = [] + + # absolute file path + path = os.path.join( + os.getcwd(), 'test/data/simple_demo_no_auth.json') + testcases_list.extend(utils.load_testcases_by_path(path)) + self.assertEqual(len(testcases_list), 2) + + # relative file path + path = 'test/data/simple_demo_no_auth.yml' + testcases_list.extend(utils.load_testcases_by_path(path)) + self.assertEqual(len(testcases_list), 4) + + # list/set container with file(s) + path = [ + os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json'), + 'test/data/simple_demo_no_auth.yml' + ] + testcases_list.extend(utils.load_testcases_by_path(path)) + self.assertEqual(len(testcases_list), 8) + + for testcase in testcases_list: + testcase = testcase["test"] + self.assertIn('name', testcase) + self.assertIn('request', testcase) + self.assertIn('response', testcase) + self.assertIn('url', testcase['request']) + self.assertIn('method', testcase['request']) + + def test_load_testcases_by_path_folder(self): + testcases_list_1 = [] + testcases_list_2 = [] + testcases_list_3 = [] + + # absolute folder path + path = os.path.join(os.getcwd(), 'test/data') + testcases_list_1.extend(utils.load_testcases_by_path(path)) + self.assertGreater(len(testcases_list_1), 10) + + # relative folder path + path = 'test/data/' + testcases_list_2.extend(utils.load_testcases_by_path(path)) + self.assertEqual(len(testcases_list_1), len(testcases_list_2)) + + # list/set container with file(s) + path = [ + os.path.join(os.getcwd(), 'test/data'), + 'test/data/' + ] + testcases_list_3.extend(utils.load_testcases_by_path(path)) + self.assertEqual(len(testcases_list_3), 2 * len(testcases_list_1)) From e7ca4f26fdfedd16eb29464bbb6b3ddf3838394f Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 20:57:22 +0800 Subject: [PATCH 049/354] load_testcases_by_path: return testcase sets list, each testset is corresponding to a file --- ate/utils.py | 29 ++++++++++++------------ test/test_utils.py | 55 +++++++++++++++++++++++++--------------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 2dcd83c8e..7832e9323 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -145,27 +145,28 @@ def load_testcases_by_path(path): - absolute/relative file path - absolute/relative folder path - list/set container with file(s) and/or folder(s) - @return all loaded testcases in a list + @return testcase sets list, each testset is corresponding to a file + [ + [testcase11, testcase12], + [testcase21, testcase22, testcase23] + ] """ - testcases_list = [] - if isinstance(path, (list, set)): + testsets_list = [] + for file_path in set(path): - _testcases_list = load_testcases_by_path(file_path) - for testcase in _testcases_list: - testcases_list.append(testcase) + _testsets_list = load_testcases_by_path(file_path) + testsets_list.extend(_testsets_list) - return testcases_list + return testsets_list if not os.path.isabs(path): path = os.path.join(os.getcwd(), path) - if os.path.isfile(path): - testcases = load_testcases(path) - testcases_list.extend(testcases) - if os.path.isdir(path): - files = load_foler_files(path) - testcases_list.extend(load_testcases_by_path(files)) + files_list = load_foler_files(path) + return load_testcases_by_path(files_list) - return testcases_list + if os.path.isfile(path): + testcases_list = load_testcases(path) + return [testcases_list] diff --git a/test/test_utils.py b/test/test_utils.py index f2d5098ab..a307e1edd 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -251,54 +251,59 @@ def test_load_foler_files(self): self.assertIn(file2, files) def test_load_testcases_by_path_files(self): - testcases_list = [] + testsets_list = [] # absolute file path path = os.path.join( os.getcwd(), 'test/data/simple_demo_no_auth.json') - testcases_list.extend(utils.load_testcases_by_path(path)) - self.assertEqual(len(testcases_list), 2) + testset_list = utils.load_testcases_by_path(path) + self.assertEqual(len(testset_list), 1) + self.assertEqual(len(testset_list[0]), 2) + testsets_list.extend(testset_list) # relative file path path = 'test/data/simple_demo_no_auth.yml' - testcases_list.extend(utils.load_testcases_by_path(path)) - self.assertEqual(len(testcases_list), 4) + testset_list = utils.load_testcases_by_path(path) + self.assertEqual(len(testset_list), 1) + self.assertEqual(len(testset_list[0]), 2) + testsets_list.extend(testset_list) # list/set container with file(s) path = [ os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json'), 'test/data/simple_demo_no_auth.yml' ] - testcases_list.extend(utils.load_testcases_by_path(path)) - self.assertEqual(len(testcases_list), 8) - - for testcase in testcases_list: - testcase = testcase["test"] - self.assertIn('name', testcase) - self.assertIn('request', testcase) - self.assertIn('response', testcase) - self.assertIn('url', testcase['request']) - self.assertIn('method', testcase['request']) + testset_list = utils.load_testcases_by_path(path) + self.assertEqual(len(testset_list), 2) + self.assertEqual(len(testset_list[0]), 2) + self.assertEqual(len(testset_list[1]), 2) + testsets_list.extend(testset_list) + self.assertEqual(len(testsets_list), 4) + + for testset in testsets_list: + for testcase in testset: + testcase = testcase["test"] + self.assertIn('name', testcase) + self.assertIn('request', testcase) + self.assertIn('response', testcase) + self.assertIn('url', testcase['request']) + self.assertIn('method', testcase['request']) def test_load_testcases_by_path_folder(self): - testcases_list_1 = [] - testcases_list_2 = [] - testcases_list_3 = [] - # absolute folder path path = os.path.join(os.getcwd(), 'test/data') - testcases_list_1.extend(utils.load_testcases_by_path(path)) - self.assertGreater(len(testcases_list_1), 10) + testset_list_1 = utils.load_testcases_by_path(path) + self.assertGreater(len(testset_list_1), 6) # relative folder path path = 'test/data/' - testcases_list_2.extend(utils.load_testcases_by_path(path)) - self.assertEqual(len(testcases_list_1), len(testcases_list_2)) + testset_list_2 = utils.load_testcases_by_path(path) + self.assertEqual(len(testset_list_1), len(testset_list_2)) # list/set container with file(s) path = [ os.path.join(os.getcwd(), 'test/data'), 'test/data/' ] - testcases_list_3.extend(utils.load_testcases_by_path(path)) - self.assertEqual(len(testcases_list_3), 2 * len(testcases_list_1)) + testset_list_3 = utils.load_testcases_by_path(path) + self.assertEqual(len(testset_list_3), 2 * len(testset_list_1)) From a7155ce22a23dc372751eadfc90e747dbc14f634 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 21:16:26 +0800 Subject: [PATCH 050/354] add codecov --- .travis.yml | 2 ++ requirements.txt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 02b1e44af..01406b8ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,3 +10,5 @@ install: - pip install -r requirements.txt script: - python -m unittest discover +after_success: + - codecov \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2536011cf..6c8711197 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ requests termcolor flask -PyYAML \ No newline at end of file +PyYAML +codecov \ No newline at end of file From f1a2ec13973099a6160f5c36e392004d2768d4ff Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 21:47:57 +0800 Subject: [PATCH 051/354] coverage: replace codecov with coveralls --- .travis.yml | 4 ++-- README.md | 1 + requirements.txt | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 01406b8ef..d8cd44e63 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,6 @@ python: install: - pip install -r requirements.txt script: - - python -m unittest discover + - coverage run -m unittest discover after_success: - - codecov \ No newline at end of file + - coveralls \ No newline at end of file diff --git a/README.md b/README.md index 01afbcbd4..ff6149566 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # ApiTestEngine [![Build Status](https://travis-ci.org/debugtalk/ApiTestEngine.svg?branch=master)](https://travis-ci.org/debugtalk/ApiTestEngine) +[![Coverage Status](https://coveralls.io/repos/github/debugtalk/ApiTestEngine/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/ApiTestEngine?branch=master) ## 核心特性 diff --git a/requirements.txt b/requirements.txt index 6c8711197..e1615c3df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ requests termcolor flask PyYAML -codecov \ No newline at end of file +coveralls +coverage \ No newline at end of file From 99a6051c24aac01fe93d3799cefb7afe97e6abf7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 22:06:15 +0800 Subject: [PATCH 052/354] update coverage --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d8cd44e63..1268b3b09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ python: install: - pip install -r requirements.txt script: - - coverage run -m unittest discover + - coverage run --source=. -m unittest discover after_success: + - coverage report -m - coveralls \ No newline at end of file From e9dfb03026bff2689bfb56d5f2ca278e80591d44 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 26 Jun 2017 22:12:50 +0800 Subject: [PATCH 053/354] ignore .coverage --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 58c55ee73..5a1d27b40 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build/* dist/* *.egg-info .python-version -logs/% \ No newline at end of file +logs/% +.coverage \ No newline at end of file From e8845ba3adb62b2bd1b84a7d85255cbb3edc1a23 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 11:18:52 +0800 Subject: [PATCH 054/354] update coverage --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1268b3b09..a5a29e036 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,6 @@ python: install: - pip install -r requirements.txt script: - - coverage run --source=. -m unittest discover + - coverage run --source=ate -m unittest discover after_success: - - coverage report -m - coveralls \ No newline at end of file From 0cdd1d9c1eec33ba0c54894769a79b92b3fb96e8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 11:57:26 +0800 Subject: [PATCH 055/354] load_testcases_by_path: testset has a config dict and a testcases list --- ate/utils.py | 18 +++++++++++++++--- test/test_utils.py | 11 +++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 7832e9323..59eddd65f 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -147,8 +147,8 @@ def load_testcases_by_path(path): - list/set container with file(s) and/or folder(s) @return testcase sets list, each testset is corresponding to a file [ - [testcase11, testcase12], - [testcase21, testcase22, testcase23] + {"config": {}, "testcases": [testcase11, testcase12]}, + {"config": {}, "testcases": [testcase21, testcase22, testcase23]}, ] """ if isinstance(path, (list, set)): @@ -168,5 +168,17 @@ def load_testcases_by_path(path): return load_testcases_by_path(files_list) if os.path.isfile(path): + testset = { + "config": {}, + "testcases": [] + } testcases_list = load_testcases(path) - return [testcases_list] + + for item in testcases_list: + for key in item: + if key == "config": + testset["config"] = item["config"] + elif key == "test": + testset["testcases"].append(item["test"]) + + return [testset] diff --git a/test/test_utils.py b/test/test_utils.py index a307e1edd..aaef004eb 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -258,14 +258,14 @@ def test_load_testcases_by_path_files(self): os.getcwd(), 'test/data/simple_demo_no_auth.json') testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) - self.assertEqual(len(testset_list[0]), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 2) testsets_list.extend(testset_list) # relative file path path = 'test/data/simple_demo_no_auth.yml' testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) - self.assertEqual(len(testset_list[0]), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 2) testsets_list.extend(testset_list) # list/set container with file(s) @@ -275,14 +275,13 @@ def test_load_testcases_by_path_files(self): ] testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 2) - self.assertEqual(len(testset_list[0]), 2) - self.assertEqual(len(testset_list[1]), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 2) + self.assertEqual(len(testset_list[1]["testcases"]), 2) testsets_list.extend(testset_list) self.assertEqual(len(testsets_list), 4) for testset in testsets_list: - for testcase in testset: - testcase = testcase["test"] + for testcase in testset["testcases"]: self.assertIn('name', testcase) self.assertIn('request', testcase) self.assertIn('response', testcase) From de218878d7ad64d81a46cc50faea4bfcb3aaee49 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 12:11:40 +0800 Subject: [PATCH 056/354] run_testsets: optimize testsets structure --- ate/runner.py | 36 ++++++++++++++++++++---------------- test/test_runner.py | 16 ++++++++-------- test/test_runner_v2.py | 14 ++++++++------ 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 68867a4ca..19e265084 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -82,26 +82,30 @@ def run_testsets(self, testsets): "requires": [], "function_binds": {}, "variable_binds": [] - } + }, + "testcases": [ + { + "variable_binds": {}, # override + "request": {}, + "response": {} + }, + testcase12 + ] }, { - "test": { - "variable_binds": {}, # override - "request": {}, - "response": {} - } - } + "config": {}, + "testcases": [testcase21, testcase22, testcase23] + }, ] """ results = [] - for item in testsets: - for key in item: - if key == "config": - config_dict = item[key] - self.pre_config(config_dict) - elif key == "test": - testcase = item[key] - result = self.run_test(testcase) - results.append(result) + + for testset in testsets: + config_dict = testset.get("config", {}) + self.pre_config(config_dict) + testcases = testset.get("testcases", []) + for testcase in testcases: + result = self.run_test(testcase) + results.append(result) return results diff --git a/test/test_runner.py b/test/test_runner.py index 235850ef7..a29c58d34 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -78,14 +78,14 @@ def test_run_single_testcase_fail(self): def test_run_testcase_suite_json_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') - testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testsets(testcases) - self.assertEqual(len(result), 2) - self.assertEqual(result, [(True, {}), (True, {})]) + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 2) + self.assertEqual(results, [(True, {}), (True, {})]) def test_run_testcase_suite_yaml_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') - testcases = utils.load_testcases(testcase_file_path) - result = self.test_runner.run_testsets(testcases) - self.assertEqual(len(result), 2) - self.assertEqual(result, [(True, {}), (True, {})]) + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 2) + self.assertEqual(results, [(True, {}), (True, {})]) diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index 3dd22322d..f2e3a4730 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -34,16 +34,17 @@ def test_run_single_testcase_json(self): def test_run_testcase_auth_suite_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') - testcases = utils.load_testcases(testcase_file_path) - results = self.test_runner.run_testsets(testcases) + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) + def test_run_testcase_auth_suite_json(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') - testcases = utils.load_testcases(testcase_file_path) - results = self.test_runner.run_testsets(testcases) + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) @@ -59,6 +60,7 @@ def test_run_testcase_template_yaml(self): def test_run_testcase_template_sets_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/demo_template_sets.yml') - testcases = utils.load_testcases(testcase_file_path) - results = self.test_runner.run_testsets(testcases) + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) From fde490172454fd62271e161baae76bdc2eef590f Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 14:16:26 +0800 Subject: [PATCH 057/354] run_test: add doc string --- ate/runner.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ate/runner.py b/ate/runner.py index 19e265084..0ed657644 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -57,6 +57,15 @@ def parse_testcase(self, testcase): def run_test(self, testcase): """ run single testcase. + @testcase + { + "name": "testcase description", + "requires": [], # optional, override + "function_binds": {}, # optional, override + "variable_binds": {}, # optional, override + "request": {}, + "response": {} + } """ testcase = self.parse_testcase(testcase) @@ -78,6 +87,7 @@ def run_testsets(self, testsets): @testsets [ { + "name": "testset description", "config": { "requires": [], "function_binds": {}, @@ -85,6 +95,7 @@ def run_testsets(self, testsets): }, "testcases": [ { + "name": "testcase description", "variable_binds": {}, # override "request": {}, "response": {} @@ -93,6 +104,7 @@ def run_testsets(self, testsets): ] }, { + "name": "XXX", "config": {}, "testcases": [testcase21, testcase22, testcase23] }, From 9da315620fb5959a9d9b708294d6953faae06d6e Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 16:12:21 +0800 Subject: [PATCH 058/354] update function doc strings --- ate/runner.py | 105 ++++++++++++++++++++++++++--------------- ate/utils.py | 6 ++- test/test_runner.py | 22 +++++++-- test/test_runner_v2.py | 35 +++++++++++--- 4 files changed, 119 insertions(+), 49 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 0ed657644..ea8650a16 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -16,6 +16,7 @@ def pre_config(self, config_dict): """ create/update variables binds @param config_dict { + "name": "description content", "requires": ["random", "hashlib"], "function_binds": { "gen_random_string": \ @@ -30,11 +31,6 @@ def pre_config(self, config_dict): {"random": {"func": "gen_random_string", "args": [5]}}, ] } - @return variables binds mapping - { - "TOKEN": "debugtalk", - "random": "A2dEx" - } """ requires = config_dict.get('requires', []) self.context.import_requires(requires) @@ -49,6 +45,20 @@ def pre_config(self, config_dict): def parse_testcase(self, testcase): """ parse testcase with variables binds if it is a template. + @param (dict) testcase + { + "name": "testcase description", + "requires": [], # optional, override + "function_binds": {}, # optional, override + "variable_binds": {}, # optional, override + "request": {}, + "response": {} + } + @return (dict) variables binds mapping + { + "TOKEN": "debugtalk", + "random": "A2dEx" + } """ self.pre_config(testcase) @@ -57,7 +67,7 @@ def parse_testcase(self, testcase): def run_test(self, testcase): """ run single testcase. - @testcase + @param (dict) testcase { "name": "testcase description", "requires": [], # optional, override @@ -66,6 +76,8 @@ def run_test(self, testcase): "request": {}, "response": {} } + @return (tuple) test result of single testcase + (success, diff_content) """ testcase = self.parse_testcase(testcase) @@ -82,42 +94,61 @@ def run_test(self, testcase): success = False if diff_content else True return success, diff_content - def run_testsets(self, testsets): - """ run testcase suite. - @testsets - [ - { + def run_testset(self, testset): + """ run single testset, including one or several testcases. + @param (dict) testset + { + "name": "testset description", + "config": { "name": "testset description", - "config": { - "requires": [], - "function_binds": {}, - "variable_binds": [] - }, - "testcases": [ - { - "name": "testcase description", - "variable_binds": {}, # override - "request": {}, - "response": {} - }, - testcase12 - ] - }, - { - "name": "XXX", - "config": {}, - "testcases": [testcase21, testcase22, testcase23] + "requires": [], + "function_binds": {}, + "variable_binds": [] }, + "testcases": [ + { + "name": "testcase description", + "variable_binds": {}, # override + "request": {}, + "response": {} + }, + testcase12 + ] + } + @return (list) test results of testcases + [ + (success, diff_content), # testcase1 + (success, diff_content) # testcase2 ] """ results = [] - for testset in testsets: - config_dict = testset.get("config", {}) - self.pre_config(config_dict) - testcases = testset.get("testcases", []) - for testcase in testcases: - result = self.run_test(testcase) - results.append(result) + config_dict = testset.get("config", {}) + self.pre_config(config_dict) + testcases = testset.get("testcases", []) + for testcase in testcases: + result = self.run_test(testcase) + results.append(result) return results + + def run_testsets(self, testsets): + """ run testsets, including one or several testsets. + @param testsets + [ + testset1, + testset2, + ] + @return (list) test results of testsets + [ + [ # testset1 + (success, diff_content), # testcase11 + (success, diff_content) # testcase12 + ], + [ # testset2 + (success, diff_content), # testcase21 + (success, diff_content) # testcase22 + ] + ] + """ + return [self.run_testset(testset) for testset in testsets] diff --git a/ate/utils.py b/ate/utils.py index 59eddd65f..a42e313dc 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -147,8 +147,8 @@ def load_testcases_by_path(path): - list/set container with file(s) and/or folder(s) @return testcase sets list, each testset is corresponding to a file [ - {"config": {}, "testcases": [testcase11, testcase12]}, - {"config": {}, "testcases": [testcase21, testcase22, testcase23]}, + {"name": "desc1", "config": {}, "testcases": [testcase11, testcase12]}, + {"name": "desc2", "config": {}, "testcases": [testcase21, testcase22, testcase23]}, ] """ if isinstance(path, (list, set)): @@ -169,6 +169,7 @@ def load_testcases_by_path(path): if os.path.isfile(path): testset = { + "name": "", "config": {}, "testcases": [] } @@ -178,6 +179,7 @@ def load_testcases_by_path(path): for key in item: if key == "config": testset["config"] = item["config"] + testset["name"] = item["config"].get("name", "") elif key == "test": testset["testcases"].append(item["test"]) diff --git a/test/test_runner.py b/test/test_runner.py index a29c58d34..595d9a462 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -76,16 +76,30 @@ def test_run_single_testcase_fail(self): } ) - def test_run_testcase_suite_json_success(self): + def test_run_testset_json_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) + results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) - def test_run_testcase_suite_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + def test_run_testsets_json_success(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results[0], [(True, {}), (True, {})]) + + def test_run_testset_yaml_success(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) + + def test_run_testsets_yaml_success(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results[0], [(True, {}), (True, {})]) diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index f2e3a4730..b80d951d7 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -31,23 +31,38 @@ def test_run_single_testcase_json(self): success, _ = self.test_runner.run_test(testcase) self.assertTrue(success) - def test_run_testcase_auth_suite_yaml(self): + def test_run_testset_auth_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) + results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) + def test_run_testsets_auth_yaml(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results[0], [(True, {}), (True, {})]) - def test_run_testcase_auth_suite_json(self): + def test_run_testset_auth_json(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) + results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) + def test_run_testsets_auth_json(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results[0], [(True, {}), (True, {})]) + def test_run_testcase_template_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/demo_template_separate.yml') @@ -57,10 +72,18 @@ def test_run_testcase_template_yaml(self): success, _ = self.test_runner.run_test(testcases[1]["test"]) self.assertTrue(success) - def test_run_testcase_template_sets_yaml(self): + def test_run_testset_template_yaml(self): testcase_file_path = os.path.join( os.getcwd(), 'test/data/demo_template_sets.yml') testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) + results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, {}), (True, {})]) + + def test_run_testsets_template_yaml(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/demo_template_sets.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results[0], [(True, {}), (True, {})]) From 17d6fea2187ca3a710a94b0a06f7d17575fbc550 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 18:30:09 +0800 Subject: [PATCH 059/354] load_testcases_by_path: ignore file path that is not JSON or YAML --- ate/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index a42e313dc..57f141f19 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -173,7 +173,10 @@ def load_testcases_by_path(path): "config": {}, "testcases": [] } - testcases_list = load_testcases(path) + try: + testcases_list = load_testcases(path) + except ParamsError: + return [] for item in testcases_list: for key in item: From db9f42708803063d14fca29e92abf3c39f400a94 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 18:46:14 +0800 Subject: [PATCH 060/354] change import mode --- test/base.py | 2 +- test/test_apiserver.py | 2 +- test/test_apiserver_v2.py | 2 +- test/test_runner.py | 2 +- test/test_runner_v2.py | 2 +- test/test_utils.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/base.py b/test/base.py index 3368bc214..36be48df4 100644 --- a/test/base.py +++ b/test/base.py @@ -3,7 +3,7 @@ import unittest from ate import utils -from . import api_server +from test import api_server class ApiServerUnittest(unittest.TestCase): diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 36076abf1..9daf0b867 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -1,6 +1,6 @@ import requests import random -from .base import ApiServerUnittest +from test.base import ApiServerUnittest class TestApiServer(ApiServerUnittest): def setUp(self): diff --git a/test/test_apiserver_v2.py b/test/test_apiserver_v2.py index 8df44d1c7..57d47be27 100644 --- a/test/test_apiserver_v2.py +++ b/test/test_apiserver_v2.py @@ -1,7 +1,7 @@ import random import requests -from .base import ApiServerUnittest +from test.base import ApiServerUnittest class TestApiServerV2(ApiServerUnittest): diff --git a/test/test_runner.py b/test/test_runner.py index 595d9a462..c767e1853 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -1,7 +1,7 @@ import os import requests from ate import runner, exception, utils -from .base import ApiServerUnittest +from test.base import ApiServerUnittest class TestRunner(ApiServerUnittest): diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index b80d951d7..c8a764a5e 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -1,7 +1,7 @@ import os import requests from ate import runner, exception, utils -from .base import ApiServerUnittest +from test.base import ApiServerUnittest class TestRunnerV2(ApiServerUnittest): diff --git a/test/test_utils.py b/test/test_utils.py index aaef004eb..6130adb84 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -3,7 +3,7 @@ import requests from ate import utils from ate import exception -from .base import ApiServerUnittest +from test.base import ApiServerUnittest class TestUtils(ApiServerUnittest): From dc1ea46036c641cecd7a8bb8cb9a1d968dbcb170 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 19:17:48 +0800 Subject: [PATCH 061/354] load_testcases_by_path: make compatible with none exist path --- ate/utils.py | 5 ++++- test/test_utils.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index 57f141f19..6c10da684 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -167,7 +167,7 @@ def load_testcases_by_path(path): files_list = load_foler_files(path) return load_testcases_by_path(files_list) - if os.path.isfile(path): + elif os.path.isfile(path): testset = { "name": "", "config": {}, @@ -187,3 +187,6 @@ def load_testcases_by_path(path): testset["testcases"].append(item["test"]) return [testset] + + else: + return [] diff --git a/test/test_utils.py b/test/test_utils.py index 6130adb84..6e1dc0937 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -306,3 +306,22 @@ def test_load_testcases_by_path_folder(self): ] testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list_3), 2 * len(testset_list_1)) + + def test_load_testcases_by_path_not_exist(self): + # absolute folder path + path = os.path.join(os.getcwd(), 'test/data_not_exist') + testset_list_1 = utils.load_testcases_by_path(path) + self.assertEqual(testset_list_1, []) + + # relative folder path + path = 'test/data_not_exist' + testset_list_2 = utils.load_testcases_by_path(path) + self.assertEqual(testset_list_2, []) + + # list/set container with file(s) + path = [ + os.path.join(os.getcwd(), 'test/data_not_exist'), + 'test/data_not_exist/' + ] + testset_list_3 = utils.load_testcases_by_path(path) + self.assertEqual(testset_list_3, []) From b0e11e6b9bd690f36e92c4963dd5e7dda0f51810 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 19:29:44 +0800 Subject: [PATCH 062/354] start entrance --- ate/main.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ test/test_main.py | 30 +++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 ate/main.py create mode 100644 test/test_main.py diff --git a/ate/main.py b/ate/main.py new file mode 100644 index 000000000..389775088 --- /dev/null +++ b/ate/main.py @@ -0,0 +1,62 @@ +import argparse +import unittest + +from ate import runner, utils + + +class ApiTestCase(unittest.TestCase): + """ create a testcase. + """ + def __init__(self, test_runner, testcase): + super(ApiTestCase, self).__init__() + self.test_runner = test_runner + self.testcase = testcase + + def runTest(self): + """ run testcase and check result. + """ + result = self.test_runner.run_test(self.testcase) + self.assertEqual(result, (True, {})) + +def create_suite(testset): + """ create test suite with a testset, it may include one or several testcases. + each suite should initialize a seperate TestRunner() with testset config. + """ + suite = unittest.TestSuite() + + test_runner = runner.TestRunner() + config_dict = testset.get("config", {}) + test_runner.pre_config(config_dict) + testcases = testset.get("testcases", []) + + for testcase in testcases: + test = ApiTestCase(test_runner, testcase) + suite.addTest(test) + + return suite + +def create_task(testcase_path): + """ create test task suite with specified testcase path. + each task suite may include one or several test suite. + """ + task_suite = unittest.TestSuite() + testsets = utils.load_testcases_by_path(testcase_path) + + for testset in testsets: + suite = create_suite(testset) + task_suite.addTest(suite) + + return task_suite + +def main(): + """ parse command line options and run commands. + """ + parser = argparse.ArgumentParser( + description='Api Test Engine.') + parser.add_argument( + '--testcase-path', default='testcases', + help="testcase file path") + + args = parser.parse_args() + task_suite = create_task(args.testcase_path) + unittest.TextTestRunner().run(task_suite) diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 000000000..8e335a716 --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,30 @@ +import os +import random +import requests +from test.base import ApiServerUnittest +from ate import main, utils + +class TestMain(ApiServerUnittest): + + def setUp(self): + self.clear_users() + + def clear_users(self): + url = "http://127.0.0.1:5000/api/users" + return requests.delete(url) + + def test_create_suite(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + suite = main.create_suite(testsets[0]) + self.assertEqual(suite.countTestCases(), 2) + for testcase in suite: + self.assertIsInstance(testcase, main.ApiTestCase) + + def test_create_task(self): + testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + task_suite = main.create_task(testcase_file_path) + self.assertEqual(task_suite.countTestCases(), 2) + for suite in task_suite: + for testcase in suite: + self.assertIsInstance(testcase, main.ApiTestCase) From 93a9872a6c18ff848a00f71de9a4c8643e4ed7dd Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 27 Jun 2017 23:56:59 +0800 Subject: [PATCH 063/354] make variables marker unified to be --- ate/context.py | 9 ++++----- ate/testcase.py | 20 ++------------------ ate/utils.py | 22 +++++++++++++++++++++- test/data/demo_binds.yml | 4 ++-- test/data/demo_template_separate.yml | 4 ++-- test/data/demo_template_sets.yml | 2 +- test/test_context.py | 2 +- 7 files changed, 33 insertions(+), 30 deletions(-) diff --git a/ate/context.py b/ate/context.py index 44e09e71a..3499b6f7c 100644 --- a/ate/context.py +++ b/ate/context.py @@ -1,4 +1,6 @@ +import re import importlib +from ate import exception, utils class Context(object): """ Manages binding of variables @@ -44,13 +46,10 @@ def bind_variables(self, variable_binds): def get_eval_value(self, data): """ evaluate data recursively, each variable in data will be evaluated. - variable will always be a string started with $, such as $token + variables marker: ${variable}. """ if isinstance(data, str): - if data.startswith('$'): - # this is a variable, and will replace with its bind value - return self.variables.get(data[1:]) - return data + return utils.parse_content_with_variables(data, self.variables) if isinstance(data, list): return [self.get_eval_value(item) for item in data] diff --git a/ate/testcase.py b/ate/testcase.py index c245a07b8..0fa22929a 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,5 +1,4 @@ -import re -from ate import exception +from ate import utils class TestcaseParser(object): @@ -44,22 +43,7 @@ def substitute(self, content): variables marker: ${variable}. """ if isinstance(content, str): - # check if content includes ${variable} - matched = re.match(r"(.*)\$\{(.*)\}(.*)", content) - if matched: - # this is a variable, and will replace with its bind value - variable_name = matched.group(2) - value = self.variables_binds.get(variable_name) - if value is None: - raise exception.ParamsError( - "%s is not defined in bind variables!" % variable_name) - if matched.group(1) or matched.group(3): - # e.g. /api/users/${uid} - return re.sub(r"\$\{.*\}", value, content) - - return value - - return content + return utils.parse_content_with_variables(content, self.variables_binds) if isinstance(content, list): return [self.substitute(item) for item in content] diff --git a/ate/utils.py b/ate/utils.py index 6c10da684..aa2d8a905 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -2,8 +2,8 @@ import json import os.path import random +import re import string - import yaml from ate.exception import ParamsError @@ -190,3 +190,23 @@ def load_testcases_by_path(path): else: return [] + +def parse_content_with_variables(content, variables_binds): + """ replace variables with bind value + """ + # check if content includes ${variable} + matched = re.match(r"(.*)\$\{(.*)\}(.*)", content) + if matched: + # this is a variable, and will replace with its bind value + variable_name = matched.group(2) + value = variables_binds.get(variable_name) + if value is None: + raise ParamsError( + "%s is not defined in bind variables!" % variable_name) + if matched.group(1) or matched.group(3): + # e.g. /api/users/${uid} + return re.sub(r"\$\{.*\}", value, content) + + return value + + return content diff --git a/test/data/demo_binds.yml b/test/data/demo_binds.yml index 0de0a1352..0ac489423 100644 --- a/test/data/demo_binds.yml +++ b/test/data/demo_binds.yml @@ -13,7 +13,7 @@ - variable_binds: - TOKEN: "debugtalk" - - token: $TOKEN + - token: ${TOKEN} - function_binds: @@ -35,4 +35,4 @@ - TOKEN: debugtalk - random: {"func": "gen_random_string", "args": [5]} - data: "{'name': 'user', 'password': '123456'}" - - authorization: {"func": "gen_md5", "args": [$TOKEN, $data, $random]} + - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index 1aa5b01ea..bd0253172 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -12,7 +12,7 @@ - TOKEN: debugtalk - random: {"func": "gen_random_string", "args": [5]} - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} - expected_status_code: 201 request: url: http://127.0.0.1:5000/api/users/1000 @@ -43,7 +43,7 @@ - TOKEN: debugtalk - random: {"func": "gen_random_string", "args": [5]} - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} - expected_status_code: 500 request: url: http://127.0.0.1:5000/api/users/1000 diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 494cef6b7..2b33dd387 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -11,7 +11,7 @@ - TOKEN: debugtalk - random: {"func": "gen_random_string", "args": [5]} - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]} + - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} - test: name: create user which does not exist diff --git a/test/test_context.py b/test/test_context.py index d7092a644..fe6cbf407 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -69,7 +69,7 @@ def test_context_variable_variable(self): testcase1 = { "variable_binds": [ {"GLOBAL_TOKEN": "debugtalk"}, - {"token": "$GLOBAL_TOKEN"} + {"token": "${GLOBAL_TOKEN}"} ] } testcase2 = self.testcases[3] From 884462a767a25caca1a698729acfd5da657cecd6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 00:02:44 +0800 Subject: [PATCH 064/354] add unittest: test_parse_content_with_variables --- test/test_utils.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/test_utils.py b/test/test_utils.py index 6e1dc0937..e2b173e6d 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -325,3 +325,25 @@ def test_load_testcases_by_path_not_exist(self): ] testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_3, []) + + def test_parse_content_with_variables(self): + content = "${var}" + variables_binds = { + "var": "abc" + } + result = utils.parse_content_with_variables(content, variables_binds) + self.assertEqual(result, "abc") + + content = "123${var}456" + variables_binds = { + "var": "abc" + } + result = utils.parse_content_with_variables(content, variables_binds) + self.assertEqual(result, "123abc456") + + content = "${var1}" + variables_binds = { + "var2": "abc" + } + with self.assertRaises(exception.ParamsError): + utils.parse_content_with_variables(content, variables_binds) From 2391f54d2c94839b96609f8b904ec7af02f3d23a Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 11:43:23 +0800 Subject: [PATCH 065/354] update doc string --- ate/runner.py | 17 ++++++++++++++--- ate/testcase.py | 45 ++++++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index ea8650a16..e655200eb 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -54,10 +54,21 @@ def parse_testcase(self, testcase): "request": {}, "response": {} } - @return (dict) variables binds mapping + @return (dict) parsed testcase with bind values { - "TOKEN": "debugtalk", - "random": "A2dEx" + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "body": '{"name": "user", "password": "123456"}' + }, + "response": { + "status_code": 201 + } } """ self.pre_config(testcase) diff --git a/ate/testcase.py b/ate/testcase.py index 0fa22929a..41d90e012 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -14,25 +14,36 @@ def update_variables_binds(self, variables_mapping): def parse(self, testcase_template): """ parse testcase_template, replace all variables with bind value. variables marker: ${variable}. - @param testcase_template - "request": { - "url": "http://127.0.0.1:5000/api/users/${uid}", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "authorization": "${authorization}", - "random": "${random}" + @param (dict) testcase_template + { + "request": { + "url": "http://127.0.0.1:5000/api/users/${uid}", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "${authorization}", + "random": "${random}" + }, + "body": "${data}" }, - "body": "${json}" - }, - "response": { - "status_code": "${expected_status}", - "headers": { - "Content-Type": "application/json" + "response": { + "status_code": "${expected_status}" + } + } + @return (dict) parsed testcase with bind values + { + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "body": '{"name": "user", "password": "123456"}' }, - "body": { - "success": True, - "msg": "user created successfully." + "response": { + "status_code": 201 } } """ From e538a86669c01d9acf1b06e23d4231eb97057997 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 15:29:40 +0800 Subject: [PATCH 066/354] move response relevant code to ate/response.py --- ate/response.py | 53 +++++++++++ ate/runner.py | 4 +- ate/utils.py | 51 ---------- test/test_response.py | 212 ++++++++++++++++++++++++++++++++++++++++++ test/test_utils.py | 208 ----------------------------------------- 5 files changed, 267 insertions(+), 261 deletions(-) create mode 100644 ate/response.py create mode 100644 test/test_response.py diff --git a/ate/response.py b/ate/response.py new file mode 100644 index 000000000..4a37ddce6 --- /dev/null +++ b/ate/response.py @@ -0,0 +1,53 @@ +from ate import utils + + +def parse_response_object(resp_obj): + try: + resp_body = resp_obj.json() + except ValueError: + resp_body = resp_obj.text + + return { + 'status_code': resp_obj.status_code, + 'headers': resp_obj.headers, + 'body': resp_body + } + +def diff_response(resp_obj, expected_resp_json): + diff_content = {} + resp_info = parse_response_object(resp_obj) + + expected_status_code = expected_resp_json.get('status_code', 200) + if resp_info['status_code'] != int(expected_status_code): + diff_content['status_code'] = { + 'value': resp_info['status_code'], + 'expected': expected_status_code + } + + expected_headers = expected_resp_json.get('headers', {}) + headers_diff = utils.diff_json(resp_info['headers'], expected_headers) + if headers_diff: + diff_content['headers'] = headers_diff + + expected_body = expected_resp_json.get('body', None) + + if expected_body is None: + body_diff = {} + elif type(expected_body) != type(resp_info['body']): + body_diff = { + 'value': resp_info['body'], + 'expected': expected_body + } + elif isinstance(expected_body, str): + if expected_body != resp_info['body']: + body_diff = { + 'value': resp_info['body'], + 'expected': expected_body + } + elif isinstance(expected_body, dict): + body_diff = utils.diff_json(resp_info['body'], expected_body) + + if body_diff: + diff_content['body'] = body_diff + + return diff_content diff --git a/ate/runner.py b/ate/runner.py index e655200eb..c4987bb45 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,6 +1,6 @@ import requests -from ate import exception, utils +from ate import exception, response from ate.context import Context from ate.testcase import TestcaseParser @@ -101,7 +101,7 @@ def run_test(self, testcase): raise exception.ParamsError("URL or METHOD missed!") resp_obj = self.client.request(url=url, method=method, **req_kwargs) - diff_content = utils.diff_response(resp_obj, testcase['response']) + diff_content = response.diff_response(resp_obj, testcase['response']) success = False if diff_content else True return success, diff_content diff --git a/ate/utils.py b/ate/utils.py index aa2d8a905..221ad47ab 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -62,18 +62,6 @@ def load_testcases(testcase_file_path): # '' or other suffix raise ParamsError("Bad testcase file name!") -def parse_response_object(resp_obj): - try: - resp_body = resp_obj.json() - except ValueError: - resp_body = resp_obj.text - - return { - 'status_code': resp_obj.status_code, - 'headers': resp_obj.headers, - 'body': resp_body - } - def diff_json(current_json, expected_json): json_diff = {} @@ -87,45 +75,6 @@ def diff_json(current_json, expected_json): return json_diff -def diff_response(resp_obj, expected_resp_json): - diff_content = {} - resp_info = parse_response_object(resp_obj) - - expected_status_code = expected_resp_json.get('status_code', 200) - if resp_info['status_code'] != int(expected_status_code): - diff_content['status_code'] = { - 'value': resp_info['status_code'], - 'expected': expected_status_code - } - - expected_headers = expected_resp_json.get('headers', {}) - headers_diff = diff_json(resp_info['headers'], expected_headers) - if headers_diff: - diff_content['headers'] = headers_diff - - expected_body = expected_resp_json.get('body', None) - - if expected_body is None: - body_diff = {} - elif type(expected_body) != type(resp_info['body']): - body_diff = { - 'value': resp_info['body'], - 'expected': expected_body - } - elif isinstance(expected_body, str): - if expected_body != resp_info['body']: - body_diff = { - 'value': resp_info['body'], - 'expected': expected_body - } - elif isinstance(expected_body, dict): - body_diff = diff_json(resp_info['body'], expected_body) - - if body_diff: - diff_content['body'] = body_diff - - return diff_content - def load_foler_files(folder_path): """ load folder path, return all files in list format. """ diff --git a/test/test_response.py b/test/test_response.py new file mode 100644 index 000000000..97a788d1e --- /dev/null +++ b/test/test_response.py @@ -0,0 +1,212 @@ +import random +import requests +from ate import response +from test.base import ApiServerUnittest + +class TestUtils(ApiServerUnittest): + + def test_parse_response_object_json(self): + url = "http://127.0.0.1:5000/api/users" + resp_obj = requests.get(url) + parse_result = response.parse_response_object(resp_obj) + self.assertIn('status_code', parse_result) + self.assertIn('headers', parse_result) + self.assertIn('body', parse_result) + self.assertIn('Content-Type', parse_result['headers']) + self.assertIn('Content-Length', parse_result['headers']) + self.assertIn('success', parse_result['body']) + + def test_parse_response_object_text(self): + url = "http://127.0.0.1:5000/" + resp_obj = requests.get(url) + parse_result = response.parse_response_object(resp_obj) + self.assertIn('status_code', parse_result) + self.assertIn('headers', parse_result) + self.assertIn('body', parse_result) + self.assertIn('Content-Type', parse_result['headers']) + self.assertIn('Content-Length', parse_result['headers']) + self.assertTrue(str, type(parse_result['body'])) + + def test_diff_response_status_code_equal(self): + status_code = random.randint(200, 511) + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'status_code': status_code, + } + ) + + expected_resp_json = { + 'status_code': status_code + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + def test_diff_response_status_code_not_equal(self): + status_code = random.randint(200, 511) + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'status_code': status_code, + } + ) + + expected_resp_json = { + 'status_code': 512 + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertIn('value', diff_content['status_code']) + self.assertIn('expected', diff_content['status_code']) + self.assertEqual(diff_content['status_code']['value'], status_code) + self.assertEqual(diff_content['status_code']['expected'], 512) + + def test_diff_response_headers_equal(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'abc': 123, + 'def': 456 + } + } + ) + + expected_resp_json = { + 'headers': { + 'abc': 123, + 'def': '456' + } + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + def test_diff_response_headers_not_equal(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'a': 123, + 'b': '456', + 'c': '789' + } + } + ) + + expected_resp_json = { + 'headers': { + 'a': '123', + 'b': '457', + 'd': 890 + } + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['headers'], + { + 'b': {'expected': '457', 'value': '456'}, + 'd': {'expected': 890, 'value': None} + } + ) + + def test_diff_response_body_equal(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': { + 'success': True, + 'count': 10 + } + } + ) + + # expected response body is not specified + expected_resp_json = {} + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + # response body is the same as expected response body + expected_resp_json = { + 'body': { + 'success': True, + 'count': '10' + } + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertFalse(diff_content) + + def test_diff_response_body_not_equal_type_unmatch(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': { + 'success': True, + 'count': 10 + } + } + ) + + # response body content type not match + expected_resp_json = { + 'body': "ok" + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['body'], + { + 'value': {'success': True, 'count': 10}, + 'expected': 'ok' + } + ) + + def test_diff_response_body_not_equal_string_unmatch(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': "success" + } + ) + + # response body content type matched to be string, while value unmatch + expected_resp_json = { + 'body': "ok" + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['body'], + { + 'value': 'success', + 'expected': 'ok' + } + ) + + def test_diff_response_body_not_equal_json_unmatch(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'body': { + 'success': False + } + } + ) + + # response body is the same as expected response body + expected_resp_json = { + 'body': { + 'success': True, + 'count': 10 + } + } + diff_content = response.diff_response(resp_obj, expected_resp_json) + self.assertEqual( + diff_content['body'], + { + 'success': { + 'value': False, + 'expected': True + }, + 'count': { + 'value': None, + 'expected': 10 + } + } + ) diff --git a/test/test_utils.py b/test/test_utils.py index e2b173e6d..e33ad4399 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,6 +1,4 @@ import os -import random -import requests from ate import utils from ate import exception from test.base import ApiServerUnittest @@ -36,212 +34,6 @@ def test_load_yaml_testcases(self): self.assertIn('url', testcase['request']) self.assertIn('method', testcase['request']) - def test_parse_response_object_json(self): - url = "http://127.0.0.1:5000/api/users" - resp_obj = requests.get(url) - parse_result = utils.parse_response_object(resp_obj) - self.assertIn('status_code', parse_result) - self.assertIn('headers', parse_result) - self.assertIn('body', parse_result) - self.assertIn('Content-Type', parse_result['headers']) - self.assertIn('Content-Length', parse_result['headers']) - self.assertIn('success', parse_result['body']) - - def test_parse_response_object_text(self): - url = "http://127.0.0.1:5000/" - resp_obj = requests.get(url) - parse_result = utils.parse_response_object(resp_obj) - self.assertIn('status_code', parse_result) - self.assertIn('headers', parse_result) - self.assertIn('body', parse_result) - self.assertIn('Content-Type', parse_result['headers']) - self.assertIn('Content-Length', parse_result['headers']) - self.assertTrue(str, type(parse_result['body'])) - - def test_diff_response_status_code_equal(self): - status_code = random.randint(200, 511) - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'status_code': status_code, - } - ) - - expected_resp_json = { - 'status_code': status_code - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertFalse(diff_content) - - def test_diff_response_status_code_not_equal(self): - status_code = random.randint(200, 511) - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'status_code': status_code, - } - ) - - expected_resp_json = { - 'status_code': 512 - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertIn('value', diff_content['status_code']) - self.assertIn('expected', diff_content['status_code']) - self.assertEqual(diff_content['status_code']['value'], status_code) - self.assertEqual(diff_content['status_code']['expected'], 512) - - def test_diff_response_headers_equal(self): - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'headers': { - 'abc': 123, - 'def': 456 - } - } - ) - - expected_resp_json = { - 'headers': { - 'abc': 123, - 'def': '456' - } - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertFalse(diff_content) - - def test_diff_response_headers_not_equal(self): - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'headers': { - 'a': 123, - 'b': '456', - 'c': '789' - } - } - ) - - expected_resp_json = { - 'headers': { - 'a': '123', - 'b': '457', - 'd': 890 - } - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertEqual( - diff_content['headers'], - { - 'b': {'expected': '457', 'value': '456'}, - 'd': {'expected': 890, 'value': None} - } - ) - - def test_diff_response_body_equal(self): - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': { - 'success': True, - 'count': 10 - } - } - ) - - # expected response body is not specified - expected_resp_json = {} - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertFalse(diff_content) - - # response body is the same as expected response body - expected_resp_json = { - 'body': { - 'success': True, - 'count': '10' - } - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertFalse(diff_content) - - def test_diff_response_body_not_equal_type_unmatch(self): - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': { - 'success': True, - 'count': 10 - } - } - ) - - # response body content type not match - expected_resp_json = { - 'body': "ok" - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertEqual( - diff_content['body'], - { - 'value': {'success': True, 'count': 10}, - 'expected': 'ok' - } - ) - - def test_diff_response_body_not_equal_string_unmatch(self): - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': "success" - } - ) - - # response body content type matched to be string, while value unmatch - expected_resp_json = { - 'body': "ok" - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertEqual( - diff_content['body'], - { - 'value': 'success', - 'expected': 'ok' - } - ) - - def test_diff_response_body_not_equal_json_unmatch(self): - resp_obj = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': { - 'success': False - } - } - ) - - # response body is the same as expected response body - expected_resp_json = { - 'body': { - 'success': True, - 'count': 10 - } - } - diff_content = utils.diff_response(resp_obj, expected_resp_json) - self.assertEqual( - diff_content['body'], - { - 'success': { - 'value': False, - 'expected': True - }, - 'count': { - 'value': None, - 'expected': 10 - } - } - ) - def test_load_foler_files(self): folder = os.path.join(os.getcwd(), 'test') files = utils.load_foler_files(folder) From 246bf5e4e1ee1dc2879a846bff9d98dea2424de8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 21:25:28 +0800 Subject: [PATCH 067/354] query_json: Do an xpath-like query with json_content. --- ate/utils.py | 33 +++++++++++++++++++++++++++++++++ test/test_utils.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 221ad47ab..18a5a660c 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -159,3 +159,36 @@ def parse_content_with_variables(content, variables_binds): return value return content + +def query_json(json_content, query, delimiter='.'): + """ Do an xpath-like query with json_content. + @param (json_content) json_content + json_content = { + "ids": [1, 2, 3, 4], + "person": { + "name": { + "first_name": "Leo", + "last_name": "Lee", + }, + "age": 29, + "cities": ["Guangzhou", "Shenzhen"] + } + } + @param (str) query + "person.name.first_name" => "Leo" + "person.cities.0" => "Guangzhou" + @return queried result + """ + stripped_query = query.strip(delimiter) + if not stripped_query: + return None + + try: + for key in stripped_query.split(delimiter): + if isinstance(json_content, list): + key = int(key) + json_content = json_content[key] + except (KeyError, ValueError, IndexError): + raise ParamsError("invalid query string in extract_binds!") + + return json_content diff --git a/test/test_utils.py b/test/test_utils.py index e33ad4399..dd323e580 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -139,3 +139,43 @@ def test_parse_content_with_variables(self): } with self.assertRaises(exception.ParamsError): utils.parse_content_with_variables(content, variables_binds) + + def test_query_json(self): + json_content = { + "ids": [1, 2, 3, 4], + "person": { + "name": { + "first_name": "Leo", + "last_name": "Lee", + }, + "age": 29, + "cities": ["Guangzhou", "Shenzhen"] + } + } + query = "ids.2" + result = utils.query_json(json_content, query) + self.assertEqual(result, 3) + + query = "ids.str_key" + with self.assertRaises(exception.ParamsError): + utils.query_json(json_content, query) + + query = "ids.5" + with self.assertRaises(exception.ParamsError): + utils.query_json(json_content, query) + + query = "person.age" + result = utils.query_json(json_content, query) + self.assertEqual(result, 29) + + query = "person.not_exist_key" + with self.assertRaises(exception.ParamsError): + utils.query_json(json_content, query) + + query = "person.cities.0" + result = utils.query_json(json_content, query) + self.assertEqual(result, "Guangzhou") + + query = "person.name.first_name" + result = utils.query_json(json_content, query) + self.assertEqual(result, "Leo") From fc9218c70c2eb2d6acfeca46ee87f94e9b9ad4a2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 21:35:18 +0800 Subject: [PATCH 068/354] extract_response: extract content from requests.Response, and bind extracted value to context.extractors --- ate/response.py | 48 +++++++++++++-- test/test_response.py | 132 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/ate/response.py b/ate/response.py index 4a37ddce6..52c3228fd 100644 --- a/ate/response.py +++ b/ate/response.py @@ -1,16 +1,17 @@ -from ate import utils +from ate import utils, exception -def parse_response_object(resp_obj): +def parse_response_body(resp_obj): try: - resp_body = resp_obj.json() + return resp_obj.json() except ValueError: - resp_body = resp_obj.text + return resp_obj.text +def parse_response_object(resp_obj): return { 'status_code': resp_obj.status_code, 'headers': resp_obj.headers, - 'body': resp_body + 'body': parse_response_body(resp_obj) } def diff_response(resp_obj, expected_resp_json): @@ -51,3 +52,40 @@ def diff_response(resp_obj, expected_resp_json): diff_content['body'] = body_diff return diff_content + +def extract_response(resp_obj, context, delimiter='.'): + """ extract content from requests.Response, and bind extracted value to context.extractors + @param (requests.Response instance) resp_obj + @param (ate.context.Context instance) context + context.extractors: + { + "resp_status_code": "status_code", + "resp_headers_content_type": "headers.content-type", + "resp_content": "content", + "resp_content_person_first_name": "content.person.name.first_name" + } + """ + for key, value in context.extractors.items(): + try: + if isinstance(value, str): + value += "." + top_query, sub_query = value.split(delimiter, maxsplit=1) + + if top_query in ["body", "content", "text"]: + json_content = parse_response_body(resp_obj) + else: + json_content = getattr(resp_obj, top_query) + + if sub_query: + # e.g. key: resp_headers_content_type, sub_query = "content-type" + answer = utils.query_json(json_content, sub_query) + context.extractors[key] = answer + else: + # e.g. key: resp_status_code, resp_content + context.extractors[key] = json_content + + else: + raise NotImplementedError("TODO: support template.") + + except AttributeError: + raise exception.ParamsError("invalid extract_binds!") diff --git a/test/test_response.py b/test/test_response.py index 97a788d1e..2e4765177 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -1,9 +1,9 @@ import random import requests -from ate import response +from ate import response, context, exception from test.base import ApiServerUnittest -class TestUtils(ApiServerUnittest): +class TestResponse(ApiServerUnittest): def test_parse_response_object_json(self): url = "http://127.0.0.1:5000/api/users" @@ -210,3 +210,131 @@ def test_diff_response_body_not_equal_json_unmatch(self): } } ) + + def test_extract_response_json(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'Content-Type': "application/json" + }, + 'body': { + 'success': False, + "person": { + "name": { + "first_name": "Leo", + "last_name": "Lee", + }, + "age": 29, + "cities": ["Guangzhou", "Shenzhen"] + } + } + } + ) + + extract_binds = { + "resp_status_code": "status_code", + "resp_headers_content_type": "headers.content-type", + "resp_content_body_success": "body.success", + "resp_content_content_success": "content.success", + "resp_content_text_success": "text.success", + "resp_content_person_first_name": "content.person.name.first_name", + "resp_content_cities_1": "content.person.cities.1" + } + + test_context = context.Context() + test_context.bind_extractors(extract_binds) + response.extract_response(resp_obj, test_context) + + extract_binds_dict = test_context.extractors + self.assertEqual( + extract_binds_dict["resp_status_code"], + 200 + ) + self.assertEqual( + extract_binds_dict["resp_headers_content_type"], + "application/json" + ) + self.assertEqual( + extract_binds_dict["resp_content_content_success"], + False + ) + self.assertEqual( + extract_binds_dict["resp_content_text_success"], + False + ) + self.assertEqual( + extract_binds_dict["resp_content_person_first_name"], + "Leo" + ) + self.assertEqual( + extract_binds_dict["resp_content_cities_1"], + "Shenzhen" + ) + + + def test_extract_response_fail(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'Content-Type': "application/json" + }, + 'body': { + 'success': False, + "person": { + "name": { + "first_name": "Leo", + "last_name": "Lee", + }, + "age": 29, + "cities": ["Guangzhou", "Shenzhen"] + } + } + } + ) + + extract_binds = { + "resp_content_dict_key_error": "content.not_exist" + } + + test_context = context.Context() + test_context.bind_extractors(extract_binds) + + with self.assertRaises(exception.ParamsError): + response.extract_response(resp_obj, test_context) + + extract_binds = { + "resp_content_list_index_error": "content.person.cities.3" + } + + test_context = context.Context() + test_context.bind_extractors(extract_binds) + + with self.assertRaises(exception.ParamsError): + response.extract_response(resp_obj, test_context) + + def test_extract_response_json_string(self): + resp_obj = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'Content-Type': "application/json" + }, + 'body': "abc" + } + ) + + extract_binds = { + "resp_content_body": "content" + } + + test_context = context.Context() + test_context.bind_extractors(extract_binds) + response.extract_response(resp_obj, test_context) + + extract_binds_dict = test_context.extractors + self.assertEqual( + extract_binds_dict["resp_content_body"], + "abc" + ) From 6438fe653a556a8ed2ce48d313c7f98062efa288 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 21:38:43 +0800 Subject: [PATCH 069/354] bind_extractors: Bind named extractors to value within the context. --- ate/context.py | 15 +++++++++++++++ ate/runner.py | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/ate/context.py b/ate/context.py index 3499b6f7c..994d42506 100644 --- a/ate/context.py +++ b/ate/context.py @@ -8,6 +8,7 @@ class Context(object): def __init__(self): self.functions = dict() self.variables = dict() # Maps variable name to value + self.extractors = dict() def import_requires(self, modules): """ import required modules dynamicly @@ -44,6 +45,20 @@ def bind_variables(self, variable_binds): for var_name, var_value in variable_bind_map.items(): self.variables[var_name] = self.get_eval_value(var_value) + def bind_extractors(self, extract_binds): + """ Bind named extractors to value within the context. + value => parsed from requests.Response object + key => extractor name, can be used as variable in next testcases + @param (dict) extract_binds + { + "resp_status_code": "status_code", + "resp_headers": "headers", + "resp_headers_content_type": "headers.content-type", + "resp_content": "content" + } + """ + self.extractors.update(extract_binds) + def get_eval_value(self, data): """ evaluate data recursively, each variable in data will be evaluated. variables marker: ${variable}. diff --git a/ate/runner.py b/ate/runner.py index c4987bb45..3b4a5e095 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -41,6 +41,9 @@ def pre_config(self, config_dict): variable_binds = config_dict.get('variable_binds', []) self.context.bind_variables(variable_binds) + extract_binds = config_dict.get('extract_binds', {}) + self.context.bind_extractors(extract_binds) + self.testcase_parser.update_variables_binds(self.context.variables) def parse_testcase(self, testcase): @@ -101,6 +104,7 @@ def run_test(self, testcase): raise exception.ParamsError("URL or METHOD missed!") resp_obj = self.client.request(url=url, method=method, **req_kwargs) + response.extract_response(resp_obj, self.context) diff_content = response.diff_response(resp_obj, testcase['response']) success = False if diff_content else True return success, diff_content From 4f87c14f536ea3538a52dd1eda8d789088c26cf4 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 21:47:10 +0800 Subject: [PATCH 070/354] can only use parameter names (sep, maxsplit) in Python 3.x, not supported in Python2.x --- ate/response.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ate/response.py b/ate/response.py index 52c3228fd..c598c749e 100644 --- a/ate/response.py +++ b/ate/response.py @@ -69,7 +69,9 @@ def extract_response(resp_obj, context, delimiter='.'): try: if isinstance(value, str): value += "." - top_query, sub_query = value.split(delimiter, maxsplit=1) + # string.split(sep=None, maxsplit=-1) -> list of strings + # e.g. "content.person.name" => ["content", "person.name"] + top_query, sub_query = value.split(delimiter, 1) if top_query in ["body", "content", "text"]: json_content = parse_response_body(resp_obj) From a2bf2f6ee13e9b651a048209372a0612574bfc17 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 22:19:01 +0800 Subject: [PATCH 071/354] apiserver: add interface /api/token --- test/api_server.py | 11 +++++++++++ test/test_apiserver.py | 7 +++++++ test/test_apiserver_v2.py | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/test/api_server.py b/test/api_server.py index b0cdd87f9..bb2b36907 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -65,6 +65,17 @@ def get_customized_response(): return response +@app.route('/api/token') +@validate_request +def get_token(): + result = { + 'success': True, + 'token': utils.gen_random_string(8) + } + response = make_response(json.dumps(result)) + response.headers["Content-Type"] = "application/json" + return response + @app.route('/api/users') @validate_request def get_users(): diff --git a/test/test_apiserver.py b/test/test_apiserver.py index 9daf0b867..29694f303 100644 --- a/test/test_apiserver.py +++ b/test/test_apiserver.py @@ -128,3 +128,10 @@ def test_get_customized_response_headers(self): resp = self.api_client.post(url, json=expected_response) self.assertIn('abc', resp.headers) self.assertIn('123', resp.headers['abc']) + + def test_get_token(self): + url = "%s/api/token" % self.host + resp = self.api_client.get(url) + resp_json = resp.json() + self.assertTrue(resp_json["success"]) + self.assertEqual(len(resp_json["token"]), 8) diff --git a/test/test_apiserver_v2.py b/test/test_apiserver_v2.py index 57d47be27..c95ab935e 100644 --- a/test/test_apiserver_v2.py +++ b/test/test_apiserver_v2.py @@ -148,3 +148,11 @@ def test_get_customized_response_headers(self): ) self.assertIn('abc', resp.headers) self.assertIn('123', resp.headers['abc']) + + def test_get_token(self): + url = "%s/api/token" % self.host + headers = self.prepare_headers() + resp = self.api_client.get(url, headers=headers) + resp_json = resp.json() + self.assertTrue(resp_json["success"]) + self.assertEqual(len(resp_json["token"]), 8) From 638733d9b6d109badf20d6ede1f5c4397998b219 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 22:20:19 +0800 Subject: [PATCH 072/354] add entrance --- main.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 000000000..6bb44538f --- /dev/null +++ b/main.py @@ -0,0 +1,2 @@ +from ate.main import main +main() \ No newline at end of file From f5705e37f8400da03af62e5805cbdc04b974e634 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 23:08:17 +0800 Subject: [PATCH 073/354] create ResponseObject, each test result will be associated with a ResponseObject --- ate/response.py | 165 ++++++++++++++++++++++-------------------- ate/runner.py | 8 +- test/test_response.py | 71 +++++++++++------- 3 files changed, 134 insertions(+), 110 deletions(-) diff --git a/ate/response.py b/ate/response.py index c598c749e..20297d500 100644 --- a/ate/response.py +++ b/ate/response.py @@ -1,93 +1,100 @@ from ate import utils, exception -def parse_response_body(resp_obj): - try: - return resp_obj.json() - except ValueError: - return resp_obj.text - -def parse_response_object(resp_obj): - return { - 'status_code': resp_obj.status_code, - 'headers': resp_obj.headers, - 'body': parse_response_body(resp_obj) - } - -def diff_response(resp_obj, expected_resp_json): - diff_content = {} - resp_info = parse_response_object(resp_obj) - - expected_status_code = expected_resp_json.get('status_code', 200) - if resp_info['status_code'] != int(expected_status_code): - diff_content['status_code'] = { - 'value': resp_info['status_code'], - 'expected': expected_status_code - } +class ResponseObject(object): - expected_headers = expected_resp_json.get('headers', {}) - headers_diff = utils.diff_json(resp_info['headers'], expected_headers) - if headers_diff: - diff_content['headers'] = headers_diff + def __init__(self, resp_obj): + """ initialize with a requests.Response object + @param (requests.Response instance) resp_obj + """ + self.resp_obj = resp_obj - expected_body = expected_resp_json.get('body', None) + def parse_response_body(self): + try: + return self.resp_obj.json() + except ValueError: + return self.resp_obj.text - if expected_body is None: - body_diff = {} - elif type(expected_body) != type(resp_info['body']): - body_diff = { - 'value': resp_info['body'], - 'expected': expected_body + def parse_response_object(self): + return { + 'status_code': self.resp_obj.status_code, + 'headers': self.resp_obj.headers, + 'body': self.parse_response_body() } - elif isinstance(expected_body, str): - if expected_body != resp_info['body']: + + def diff_response(self, expected_resp_json): + diff_content = {} + resp_info = self.parse_response_object() + + expected_status_code = expected_resp_json.get('status_code', 200) + if resp_info['status_code'] != int(expected_status_code): + diff_content['status_code'] = { + 'value': resp_info['status_code'], + 'expected': expected_status_code + } + + expected_headers = expected_resp_json.get('headers', {}) + headers_diff = utils.diff_json(resp_info['headers'], expected_headers) + if headers_diff: + diff_content['headers'] = headers_diff + + expected_body = expected_resp_json.get('body', None) + + if expected_body is None: + body_diff = {} + elif type(expected_body) != type(resp_info['body']): body_diff = { 'value': resp_info['body'], 'expected': expected_body } - elif isinstance(expected_body, dict): - body_diff = utils.diff_json(resp_info['body'], expected_body) - - if body_diff: - diff_content['body'] = body_diff - - return diff_content - -def extract_response(resp_obj, context, delimiter='.'): - """ extract content from requests.Response, and bind extracted value to context.extractors - @param (requests.Response instance) resp_obj - @param (ate.context.Context instance) context - context.extractors: - { - "resp_status_code": "status_code", - "resp_headers_content_type": "headers.content-type", - "resp_content": "content", - "resp_content_person_first_name": "content.person.name.first_name" - } - """ - for key, value in context.extractors.items(): - try: - if isinstance(value, str): - value += "." - # string.split(sep=None, maxsplit=-1) -> list of strings - # e.g. "content.person.name" => ["content", "person.name"] - top_query, sub_query = value.split(delimiter, 1) - - if top_query in ["body", "content", "text"]: - json_content = parse_response_body(resp_obj) - else: - json_content = getattr(resp_obj, top_query) + elif isinstance(expected_body, str): + if expected_body != resp_info['body']: + body_diff = { + 'value': resp_info['body'], + 'expected': expected_body + } + elif isinstance(expected_body, dict): + body_diff = utils.diff_json(resp_info['body'], expected_body) - if sub_query: - # e.g. key: resp_headers_content_type, sub_query = "content-type" - answer = utils.query_json(json_content, sub_query) - context.extractors[key] = answer - else: - # e.g. key: resp_status_code, resp_content - context.extractors[key] = json_content + if body_diff: + diff_content['body'] = body_diff - else: - raise NotImplementedError("TODO: support template.") + return diff_content + + def extract_response(self, context, delimiter='.'): + """ extract content from requests.Response, and bind extracted value to context.extractors + @param (ate.context.Context instance) context + context.extractors: + { + "resp_status_code": "status_code", + "resp_headers_content_type": "headers.content-type", + "resp_content": "content", + "resp_content_person_first_name": "content.person.name.first_name" + } + """ + for key, value in context.extractors.items(): + try: + if isinstance(value, str): + value += "." + # string.split(sep=None, maxsplit=-1) -> list of strings + # e.g. "content.person.name" => ["content", "person.name"] + top_query, sub_query = value.split(delimiter, 1) + + if top_query in ["body", "content", "text"]: + json_content = self.parse_response_body() + else: + json_content = getattr(self.resp_obj, top_query) + + if sub_query: + # e.g. key: resp_headers_content_type, sub_query = "content-type" + answer = utils.query_json(json_content, sub_query) + context.extractors[key] = answer + else: + # e.g. key: resp_status_code, resp_content + context.extractors[key] = json_content + + else: + raise NotImplementedError("TODO: support template.") - except AttributeError: - raise exception.ParamsError("invalid extract_binds!") + except AttributeError: + raise exception.ParamsError("invalid extract_binds!") diff --git a/ate/runner.py b/ate/runner.py index 3b4a5e095..9581b858b 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -103,9 +103,11 @@ def run_test(self, testcase): except KeyError: raise exception.ParamsError("URL or METHOD missed!") - resp_obj = self.client.request(url=url, method=method, **req_kwargs) - response.extract_response(resp_obj, self.context) - diff_content = response.diff_response(resp_obj, testcase['response']) + resp = self.client.request(url=url, method=method, **req_kwargs) + + resp_obj = response.ResponseObject(resp) + resp_obj.extract_response(self.context) + diff_content = resp_obj.diff_response(testcase['response']) success = False if diff_content else True return success, diff_content diff --git a/test/test_response.py b/test/test_response.py index 2e4765177..bc425c59b 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -7,8 +7,9 @@ class TestResponse(ApiServerUnittest): def test_parse_response_object_json(self): url = "http://127.0.0.1:5000/api/users" - resp_obj = requests.get(url) - parse_result = response.parse_response_object(resp_obj) + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + parse_result = resp_obj.parse_response_object() self.assertIn('status_code', parse_result) self.assertIn('headers', parse_result) self.assertIn('body', parse_result) @@ -18,8 +19,9 @@ def test_parse_response_object_json(self): def test_parse_response_object_text(self): url = "http://127.0.0.1:5000/" - resp_obj = requests.get(url) - parse_result = response.parse_response_object(resp_obj) + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + parse_result = resp_obj.parse_response_object() self.assertIn('status_code', parse_result) self.assertIn('headers', parse_result) self.assertIn('body', parse_result) @@ -29,7 +31,7 @@ def test_parse_response_object_text(self): def test_diff_response_status_code_equal(self): status_code = random.randint(200, 511) - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'status_code': status_code, @@ -39,12 +41,13 @@ def test_diff_response_status_code_equal(self): expected_resp_json = { 'status_code': status_code } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertFalse(diff_content) def test_diff_response_status_code_not_equal(self): status_code = random.randint(200, 511) - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'status_code': status_code, @@ -54,14 +57,15 @@ def test_diff_response_status_code_not_equal(self): expected_resp_json = { 'status_code': 512 } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertIn('value', diff_content['status_code']) self.assertIn('expected', diff_content['status_code']) self.assertEqual(diff_content['status_code']['value'], status_code) self.assertEqual(diff_content['status_code']['expected'], 512) def test_diff_response_headers_equal(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'headers': { @@ -77,11 +81,12 @@ def test_diff_response_headers_equal(self): 'def': '456' } } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertFalse(diff_content) def test_diff_response_headers_not_equal(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'headers': { @@ -99,7 +104,8 @@ def test_diff_response_headers_not_equal(self): 'd': 890 } } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertEqual( diff_content['headers'], { @@ -109,7 +115,7 @@ def test_diff_response_headers_not_equal(self): ) def test_diff_response_body_equal(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'body': { @@ -121,7 +127,8 @@ def test_diff_response_body_equal(self): # expected response body is not specified expected_resp_json = {} - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertFalse(diff_content) # response body is the same as expected response body @@ -131,11 +138,12 @@ def test_diff_response_body_equal(self): 'count': '10' } } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertFalse(diff_content) def test_diff_response_body_not_equal_type_unmatch(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'body': { @@ -149,7 +157,8 @@ def test_diff_response_body_not_equal_type_unmatch(self): expected_resp_json = { 'body': "ok" } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertEqual( diff_content['body'], { @@ -159,7 +168,7 @@ def test_diff_response_body_not_equal_type_unmatch(self): ) def test_diff_response_body_not_equal_string_unmatch(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'body': "success" @@ -170,7 +179,8 @@ def test_diff_response_body_not_equal_string_unmatch(self): expected_resp_json = { 'body': "ok" } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertEqual( diff_content['body'], { @@ -180,7 +190,7 @@ def test_diff_response_body_not_equal_string_unmatch(self): ) def test_diff_response_body_not_equal_json_unmatch(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'body': { @@ -196,7 +206,8 @@ def test_diff_response_body_not_equal_json_unmatch(self): 'count': 10 } } - diff_content = response.diff_response(resp_obj, expected_resp_json) + resp_obj = response.ResponseObject(resp) + diff_content = resp_obj.diff_response(expected_resp_json) self.assertEqual( diff_content['body'], { @@ -212,7 +223,7 @@ def test_diff_response_body_not_equal_json_unmatch(self): ) def test_extract_response_json(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'headers': { @@ -244,7 +255,8 @@ def test_extract_response_json(self): test_context = context.Context() test_context.bind_extractors(extract_binds) - response.extract_response(resp_obj, test_context) + resp_obj = response.ResponseObject(resp) + resp_obj.extract_response(test_context) extract_binds_dict = test_context.extractors self.assertEqual( @@ -274,7 +286,7 @@ def test_extract_response_json(self): def test_extract_response_fail(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'headers': { @@ -300,9 +312,10 @@ def test_extract_response_fail(self): test_context = context.Context() test_context.bind_extractors(extract_binds) + resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - response.extract_response(resp_obj, test_context) + resp_obj.extract_response(test_context) extract_binds = { "resp_content_list_index_error": "content.person.cities.3" @@ -310,12 +323,13 @@ def test_extract_response_fail(self): test_context = context.Context() test_context.bind_extractors(extract_binds) + resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - response.extract_response(resp_obj, test_context) + resp_obj.extract_response(test_context) def test_extract_response_json_string(self): - resp_obj = requests.post( + resp = requests.post( url="http://127.0.0.1:5000/customize-response", json={ 'headers': { @@ -331,7 +345,8 @@ def test_extract_response_json_string(self): test_context = context.Context() test_context.bind_extractors(extract_binds) - response.extract_response(resp_obj, test_context) + resp_obj = response.ResponseObject(resp) + resp_obj.extract_response(test_context) extract_binds_dict = test_context.extractors self.assertEqual( From 895553e1584d29bd486c33b2244d2958059d3a9a Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 23:25:08 +0800 Subject: [PATCH 074/354] change method name --- ate/response.py | 15 ++++++++++----- ate/runner.py | 4 +--- test/test_response.py | 28 ++++++++++++++-------------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/ate/response.py b/ate/response.py index 20297d500..02585affc 100644 --- a/ate/response.py +++ b/ate/response.py @@ -9,22 +9,22 @@ def __init__(self, resp_obj): """ self.resp_obj = resp_obj - def parse_response_body(self): + def parsed_body(self): try: return self.resp_obj.json() except ValueError: return self.resp_obj.text - def parse_response_object(self): + def parsed_dict(self): return { 'status_code': self.resp_obj.status_code, 'headers': self.resp_obj.headers, - 'body': self.parse_response_body() + 'body': self.parsed_body() } def diff_response(self, expected_resp_json): diff_content = {} - resp_info = self.parse_response_object() + resp_info = self.parsed_dict() expected_status_code = expected_resp_json.get('status_code', 200) if resp_info['status_code'] != int(expected_status_code): @@ -81,7 +81,7 @@ def extract_response(self, context, delimiter='.'): top_query, sub_query = value.split(delimiter, 1) if top_query in ["body", "content", "text"]: - json_content = self.parse_response_body() + json_content = self.parsed_body() else: json_content = getattr(self.resp_obj, top_query) @@ -98,3 +98,8 @@ def extract_response(self, context, delimiter='.'): except AttributeError: raise exception.ParamsError("invalid extract_binds!") + + def validate(self, expected_resp_json): + diff_content = self.diff_response(expected_resp_json) + success = False if diff_content else True + return success, diff_content diff --git a/ate/runner.py b/ate/runner.py index 9581b858b..a75ff6dfc 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -107,9 +107,7 @@ def run_test(self, testcase): resp_obj = response.ResponseObject(resp) resp_obj.extract_response(self.context) - diff_content = resp_obj.diff_response(testcase['response']) - success = False if diff_content else True - return success, diff_content + return resp_obj.validate(testcase['response']) def run_testset(self, testset): """ run single testset, including one or several testcases. diff --git a/test/test_response.py b/test/test_response.py index bc425c59b..7e5b5ba7b 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -9,25 +9,25 @@ def test_parse_response_object_json(self): url = "http://127.0.0.1:5000/api/users" resp = requests.get(url) resp_obj = response.ResponseObject(resp) - parse_result = resp_obj.parse_response_object() - self.assertIn('status_code', parse_result) - self.assertIn('headers', parse_result) - self.assertIn('body', parse_result) - self.assertIn('Content-Type', parse_result['headers']) - self.assertIn('Content-Length', parse_result['headers']) - self.assertIn('success', parse_result['body']) + parsed_dict = resp_obj.parsed_dict() + self.assertIn('status_code', parsed_dict) + self.assertIn('headers', parsed_dict) + self.assertIn('body', parsed_dict) + self.assertIn('Content-Type', parsed_dict['headers']) + self.assertIn('Content-Length', parsed_dict['headers']) + self.assertIn('success', parsed_dict['body']) def test_parse_response_object_text(self): url = "http://127.0.0.1:5000/" resp = requests.get(url) resp_obj = response.ResponseObject(resp) - parse_result = resp_obj.parse_response_object() - self.assertIn('status_code', parse_result) - self.assertIn('headers', parse_result) - self.assertIn('body', parse_result) - self.assertIn('Content-Type', parse_result['headers']) - self.assertIn('Content-Length', parse_result['headers']) - self.assertTrue(str, type(parse_result['body'])) + parsed_dict = resp_obj.parsed_dict() + self.assertIn('status_code', parsed_dict) + self.assertIn('headers', parsed_dict) + self.assertIn('body', parsed_dict) + self.assertIn('Content-Type', parsed_dict['headers']) + self.assertIn('Content-Length', parsed_dict['headers']) + self.assertTrue(str, type(parsed_dict['body'])) def test_diff_response_status_code_equal(self): status_code = random.randint(200, 511) From b1394591956426ec647f86c668aae4c29cb8d4b9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 28 Jun 2017 23:35:56 +0800 Subject: [PATCH 075/354] extract_response when initialize ResponseObject --- ate/response.py | 5 ++++- ate/runner.py | 3 +-- test/test_response.py | 12 ++++-------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/ate/response.py b/ate/response.py index 02585affc..727bda05c 100644 --- a/ate/response.py +++ b/ate/response.py @@ -3,11 +3,14 @@ class ResponseObject(object): - def __init__(self, resp_obj): + def __init__(self, resp_obj, context=None): """ initialize with a requests.Response object @param (requests.Response instance) resp_obj + @param (ate.context.Context instance) context """ self.resp_obj = resp_obj + if context: + self.extract_response(context) def parsed_body(self): try: diff --git a/ate/runner.py b/ate/runner.py index a75ff6dfc..30439464c 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -105,8 +105,7 @@ def run_test(self, testcase): resp = self.client.request(url=url, method=method, **req_kwargs) - resp_obj = response.ResponseObject(resp) - resp_obj.extract_response(self.context) + resp_obj = response.ResponseObject(resp, self.context) return resp_obj.validate(testcase['response']) def run_testset(self, testset): diff --git a/test/test_response.py b/test/test_response.py index 7e5b5ba7b..ce2bb419c 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -255,8 +255,7 @@ def test_extract_response_json(self): test_context = context.Context() test_context.bind_extractors(extract_binds) - resp_obj = response.ResponseObject(resp) - resp_obj.extract_response(test_context) + response.ResponseObject(resp, test_context) extract_binds_dict = test_context.extractors self.assertEqual( @@ -312,10 +311,9 @@ def test_extract_response_fail(self): test_context = context.Context() test_context.bind_extractors(extract_binds) - resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - resp_obj.extract_response(test_context) + response.ResponseObject(resp, test_context) extract_binds = { "resp_content_list_index_error": "content.person.cities.3" @@ -323,10 +321,9 @@ def test_extract_response_fail(self): test_context = context.Context() test_context.bind_extractors(extract_binds) - resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - resp_obj.extract_response(test_context) + response.ResponseObject(resp, test_context) def test_extract_response_json_string(self): resp = requests.post( @@ -345,8 +342,7 @@ def test_extract_response_json_string(self): test_context = context.Context() test_context.bind_extractors(extract_binds) - resp_obj = response.ResponseObject(resp) - resp_obj.extract_response(test_context) + resp_obj = response.ResponseObject(resp, test_context) extract_binds_dict = test_context.extractors self.assertEqual( From 67a5de3575fdf57e36b80d00aea9a748bc8958a6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 15:27:26 +0800 Subject: [PATCH 076/354] refactor: add extract_binds and validators --- ate/context.py | 16 +- ate/main.py | 2 +- ate/response.py | 143 +++++++------ ate/runner.py | 74 +++---- ate/testcase.py | 91 ++++---- ate/utils.py | 35 +-- test/data/demo_template_separate.yml | 28 ++- test/data/demo_template_sets.yml | 27 ++- test/data/simple_demo_auth_hardcode.json | 33 ++- test/data/simple_demo_auth_hardcode.yml | 27 ++- test/data/simple_demo_no_auth.json | 33 ++- test/data/simple_demo_no_auth.yml | 30 +-- test/test_response.py | 261 ++++------------------- test/test_runner.py | 40 ++-- test/test_testcase.py | 12 +- test/test_utils.py | 11 +- 16 files changed, 322 insertions(+), 541 deletions(-) diff --git a/ate/context.py b/ate/context.py index 994d42506..2665754de 100644 --- a/ate/context.py +++ b/ate/context.py @@ -8,7 +8,6 @@ class Context(object): def __init__(self): self.functions = dict() self.variables = dict() # Maps variable name to value - self.extractors = dict() def import_requires(self, modules): """ import required modules dynamicly @@ -45,19 +44,10 @@ def bind_variables(self, variable_binds): for var_name, var_value in variable_bind_map.items(): self.variables[var_name] = self.get_eval_value(var_value) - def bind_extractors(self, extract_binds): - """ Bind named extractors to value within the context. - value => parsed from requests.Response object - key => extractor name, can be used as variable in next testcases - @param (dict) extract_binds - { - "resp_status_code": "status_code", - "resp_headers": "headers", - "resp_headers_content_type": "headers.content-type", - "resp_content": "content" - } + def update_variables(self, variables_mapping): + """ update context variables binds with new variables mapping """ - self.extractors.update(extract_binds) + self.variables.update(variables_mapping) def get_eval_value(self, data): """ evaluate data recursively, each variable in data will be evaluated. diff --git a/ate/main.py b/ate/main.py index 389775088..6e0e3cfb1 100644 --- a/ate/main.py +++ b/ate/main.py @@ -26,7 +26,7 @@ def create_suite(testset): test_runner = runner.TestRunner() config_dict = testset.get("config", {}) - test_runner.pre_config(config_dict) + test_runner.update_context(config_dict) testcases = testset.get("testcases", []) for testcase in testcases: diff --git a/ate/response.py b/ate/response.py index 727bda05c..752d2348e 100644 --- a/ate/response.py +++ b/ate/response.py @@ -1,16 +1,18 @@ from ate import utils, exception +try: + basestring # Python 2.x +except NameError: + basestring = str # Python 3.x class ResponseObject(object): - def __init__(self, resp_obj, context=None): + def __init__(self, resp_obj): """ initialize with a requests.Response object @param (requests.Response instance) resp_obj - @param (ate.context.Context instance) context """ self.resp_obj = resp_obj - if context: - self.extract_response(context) + self.success = True def parsed_body(self): try: @@ -25,49 +27,9 @@ def parsed_dict(self): 'body': self.parsed_body() } - def diff_response(self, expected_resp_json): - diff_content = {} - resp_info = self.parsed_dict() - - expected_status_code = expected_resp_json.get('status_code', 200) - if resp_info['status_code'] != int(expected_status_code): - diff_content['status_code'] = { - 'value': resp_info['status_code'], - 'expected': expected_status_code - } - - expected_headers = expected_resp_json.get('headers', {}) - headers_diff = utils.diff_json(resp_info['headers'], expected_headers) - if headers_diff: - diff_content['headers'] = headers_diff - - expected_body = expected_resp_json.get('body', None) - - if expected_body is None: - body_diff = {} - elif type(expected_body) != type(resp_info['body']): - body_diff = { - 'value': resp_info['body'], - 'expected': expected_body - } - elif isinstance(expected_body, str): - if expected_body != resp_info['body']: - body_diff = { - 'value': resp_info['body'], - 'expected': expected_body - } - elif isinstance(expected_body, dict): - body_diff = utils.diff_json(resp_info['body'], expected_body) - - if body_diff: - diff_content['body'] = body_diff - - return diff_content - - def extract_response(self, context, delimiter='.'): - """ extract content from requests.Response, and bind extracted value to context.extractors - @param (ate.context.Context instance) context - context.extractors: + def extract_response(self, extract_binds, delimiter='.'): + """ extract content from requests.Response + @param (dict) extract_binds { "resp_status_code": "status_code", "resp_headers_content_type": "headers.content-type", @@ -75,34 +37,73 @@ def extract_response(self, context, delimiter='.'): "resp_content_person_first_name": "content.person.name.first_name" } """ - for key, value in context.extractors.items(): + extract_binds_dict = {} + + for key, value in extract_binds.items(): + if not isinstance(value, basestring): + raise exception.ParamsError("invalid extract_binds!") + try: - if isinstance(value, str): - value += "." - # string.split(sep=None, maxsplit=-1) -> list of strings - # e.g. "content.person.name" => ["content", "person.name"] - top_query, sub_query = value.split(delimiter, 1) - - if top_query in ["body", "content", "text"]: - json_content = self.parsed_body() - else: - json_content = getattr(self.resp_obj, top_query) - - if sub_query: - # e.g. key: resp_headers_content_type, sub_query = "content-type" - answer = utils.query_json(json_content, sub_query) - context.extractors[key] = answer - else: - # e.g. key: resp_status_code, resp_content - context.extractors[key] = json_content + value += "." + # string.split(sep=None, maxsplit=-1) -> list of strings + # e.g. "content.person.name" => ["content", "person.name"] + top_query, sub_query = value.split(delimiter, 1) + if top_query in ["body", "content", "text"]: + json_content = self.parsed_body() else: - raise NotImplementedError("TODO: support template.") + json_content = getattr(self.resp_obj, top_query) + + if sub_query: + # e.g. key: resp_headers_content_type, sub_query = "content-type" + answer = utils.query_json(json_content, sub_query) + extract_binds_dict[key] = answer + else: + # e.g. key: resp_status_code, resp_content + extract_binds_dict[key] = json_content except AttributeError: raise exception.ParamsError("invalid extract_binds!") - def validate(self, expected_resp_json): - diff_content = self.diff_response(expected_resp_json) - success = False if diff_content else True - return success, diff_content + return extract_binds_dict + + def validate(self, validators, variables_mapping): + """ Bind named validators to value within the context. + @param (dict) validators + { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq", "expected": True} + } + @param (dict) variables_mapping + { + "resp_status_code": 200, + "resp_body_success": True + } + @return (dict) content differences + { + "resp_status_code": { + "comparator": "eq", "expected": 201, "value": 200 + } + } + """ + diff_content_dict = {} + + for validator_key, validator_dict in validators.items(): + + try: + value = variables_mapping[validator_key] + validator_dict["value"] = value + except KeyError: + raise exception.ParamsError("invalid validator %s" % validator_key) + + difference_exist = utils.compare( + value, + validator_dict["expected"], + validator_dict["comparator"] + ) + + if difference_exist: + diff_content_dict[validator_key] = validator_dict + + self.success = False if diff_content_dict else True + return diff_content_dict diff --git a/ate/runner.py b/ate/runner.py index 30439464c..1b58996ae 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -2,7 +2,7 @@ from ate import exception, response from ate.context import Context -from ate.testcase import TestcaseParser +from ate.testcase import parse_template class TestRunner(object): @@ -10,10 +10,9 @@ class TestRunner(object): def __init__(self): self.client = requests.Session() self.context = Context() - self.testcase_parser = TestcaseParser() - def pre_config(self, config_dict): - """ create/update variables binds + def update_context(self, config_dict): + """ create/update context variables binds @param config_dict { "name": "description content", @@ -41,72 +40,50 @@ def pre_config(self, config_dict): variable_binds = config_dict.get('variable_binds', []) self.context.bind_variables(variable_binds) - extract_binds = config_dict.get('extract_binds', {}) - self.context.bind_extractors(extract_binds) - - self.testcase_parser.update_variables_binds(self.context.variables) - - def parse_testcase(self, testcase): - """ parse testcase with variables binds if it is a template. + def run_test(self, testcase): + """ run single testcase. @param (dict) testcase { "name": "testcase description", "requires": [], # optional, override "function_binds": {}, # optional, override "variable_binds": {}, # optional, override - "request": {}, - "response": {} - } - @return (dict) parsed testcase with bind values - { "request": { "url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", "headers": { "Content-Type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" + "authorization": "${authorization}", + "random": "${random}" }, "body": '{"name": "user", "password": "123456"}' }, - "response": { - "status_code": 201 - } - } - """ - self.pre_config(testcase) - - parsed_testcase = self.testcase_parser.parse(testcase) - return parsed_testcase - - def run_test(self, testcase): - """ run single testcase. - @param (dict) testcase - { - "name": "testcase description", - "requires": [], # optional, override - "function_binds": {}, # optional, override - "variable_binds": {}, # optional, override - "request": {}, - "response": {} + "extract_binds": {}, + "validators": {} } @return (tuple) test result of single testcase (success, diff_content) """ - testcase = self.parse_testcase(testcase) - - req_kwargs = testcase['request'] + self.update_context(testcase) + parsed_request = parse_template(testcase["request"], self.context.variables) try: - url = req_kwargs.pop('url') - method = req_kwargs.pop('method') + url = parsed_request.pop('url') + method = parsed_request.pop('method') except KeyError: raise exception.ParamsError("URL or METHOD missed!") - resp = self.client.request(url=url, method=method, **req_kwargs) + resp = self.client.request(url=url, method=method, **parsed_request) + resp_obj = response.ResponseObject(resp) + + extract_binds = testcase.get("extract_binds", {}) + extract_binds_dict = resp_obj.extract_response(extract_binds) + self.context.update_variables(extract_binds_dict) + + validators = testcase.get("validators", {}) + diff_content_dict = resp_obj.validate(validators, self.context.variables) - resp_obj = response.ResponseObject(resp, self.context) - return resp_obj.validate(testcase['response']) + return resp_obj.success, diff_content_dict def run_testset(self, testset): """ run single testset, including one or several testcases. @@ -124,7 +101,8 @@ def run_testset(self, testset): "name": "testcase description", "variable_binds": {}, # override "request": {}, - "response": {} + "extract_binds": {}, + "validators": {} }, testcase12 ] @@ -138,7 +116,7 @@ def run_testset(self, testset): results = [] config_dict = testset.get("config", {}) - self.pre_config(config_dict) + self.update_context(config_dict) testcases = testset.get("testcases", []) for testcase in testcases: result = self.run_test(testcase) diff --git a/ate/testcase.py b/ate/testcase.py index 41d90e012..884edbf5c 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,69 +1,56 @@ from ate import utils -class TestcaseParser(object): - def __init__(self, variables_binds={}): - self.variables_binds = variables_binds - - def update_variables_binds(self, variables_mapping): - """ update variables binds with new mapping. - """ - if variables_mapping: - self.variables_binds.update(variables_mapping) - - def parse(self, testcase_template): - """ parse testcase_template, replace all variables with bind value. - variables marker: ${variable}. - @param (dict) testcase_template - { - "request": { - "url": "http://127.0.0.1:5000/api/users/${uid}", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "authorization": "${authorization}", - "random": "${random}" - }, - "body": "${data}" - }, - "response": { - "status_code": "${expected_status}" - } - } - @return (dict) parsed testcase with bind values - { - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" - }, - "body": '{"name": "user", "password": "123456"}' - }, - "response": { - "status_code": 201 - } - } - """ - return self.substitute(testcase_template) - - def substitute(self, content): +def parse_template(testcase_template, variables_binds): + """ parse testcase_template, replace all variables with bind value. + variables marker: ${variable}. + @param (dict) testcase_template + { + "url": "http://127.0.0.1:5000/api/users/${uid}", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "${authorization}", + "random": "${random}" + }, + "body": "${data}" + } + @param (dict) variables binds mapping + { + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx", + "data": '{"name": "user", "password": "123456"}' + } + @return (dict) parsed testcase with bind variable values + { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx" + }, + "body": '{"name": "user", "password": "123456"}' + } + """ + + def substitute(content): """ substitute content recursively, each variable will be replaced with bind value. variables marker: ${variable}. """ if isinstance(content, str): - return utils.parse_content_with_variables(content, self.variables_binds) + return utils.parse_content_with_variables(content, variables_binds) if isinstance(content, list): - return [self.substitute(item) for item in content] + return [substitute(item) for item in content] if isinstance(content, dict): parsed_content = {} for key, value in content.items(): - parsed_content[key] = self.substitute(value) + parsed_content[key] = substitute(value) return parsed_content return content + + return substitute(testcase_template) diff --git a/ate/utils.py b/ate/utils.py index 18a5a660c..e3a0b86e7 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -62,19 +62,6 @@ def load_testcases(testcase_file_path): # '' or other suffix raise ParamsError("Bad testcase file name!") -def diff_json(current_json, expected_json): - json_diff = {} - - for key, expected_value in expected_json.items(): - value = current_json.get(key, None) - if str(value) != str(expected_value): - json_diff[key] = { - 'value': value, - 'expected': expected_value - } - - return json_diff - def load_foler_files(folder_path): """ load folder path, return all files in list format. """ @@ -192,3 +179,25 @@ def query_json(json_content, query, delimiter='.'): raise ParamsError("invalid query string in extract_binds!") return json_content + +def diff_json(current_json, expected_json): + json_diff = {} + + for key, expected_value in expected_json.items(): + value = current_json.get(key, None) + if str(value) != str(expected_value): + json_diff[key] = { + 'value': value, + 'expected': expected_value + } + + return json_diff + +def compare(value, expected, comparator="eq"): + try: + if comparator in ["eq", "=="]: + assert value == expected + + return False + except AssertionError: + return True diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index bd0253172..27a999c7c 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -13,7 +13,6 @@ - random: {"func": "gen_random_string", "args": [5]} - data: '{"name": "user", "password": "123456"}' - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} - - expected_status_code: 201 request: url: http://127.0.0.1:5000/api/users/1000 method: POST @@ -22,13 +21,12 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - response: - status_code: "${expected_status_code}" - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + validators: + resp_status_code: {"comparator": "eq", "expected": 201} + resp_body_success: {"comparator": "eq", "expected": true} - test: name: create user which does not exist @@ -44,7 +42,6 @@ - random: {"func": "gen_random_string", "args": [5]} - data: '{"name": "user", "password": "123456"}' - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} - - expected_status_code: 500 request: url: http://127.0.0.1:5000/api/users/1000 method: POST @@ -53,10 +50,9 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - response: - status_code: "${expected_status_code}" - headers: - Content-Type: application/json - body: - success: false - msg: user already existed. + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + validators: + resp_status_code: {"comparator": "eq", "expected": 500} + resp_body_success: {"comparator": "eq", "expected": false} diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 2b33dd387..2f4cbb74e 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -17,7 +17,6 @@ name: create user which does not exist variable_binds: - data: '{"name": "user", "password": "123456"}' - - expected_status_code: 201 request: url: http://127.0.0.1:5000/api/users/1000 method: POST @@ -26,13 +25,12 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - response: - status_code: "${expected_status_code}" - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + validators: + resp_status_code: {"comparator": "eq", "expected": 201} + resp_body_success: {"comparator": "eq", "expected": true} - test: name: create user which does not exist @@ -47,10 +45,9 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - response: - status_code: "${expected_status_code}" - headers: - Content-Type: application/json - body: - success: false - msg: user already existed. + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + validators: + resp_status_code: {"comparator": "eq", "expected": 500} + resp_body_success: {"comparator": "eq", "expected": false} diff --git a/test/data/simple_demo_auth_hardcode.json b/test/data/simple_demo_auth_hardcode.json index fc30e0dd1..27d191abe 100644 --- a/test/data/simple_demo_auth_hardcode.json +++ b/test/data/simple_demo_auth_hardcode.json @@ -15,15 +15,14 @@ "password": "123456" } }, - "response": { - "status_code": 201, - "headers": { - "Content-Type": "application/json" - }, - "body": { - "success": true, - "msg": "user created successfully." - } + "extract_binds": { + "resp_status_code": "status_code", + "resp_body_success": "content.success", + "resp_body_msg": "content.msg" + }, + "validators": { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq", "expected": true} } } }, @@ -43,15 +42,13 @@ "password": "123456" } }, - "response": { - "status_code": 500, - "headers": { - "Content-Type": "application/json" - }, - "body":{ - "success": false, - "msg": "user already existed." - } + "extract_binds": { + "resp_status_code": "status_code", + "resp_body_success": "content.success" + }, + "validators": { + "resp_status_code": {"comparator": "eq", "expected": 500}, + "resp_body_success": {"comparator": "eq", "expected": false} } } } diff --git a/test/data/simple_demo_auth_hardcode.yml b/test/data/simple_demo_auth_hardcode.yml index f8ccfda7a..8d0c02043 100644 --- a/test/data/simple_demo_auth_hardcode.yml +++ b/test/data/simple_demo_auth_hardcode.yml @@ -10,13 +10,13 @@ json: name: "user1" password: "123456" - response: - status_code: 201 - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + resp_body_msg: content.msg + validators: + resp_status_code: {"comparator": "eq", "expected": 201} + resp_body_success: {"comparator": "eq", "expected": true} - test: name: create user which existed @@ -30,10 +30,9 @@ json: name: "user1" password: "123456" - response: - status_code: 500 - headers: - Content-Type: application/json - body: - success: false - msg: user already existed. \ No newline at end of file + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + validators: + resp_status_code: {"comparator": "eq", "expected": 500} + resp_body_success: {"comparator": "eq", "expected": false} \ No newline at end of file diff --git a/test/data/simple_demo_no_auth.json b/test/data/simple_demo_no_auth.json index 6f5bb8338..a076ddcf6 100644 --- a/test/data/simple_demo_no_auth.json +++ b/test/data/simple_demo_no_auth.json @@ -14,15 +14,14 @@ "password": "123456" } }, - "response": { - "status_code": 201, - "headers": { - "Content-Type": "application/json" - }, - "body": { - "success": true, - "msg": "user created successfully." - } + "extract_binds": { + "resp_status_code": "status_code", + "resp_body_success": "content.success", + "resp_body_msg": "content.msg" + }, + "validators": { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq", "expected": true} } } }, @@ -40,15 +39,13 @@ "password": "123456" } }, - "response": { - "status_code": 500, - "headers": { - "Content-Type": "application/json" - }, - "body":{ - "success": false, - "msg": "user already existed." - } + "extract_binds": { + "resp_status_code": "status_code", + "resp_body_success": "content.success" + }, + "validators": { + "resp_status_code": {"comparator": "eq", "expected": 500}, + "resp_body_success": {"comparator": "eq", "expected": false} } } } diff --git a/test/data/simple_demo_no_auth.yml b/test/data/simple_demo_no_auth.yml index 7eb49cbd6..4be5460ec 100644 --- a/test/data/simple_demo_no_auth.yml +++ b/test/data/simple_demo_no_auth.yml @@ -8,13 +8,14 @@ json: name: user1 password: 123456 - response: - status_code: 201 - headers: - Content-Type: application/json - body: - success: true - msg: user created successfully. + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + resp_headers_content_type: headers.content-type + validators: + resp_status_code: {"comparator": "eq", "expected": 201} + resp_headers_content_type: {"comparator": "eq", "expected": "application/json"} + resp_body_success: {"comparator": "eq", "expected": true} - test: name: create user which existed @@ -26,10 +27,11 @@ json: name: user1 password: 123456 - response: - status_code: 500 - headers: - Content-Type: application/json - body: - success: false - msg: user already existed. \ No newline at end of file + extract_binds: + resp_status_code: status_code + resp_body_success: content.success + resp_headers_content_type: headers.content-type + validators: + resp_status_code: {"comparator": "eq", "expected": 500} + resp_headers_content_type: {"comparator": "eq", "expected": "application/json"} + resp_body_success: {"comparator": "eq", "expected": false} \ No newline at end of file diff --git a/test/test_response.py b/test/test_response.py index ce2bb419c..73ba023b3 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -1,6 +1,5 @@ -import random import requests -from ate import response, context, exception +from ate import response, exception from test.base import ApiServerUnittest class TestResponse(ApiServerUnittest): @@ -29,199 +28,6 @@ def test_parse_response_object_text(self): self.assertIn('Content-Length', parsed_dict['headers']) self.assertTrue(str, type(parsed_dict['body'])) - def test_diff_response_status_code_equal(self): - status_code = random.randint(200, 511) - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'status_code': status_code, - } - ) - - expected_resp_json = { - 'status_code': status_code - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertFalse(diff_content) - - def test_diff_response_status_code_not_equal(self): - status_code = random.randint(200, 511) - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'status_code': status_code, - } - ) - - expected_resp_json = { - 'status_code': 512 - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertIn('value', diff_content['status_code']) - self.assertIn('expected', diff_content['status_code']) - self.assertEqual(diff_content['status_code']['value'], status_code) - self.assertEqual(diff_content['status_code']['expected'], 512) - - def test_diff_response_headers_equal(self): - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'headers': { - 'abc': 123, - 'def': 456 - } - } - ) - - expected_resp_json = { - 'headers': { - 'abc': 123, - 'def': '456' - } - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertFalse(diff_content) - - def test_diff_response_headers_not_equal(self): - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'headers': { - 'a': 123, - 'b': '456', - 'c': '789' - } - } - ) - - expected_resp_json = { - 'headers': { - 'a': '123', - 'b': '457', - 'd': 890 - } - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertEqual( - diff_content['headers'], - { - 'b': {'expected': '457', 'value': '456'}, - 'd': {'expected': 890, 'value': None} - } - ) - - def test_diff_response_body_equal(self): - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': { - 'success': True, - 'count': 10 - } - } - ) - - # expected response body is not specified - expected_resp_json = {} - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertFalse(diff_content) - - # response body is the same as expected response body - expected_resp_json = { - 'body': { - 'success': True, - 'count': '10' - } - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertFalse(diff_content) - - def test_diff_response_body_not_equal_type_unmatch(self): - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': { - 'success': True, - 'count': 10 - } - } - ) - - # response body content type not match - expected_resp_json = { - 'body': "ok" - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertEqual( - diff_content['body'], - { - 'value': {'success': True, 'count': 10}, - 'expected': 'ok' - } - ) - - def test_diff_response_body_not_equal_string_unmatch(self): - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': "success" - } - ) - - # response body content type matched to be string, while value unmatch - expected_resp_json = { - 'body': "ok" - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertEqual( - diff_content['body'], - { - 'value': 'success', - 'expected': 'ok' - } - ) - - def test_diff_response_body_not_equal_json_unmatch(self): - resp = requests.post( - url="http://127.0.0.1:5000/customize-response", - json={ - 'body': { - 'success': False - } - } - ) - - # response body is the same as expected response body - expected_resp_json = { - 'body': { - 'success': True, - 'count': 10 - } - } - resp_obj = response.ResponseObject(resp) - diff_content = resp_obj.diff_response(expected_resp_json) - self.assertEqual( - diff_content['body'], - { - 'success': { - 'value': False, - 'expected': True - }, - 'count': { - 'value': None, - 'expected': 10 - } - } - ) - def test_extract_response_json(self): resp = requests.post( url="http://127.0.0.1:5000/customize-response", @@ -252,12 +58,9 @@ def test_extract_response_json(self): "resp_content_person_first_name": "content.person.name.first_name", "resp_content_cities_1": "content.person.cities.1" } + resp_obj = response.ResponseObject(resp) + extract_binds_dict = resp_obj.extract_response(extract_binds) - test_context = context.Context() - test_context.bind_extractors(extract_binds) - response.ResponseObject(resp, test_context) - - extract_binds_dict = test_context.extractors self.assertEqual( extract_binds_dict["resp_status_code"], 200 @@ -283,7 +86,6 @@ def test_extract_response_json(self): "Shenzhen" ) - def test_extract_response_fail(self): resp = requests.post( url="http://127.0.0.1:5000/customize-response", @@ -308,22 +110,18 @@ def test_extract_response_fail(self): extract_binds = { "resp_content_dict_key_error": "content.not_exist" } - - test_context = context.Context() - test_context.bind_extractors(extract_binds) + resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - response.ResponseObject(resp, test_context) + resp_obj.extract_response(extract_binds) extract_binds = { "resp_content_list_index_error": "content.person.cities.3" } - - test_context = context.Context() - test_context.bind_extractors(extract_binds) + resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - response.ResponseObject(resp, test_context) + resp_obj.extract_response(extract_binds) def test_extract_response_json_string(self): resp = requests.post( @@ -339,13 +137,48 @@ def test_extract_response_json_string(self): extract_binds = { "resp_content_body": "content" } + resp_obj = response.ResponseObject(resp) - test_context = context.Context() - test_context.bind_extractors(extract_binds) - resp_obj = response.ResponseObject(resp, test_context) - - extract_binds_dict = test_context.extractors + extract_binds_dict = resp_obj.extract_response(extract_binds) self.assertEqual( extract_binds_dict["resp_content_body"], "abc" ) + + def test_validate(self): + url = "http://127.0.0.1:5000/" + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + + validators = { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq", "expected": True} + } + variables_mapping = { + "resp_status_code": 200, + "resp_body_success": True + } + + diff_content_dict = resp_obj.validate(validators, variables_mapping) + self.assertFalse(resp_obj.success) + self.assertEqual( + diff_content_dict, + { + "resp_status_code": { + "comparator": "eq", "expected": 201, "value": 200 + } + } + ) + + validators = { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq", "expected": True} + } + variables_mapping = { + "resp_status_code": 201, + "resp_body_success": True + } + + diff_content_dict = resp_obj.validate(validators, variables_mapping) + self.assertTrue(resp_obj.success) + self.assertEqual(diff_content_dict, {}) diff --git a/test/test_runner.py b/test/test_runner.py index c767e1853..43b122a4a 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -41,39 +41,31 @@ def test_run_single_testcase_fail(self): "password": "123456" } }, - "response": { - "status_code": 200, - "headers": { - "Content-Type": "html/text" - }, - "body": { - 'success': False, - 'msg': "user already existed." - } + "extract_binds": { + "resp_status_code": "status_code", + "resp_body_success": "content.success", + "resp_headers_contenttype": "headers.content-type" + }, + "validators": { + "resp_status_code": {"comparator": "eq", "expected": 200}, + "resp_body_success": {"comparator": "eq", "expected": False}, + "resp_headers_contenttype": {"comparator": "eq", "expected": "html/text"} } } + success, diff_content = self.test_runner.run_test(testcase) self.assertFalse(success) self.assertEqual( - diff_content['status_code'], - {'expected': 200, 'value': 201} + diff_content['resp_status_code'], + {"comparator": "eq", "expected": 200, 'value': 201} ) self.assertEqual( - diff_content['headers'], - {'Content-Type': {'expected': 'html/text', 'value': 'application/json'}} + diff_content['resp_body_success'], + {"comparator": "eq", "expected": False, 'value': True} ) self.assertEqual( - diff_content['body'], - { - 'msg': { - 'expected': 'user already existed.', - 'value': 'user created successfully.' - }, - 'success': { - 'expected': False, - 'value': True - } - } + diff_content['resp_headers_contenttype'], + {"comparator": "eq", "expected": "html/text", 'value': "application/json"} ) def test_run_testset_json_success(self): diff --git a/test/test_testcase.py b/test/test_testcase.py index 62f4a7318..a91c284f0 100644 --- a/test/test_testcase.py +++ b/test/test_testcase.py @@ -1,6 +1,6 @@ import unittest -from ate.testcase import TestcaseParser +from ate.testcase import parse_template from ate import exception @@ -18,7 +18,6 @@ def setUp(self): "expected_status": 201, "expected_success": True } - self.testcase_parser = TestcaseParser(self.variables_binds) def test_parse_testcase_template(self): testcase = { @@ -43,7 +42,7 @@ def test_parse_testcase_template(self): } } } - parsed_testcase = self.testcase_parser.parse(testcase) + parsed_testcase = parse_template(testcase, self.variables_binds) self.assertEqual( parsed_testcase["request"]["url"], @@ -78,7 +77,7 @@ def test_parse_testcase_template_miss_bind_variable(self): } } with self.assertRaises(exception.ParamsError): - self.testcase_parser.parse(testcase) + parse_template(testcase, self.variables_binds) def test_parse_testcase_with_new_variable_binds(self): testcase = { @@ -90,10 +89,9 @@ def test_parse_testcase_with_new_variable_binds(self): new_variable_binds = { "method": "GET" } - self.testcase_parser.update_variables_binds(new_variable_binds) - parsed_testcase = self.testcase_parser.parse(testcase) + self.variables_binds.update(new_variable_binds) + parsed_testcase = parse_template(testcase, self.variables_binds) - self.assertIn("method", self.testcase_parser.variables_binds) self.assertEqual( parsed_testcase["request"]["method"], new_variable_binds["method"] diff --git a/test/test_utils.py b/test/test_utils.py index dd323e580..3641c20f9 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -18,7 +18,6 @@ def test_load_json_testcases(self): testcase = testcases[0]["test"] self.assertIn('name', testcase) self.assertIn('request', testcase) - self.assertIn('response', testcase) self.assertIn('url', testcase['request']) self.assertIn('method', testcase['request']) @@ -30,7 +29,6 @@ def test_load_yaml_testcases(self): testcase = testcases[0]["test"] self.assertIn('name', testcase) self.assertIn('request', testcase) - self.assertIn('response', testcase) self.assertIn('url', testcase['request']) self.assertIn('method', testcase['request']) @@ -76,7 +74,6 @@ def test_load_testcases_by_path_files(self): for testcase in testset["testcases"]: self.assertIn('name', testcase) self.assertIn('request', testcase) - self.assertIn('response', testcase) self.assertIn('url', testcase['request']) self.assertIn('method', testcase['request']) @@ -179,3 +176,11 @@ def test_query_json(self): query = "person.name.first_name" result = utils.query_json(json_content, query) self.assertEqual(result, "Leo") + + def test_compare(self): + self.assertEqual(utils.compare(1, 1, "eq"), False) + self.assertEqual(utils.compare("abc", "abc", "eq"), False) + self.assertEqual(utils.compare("abc", "abc"), False) + + self.assertEqual(utils.compare(123, "123", "eq"), True) + self.assertEqual(utils.compare(123, "123"), True) From a9c4af3a82e96f60c28586179e41656afee69429 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 15:39:14 +0800 Subject: [PATCH 077/354] change method of checking python version --- ate/response.py | 6 +----- ate/utils.py | 5 +++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ate/response.py b/ate/response.py index 752d2348e..dc0cb3f61 100644 --- a/ate/response.py +++ b/ate/response.py @@ -1,9 +1,5 @@ from ate import utils, exception -try: - basestring # Python 2.x -except NameError: - basestring = str # Python 3.x class ResponseObject(object): @@ -40,7 +36,7 @@ def extract_response(self, extract_binds, delimiter='.'): extract_binds_dict = {} for key, value in extract_binds.items(): - if not isinstance(value, basestring): + if not isinstance(value, utils.string_type): raise exception.ParamsError("invalid extract_binds!") try: diff --git a/ate/utils.py b/ate/utils.py index e3a0b86e7..0f5d5190e 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -9,9 +9,10 @@ from ate.exception import ParamsError try: - assert bytes is str + string_type = basestring PYTHON_VERSION = 2 -except AssertionError: +except NameError: + string_type = str PYTHON_VERSION = 3 From 5da07a1652dc89983eb891608ed760047de95a50 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 16:13:20 +0800 Subject: [PATCH 078/354] add comparators --- ate/response.py | 4 ++-- ate/utils.py | 46 +++++++++++++++++++++++++++++----------------- test/test_utils.py | 26 ++++++++++++++++++++------ 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/ate/response.py b/ate/response.py index dc0cb3f61..d46a57fdd 100644 --- a/ate/response.py +++ b/ate/response.py @@ -92,13 +92,13 @@ def validate(self, validators, variables_mapping): except KeyError: raise exception.ParamsError("invalid validator %s" % validator_key) - difference_exist = utils.compare( + match_expected = utils.match_expected( value, validator_dict["expected"], validator_dict["comparator"] ) - if difference_exist: + if not match_expected: diff_content_dict[validator_key] = validator_dict self.success = False if diff_content_dict else True diff --git a/ate/utils.py b/ate/utils.py index 0f5d5190e..b72cafaf1 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -181,24 +181,36 @@ def query_json(json_content, query, delimiter='.'): return json_content -def diff_json(current_json, expected_json): - json_diff = {} - - for key, expected_value in expected_json.items(): - value = current_json.get(key, None) - if str(value) != str(expected_value): - json_diff[key] = { - 'value': value, - 'expected': expected_value - } - - return json_diff - -def compare(value, expected, comparator="eq"): +def match_expected(value, expected, comparator="eq"): + """ check if value matches expected value. + @param value: value that get from response. + @param expected: expected result described in testcase + @param comparator: compare method + """ try: - if comparator in ["eq", "=="]: + if comparator in ["eq", "equals", "=="]: assert value == expected + elif comparator in ["str_eq", "string_equals"]: + assert str(value) == str(expected) + elif comparator in ["ne", "not_equals"]: + assert value != expected + elif comparator in ["len_eq", "length_equal", "count_eq"]: + assert len(value) == len(expected) + elif comparator in ["lt", "less_than"]: + assert value < expected + elif comparator in ["le", "less_than_or_equals"]: + assert value <= expected + elif comparator in ["gt", "greater_than"]: + assert value > expected + elif comparator in ["ge", "greater_than_or_equals"]: + assert value >= expected + elif comparator in ["contains"]: + assert expected in value + elif comparator in ["contained_by"]: + assert value in expected + elif comparator in ["regex"]: + assert re.match(expected, value) - return False - except AssertionError: return True + except AssertionError: + return False diff --git a/test/test_utils.py b/test/test_utils.py index 3641c20f9..3a191e085 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -178,9 +178,23 @@ def test_query_json(self): self.assertEqual(result, "Leo") def test_compare(self): - self.assertEqual(utils.compare(1, 1, "eq"), False) - self.assertEqual(utils.compare("abc", "abc", "eq"), False) - self.assertEqual(utils.compare("abc", "abc"), False) - - self.assertEqual(utils.compare(123, "123", "eq"), True) - self.assertEqual(utils.compare(123, "123"), True) + self.assertTrue(utils.match_expected(1, 1, "eq")) + self.assertTrue(utils.match_expected("abc", "abc", "eq")) + self.assertTrue(utils.match_expected("abc", "abc")) + self.assertFalse(utils.match_expected(123, "123", "eq")) + self.assertFalse(utils.match_expected(123, "123")) + + self.assertTrue(utils.match_expected("123", "345", "len_eq")) + self.assertTrue(utils.match_expected(123, "123", "str_eq")) + self.assertTrue(utils.match_expected(123, "123", "ne")) + + self.assertTrue(utils.match_expected(1, 2, "lt")) + self.assertTrue(utils.match_expected(1, 1, "le")) + self.assertTrue(utils.match_expected(2, 1, "gt")) + self.assertTrue(utils.match_expected(1, 1, "ge")) + + self.assertTrue(utils.match_expected("123abc456", "3ab", "contains")) + self.assertTrue(utils.match_expected("3ab", "123abc456", "contained_by")) + + self.assertTrue(utils.match_expected("123abc456", "^123.*456$", "regex")) + self.assertFalse(utils.match_expected("123abc456", "^12b.*456$", "regex")) \ No newline at end of file From 1bca625f4223b000ac785664f18f4aed55012e89 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 16:36:59 +0800 Subject: [PATCH 079/354] validate response: check validators and variables_mapping --- ate/response.py | 5 +++-- test/test_response.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/ate/response.py b/ate/response.py index d46a57fdd..83caedb9c 100644 --- a/ate/response.py +++ b/ate/response.py @@ -89,13 +89,14 @@ def validate(self, validators, variables_mapping): try: value = variables_mapping[validator_key] validator_dict["value"] = value + expected_value = validator_dict["expected"] except KeyError: raise exception.ParamsError("invalid validator %s" % validator_key) match_expected = utils.match_expected( value, - validator_dict["expected"], - validator_dict["comparator"] + expected_value, + validator_dict.get("comparator", "eq") ) if not match_expected: diff --git a/test/test_response.py b/test/test_response.py index 73ba023b3..c4d02723c 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -182,3 +182,31 @@ def test_validate(self): diff_content_dict = resp_obj.validate(validators, variables_mapping) self.assertTrue(resp_obj.success) self.assertEqual(diff_content_dict, {}) + + def test_validate_exception(self): + url = "http://127.0.0.1:5000/" + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + + # expected value missed in validators + validators = { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq"} + } + variables_mapping = { + "resp_status_code": 200, + "resp_body_success": True + } + with self.assertRaises(exception.ParamsError): + resp_obj.validate(validators, variables_mapping) + + # expected value missed in validators + validators = { + "resp_status_code": {"comparator": "eq", "expected": 201}, + "resp_body_success": {"comparator": "eq", "expected": True} + } + variables_mapping = { + "resp_status_code": 200 + } + with self.assertRaises(exception.ParamsError): + resp_obj.validate(validators, variables_mapping) From bfa2227d10fdace74cd7275d562103616fd4cdf2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 17:19:06 +0800 Subject: [PATCH 080/354] do not need to config extract_binds if only want to validate results --- ate/response.py | 74 ++++++++++++++---------- ate/runner.py | 4 +- test/data/demo_template_separate.yml | 14 ++--- test/data/demo_template_sets.yml | 14 ++--- test/data/simple_demo_auth_hardcode.json | 17 ++---- test/data/simple_demo_auth_hardcode.yml | 15 ++--- test/data/simple_demo_no_auth.json | 17 ++---- test/data/simple_demo_no_auth.yml | 20 ++----- 8 files changed, 70 insertions(+), 105 deletions(-) diff --git a/ate/response.py b/ate/response.py index 83caedb9c..15e08f458 100644 --- a/ate/response.py +++ b/ate/response.py @@ -23,7 +23,36 @@ def parsed_dict(self): 'body': self.parsed_body() } - def extract_response(self, extract_binds, delimiter='.'): + def extract_field(self, field, delimiter='.'): + """ extract field from requests.Response + @param (str) field of requests.Response object, and may be joined by delimiter + "status_code" + "content" + "headers.content-type" + "content.person.name.first_name" + """ + try: + field += "." + # string.split(sep=None, maxsplit=-1) -> list of strings + # e.g. "content.person.name" => ["content", "person.name"] + top_query, sub_query = field.split(delimiter, 1) + + if top_query in ["body", "content", "text"]: + json_content = self.parsed_body() + else: + json_content = getattr(self.resp_obj, top_query) + + if sub_query: + # e.g. key: resp_headers_content_type, sub_query = "content-type" + return utils.query_json(json_content, sub_query) + else: + # e.g. key: resp_status_code, resp_content + return json_content + + except AttributeError: + raise exception.ParamsError("invalid extract_binds!") + + def extract_response(self, extract_binds): """ extract content from requests.Response @param (dict) extract_binds { @@ -33,35 +62,15 @@ def extract_response(self, extract_binds, delimiter='.'): "resp_content_person_first_name": "content.person.name.first_name" } """ - extract_binds_dict = {} + extracted_variables_mapping = {} - for key, value in extract_binds.items(): - if not isinstance(value, utils.string_type): + for key, field in extract_binds.items(): + if not isinstance(field, utils.string_type): raise exception.ParamsError("invalid extract_binds!") - try: - value += "." - # string.split(sep=None, maxsplit=-1) -> list of strings - # e.g. "content.person.name" => ["content", "person.name"] - top_query, sub_query = value.split(delimiter, 1) - - if top_query in ["body", "content", "text"]: - json_content = self.parsed_body() - else: - json_content = getattr(self.resp_obj, top_query) - - if sub_query: - # e.g. key: resp_headers_content_type, sub_query = "content-type" - answer = utils.query_json(json_content, sub_query) - extract_binds_dict[key] = answer - else: - # e.g. key: resp_status_code, resp_content - extract_binds_dict[key] = json_content - - except AttributeError: - raise exception.ParamsError("invalid extract_binds!") + extracted_variables_mapping[key] = self.extract_field(field) - return extract_binds_dict + return extracted_variables_mapping def validate(self, validators, variables_mapping): """ Bind named validators to value within the context. @@ -87,15 +96,16 @@ def validate(self, validators, variables_mapping): for validator_key, validator_dict in validators.items(): try: - value = variables_mapping[validator_key] - validator_dict["value"] = value - expected_value = validator_dict["expected"] + validator_dict["value"] = variables_mapping[validator_key] except KeyError: - raise exception.ParamsError("invalid validator %s" % validator_key) + validator_dict["value"] = self.extract_field(validator_key) + + if "expected" not in validator_dict: + raise exception.ParamsError("expected not specified in validator") match_expected = utils.match_expected( - value, - expected_value, + validator_dict["value"], + validator_dict["expected"], validator_dict.get("comparator", "eq") ) diff --git a/ate/runner.py b/ate/runner.py index 1b58996ae..2c3b4f001 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -77,8 +77,8 @@ def run_test(self, testcase): resp_obj = response.ResponseObject(resp) extract_binds = testcase.get("extract_binds", {}) - extract_binds_dict = resp_obj.extract_response(extract_binds) - self.context.update_variables(extract_binds_dict) + extracted_variables_mapping = resp_obj.extract_response(extract_binds) + self.context.update_variables(extracted_variables_mapping) validators = testcase.get("validators", {}) diff_content_dict = resp_obj.validate(validators, self.context.variables) diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index 27a999c7c..6a1b351e8 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -21,12 +21,9 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - extract_binds: - resp_status_code: status_code - resp_body_success: content.success validators: - resp_status_code: {"comparator": "eq", "expected": 201} - resp_body_success: {"comparator": "eq", "expected": true} + status_code: {"comparator": "eq", "expected": 201} + content.success: {"comparator": "eq", "expected": true} - test: name: create user which does not exist @@ -50,9 +47,6 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - extract_binds: - resp_status_code: status_code - resp_body_success: content.success validators: - resp_status_code: {"comparator": "eq", "expected": 500} - resp_body_success: {"comparator": "eq", "expected": false} + status_code: {"comparator": "eq", "expected": 500} + content.success: {"comparator": "eq", "expected": false} diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 2f4cbb74e..754433a46 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -25,12 +25,9 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - extract_binds: - resp_status_code: status_code - resp_body_success: content.success validators: - resp_status_code: {"comparator": "eq", "expected": 201} - resp_body_success: {"comparator": "eq", "expected": true} + status_code: {"comparator": "eq", "expected": 201} + content.success: {"comparator": "eq", "expected": true} - test: name: create user which does not exist @@ -45,9 +42,6 @@ authorization: "${authorization}" random: "${random}" data: "${data}" - extract_binds: - resp_status_code: status_code - resp_body_success: content.success validators: - resp_status_code: {"comparator": "eq", "expected": 500} - resp_body_success: {"comparator": "eq", "expected": false} + status_code: {"comparator": "eq", "expected": 500} + content.success: {"comparator": "eq", "expected": false} diff --git a/test/data/simple_demo_auth_hardcode.json b/test/data/simple_demo_auth_hardcode.json index 27d191abe..840288dc5 100644 --- a/test/data/simple_demo_auth_hardcode.json +++ b/test/data/simple_demo_auth_hardcode.json @@ -15,14 +15,9 @@ "password": "123456" } }, - "extract_binds": { - "resp_status_code": "status_code", - "resp_body_success": "content.success", - "resp_body_msg": "content.msg" - }, "validators": { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq", "expected": true} + "status_code": {"comparator": "eq", "expected": 201}, + "content.success": {"comparator": "eq", "expected": true} } } }, @@ -42,13 +37,9 @@ "password": "123456" } }, - "extract_binds": { - "resp_status_code": "status_code", - "resp_body_success": "content.success" - }, "validators": { - "resp_status_code": {"comparator": "eq", "expected": 500}, - "resp_body_success": {"comparator": "eq", "expected": false} + "status_code": {"comparator": "eq", "expected": 500}, + "content.success": {"comparator": "eq", "expected": false} } } } diff --git a/test/data/simple_demo_auth_hardcode.yml b/test/data/simple_demo_auth_hardcode.yml index 8d0c02043..15f66e06e 100644 --- a/test/data/simple_demo_auth_hardcode.yml +++ b/test/data/simple_demo_auth_hardcode.yml @@ -10,13 +10,9 @@ json: name: "user1" password: "123456" - extract_binds: - resp_status_code: status_code - resp_body_success: content.success - resp_body_msg: content.msg validators: - resp_status_code: {"comparator": "eq", "expected": 201} - resp_body_success: {"comparator": "eq", "expected": true} + status_code: {"comparator": "eq", "expected": 201} + content.success: {"comparator": "eq", "expected": true} - test: name: create user which existed @@ -30,9 +26,6 @@ json: name: "user1" password: "123456" - extract_binds: - resp_status_code: status_code - resp_body_success: content.success validators: - resp_status_code: {"comparator": "eq", "expected": 500} - resp_body_success: {"comparator": "eq", "expected": false} \ No newline at end of file + status_code: {"comparator": "eq", "expected": 500} + content.success: {"comparator": "eq", "expected": false} \ No newline at end of file diff --git a/test/data/simple_demo_no_auth.json b/test/data/simple_demo_no_auth.json index a076ddcf6..ae6c2aaaf 100644 --- a/test/data/simple_demo_no_auth.json +++ b/test/data/simple_demo_no_auth.json @@ -14,14 +14,9 @@ "password": "123456" } }, - "extract_binds": { - "resp_status_code": "status_code", - "resp_body_success": "content.success", - "resp_body_msg": "content.msg" - }, "validators": { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq", "expected": true} + "status_code": {"comparator": "eq", "expected": 201}, + "content.success": {"comparator": "eq", "expected": true} } } }, @@ -39,13 +34,9 @@ "password": "123456" } }, - "extract_binds": { - "resp_status_code": "status_code", - "resp_body_success": "content.success" - }, "validators": { - "resp_status_code": {"comparator": "eq", "expected": 500}, - "resp_body_success": {"comparator": "eq", "expected": false} + "status_code": {"comparator": "eq", "expected": 500}, + "content.success": {"comparator": "eq", "expected": false} } } } diff --git a/test/data/simple_demo_no_auth.yml b/test/data/simple_demo_no_auth.yml index 4be5460ec..7a6bf078c 100644 --- a/test/data/simple_demo_no_auth.yml +++ b/test/data/simple_demo_no_auth.yml @@ -8,14 +8,10 @@ json: name: user1 password: 123456 - extract_binds: - resp_status_code: status_code - resp_body_success: content.success - resp_headers_content_type: headers.content-type validators: - resp_status_code: {"comparator": "eq", "expected": 201} - resp_headers_content_type: {"comparator": "eq", "expected": "application/json"} - resp_body_success: {"comparator": "eq", "expected": true} + status_code: {"comparator": "eq", "expected": 201} + headers.content-type: {"comparator": "eq", "expected": "application/json"} + content.success: {"comparator": "eq", "expected": true} - test: name: create user which existed @@ -27,11 +23,7 @@ json: name: user1 password: 123456 - extract_binds: - resp_status_code: status_code - resp_body_success: content.success - resp_headers_content_type: headers.content-type validators: - resp_status_code: {"comparator": "eq", "expected": 500} - resp_headers_content_type: {"comparator": "eq", "expected": "application/json"} - resp_body_success: {"comparator": "eq", "expected": false} \ No newline at end of file + status_code: {"comparator": "eq", "expected": 500} + headers.content-type: {"comparator": "eq", "expected": "application/json"} + content.success: {"comparator": "eq", "expected": false} \ No newline at end of file From 387e5beeabd4720ebe9d9fde44e3dde5d0c11279 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 17:27:23 +0800 Subject: [PATCH 081/354] bugfix: match_expected should raise ParamsError when comparator is not supported --- ate/utils.py | 2 ++ test/test_utils.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index b72cafaf1..50a93ad6c 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -210,6 +210,8 @@ def match_expected(value, expected, comparator="eq"): assert value in expected elif comparator in ["regex"]: assert re.match(expected, value) + else: + raise ParamsError("comparator not supported!") return True except AssertionError: diff --git a/test/test_utils.py b/test/test_utils.py index 3a191e085..86aacec33 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -197,4 +197,7 @@ def test_compare(self): self.assertTrue(utils.match_expected("3ab", "123abc456", "contained_by")) self.assertTrue(utils.match_expected("123abc456", "^123.*456$", "regex")) - self.assertFalse(utils.match_expected("123abc456", "^12b.*456$", "regex")) \ No newline at end of file + self.assertFalse(utils.match_expected("123abc456", "^12b.*456$", "regex")) + + with self.assertRaises(exception.ParamsError): + utils.match_expected(1, 2, "not_supported_comparator") From d21f04143797576b29aa5838edae3945df9ca227 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 17:32:49 +0800 Subject: [PATCH 082/354] match_expected: add comparator str_len/string_length --- ate/utils.py | 2 ++ test/test_utils.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 50a93ad6c..a9685349d 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -210,6 +210,8 @@ def match_expected(value, expected, comparator="eq"): assert value in expected elif comparator in ["regex"]: assert re.match(expected, value) + elif comparator in ["str_len", "string_length"]: + assert len(value) == int(expected) else: raise ParamsError("comparator not supported!") diff --git a/test/test_utils.py b/test/test_utils.py index 86aacec33..a607ce46d 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -201,3 +201,6 @@ def test_compare(self): with self.assertRaises(exception.ParamsError): utils.match_expected(1, 2, "not_supported_comparator") + + self.assertTrue(utils.match_expected("2017-06-29 17:29:58", 19, "str_len")) + self.assertTrue(utils.match_expected("2017-06-29 17:29:58", "19", "str_len")) From 87ef0b3cd6c9db12dfb2f23a7fd37557155a298c Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 18:13:50 +0800 Subject: [PATCH 083/354] change validators from dict to list, as there may be several tests on one filed --- ate/main.py | 2 +- ate/response.py | 37 ++++++++-------- ate/runner.py | 10 ++--- test/data/demo_template_separate.yml | 8 ++-- test/data/demo_template_sets.yml | 8 ++-- test/data/simple_demo_auth_hardcode.json | 16 +++---- test/data/simple_demo_auth_hardcode.yml | 8 ++-- test/data/simple_demo_no_auth.json | 16 +++---- test/data/simple_demo_no_auth.yml | 12 +++--- test/test_response.py | 54 ++++++++++++------------ test/test_runner.py | 32 +++++++------- test/test_runner_v2.py | 12 +++--- 12 files changed, 107 insertions(+), 108 deletions(-) diff --git a/ate/main.py b/ate/main.py index 6e0e3cfb1..997b3da14 100644 --- a/ate/main.py +++ b/ate/main.py @@ -16,7 +16,7 @@ def runTest(self): """ run testcase and check result. """ result = self.test_runner.run_test(self.testcase) - self.assertEqual(result, (True, {})) + self.assertEqual(result, (True, [])) def create_suite(testset): """ create test suite with a testset, it may include one or several testcases. diff --git a/ate/response.py b/ate/response.py index 15e08f458..a5d73f93a 100644 --- a/ate/response.py +++ b/ate/response.py @@ -74,35 +74,36 @@ def extract_response(self, extract_binds): def validate(self, validators, variables_mapping): """ Bind named validators to value within the context. - @param (dict) validators - { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq", "expected": True} - } + @param (list) validators + [ + {"check": "status_code", "comparator": "eq", "expected": 201}, + {"check": "resp_body_success", "comparator": "eq", "expected": True} + ] @param (dict) variables_mapping { - "resp_status_code": 200, "resp_body_success": True } - @return (dict) content differences - { - "resp_status_code": { + @return (list) content differences + [ + { + "check": "status_code", "comparator": "eq", "expected": 201, "value": 200 } - } + ] """ - diff_content_dict = {} + diff_content_list = [] - for validator_key, validator_dict in validators.items(): + for validator_dict in validators: + if "expected" not in validator_dict or "check" not in validator_dict: + raise exception.ParamsError("expected not specified in validator") + + validator_key = validator_dict["check"] try: validator_dict["value"] = variables_mapping[validator_key] except KeyError: validator_dict["value"] = self.extract_field(validator_key) - if "expected" not in validator_dict: - raise exception.ParamsError("expected not specified in validator") - match_expected = utils.match_expected( validator_dict["value"], validator_dict["expected"], @@ -110,7 +111,7 @@ def validate(self, validators, variables_mapping): ) if not match_expected: - diff_content_dict[validator_key] = validator_dict + diff_content_list.append(validator_dict) - self.success = False if diff_content_dict else True - return diff_content_dict + self.success = False if diff_content_list else True + return diff_content_list diff --git a/ate/runner.py b/ate/runner.py index 2c3b4f001..f11198393 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -59,10 +59,10 @@ def run_test(self, testcase): "body": '{"name": "user", "password": "123456"}' }, "extract_binds": {}, - "validators": {} + "validators": [] } @return (tuple) test result of single testcase - (success, diff_content) + (success, diff_content_list) """ self.update_context(testcase) parsed_request = parse_template(testcase["request"], self.context.variables) @@ -80,10 +80,10 @@ def run_test(self, testcase): extracted_variables_mapping = resp_obj.extract_response(extract_binds) self.context.update_variables(extracted_variables_mapping) - validators = testcase.get("validators", {}) - diff_content_dict = resp_obj.validate(validators, self.context.variables) + validators = testcase.get("validators", []) + diff_content_list = resp_obj.validate(validators, self.context.variables) - return resp_obj.success, diff_content_dict + return resp_obj.success, diff_content_list def run_testset(self, testset): """ run single testset, including one or several testcases. diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index 6a1b351e8..389d49b93 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -22,8 +22,8 @@ random: "${random}" data: "${data}" validators: - status_code: {"comparator": "eq", "expected": 201} - content.success: {"comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} - test: name: create user which does not exist @@ -48,5 +48,5 @@ random: "${random}" data: "${data}" validators: - status_code: {"comparator": "eq", "expected": 500} - content.success: {"comparator": "eq", "expected": false} + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 754433a46..1036e7bdb 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -26,8 +26,8 @@ random: "${random}" data: "${data}" validators: - status_code: {"comparator": "eq", "expected": 201} - content.success: {"comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} - test: name: create user which does not exist @@ -43,5 +43,5 @@ random: "${random}" data: "${data}" validators: - status_code: {"comparator": "eq", "expected": 500} - content.success: {"comparator": "eq", "expected": false} + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/test/data/simple_demo_auth_hardcode.json b/test/data/simple_demo_auth_hardcode.json index 840288dc5..213c61b1f 100644 --- a/test/data/simple_demo_auth_hardcode.json +++ b/test/data/simple_demo_auth_hardcode.json @@ -15,10 +15,10 @@ "password": "123456" } }, - "validators": { - "status_code": {"comparator": "eq", "expected": 201}, - "content.success": {"comparator": "eq", "expected": true} - } + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 201}, + {"check": "content.success", "comparator": "eq", "expected": true} + ] } }, { @@ -37,10 +37,10 @@ "password": "123456" } }, - "validators": { - "status_code": {"comparator": "eq", "expected": 500}, - "content.success": {"comparator": "eq", "expected": false} - } + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 500}, + {"check": "content.success", "comparator": "eq", "expected": false} + ] } } ] \ No newline at end of file diff --git a/test/data/simple_demo_auth_hardcode.yml b/test/data/simple_demo_auth_hardcode.yml index 15f66e06e..21f432dbe 100644 --- a/test/data/simple_demo_auth_hardcode.yml +++ b/test/data/simple_demo_auth_hardcode.yml @@ -11,8 +11,8 @@ name: "user1" password: "123456" validators: - status_code: {"comparator": "eq", "expected": 201} - content.success: {"comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} - test: name: create user which existed @@ -27,5 +27,5 @@ name: "user1" password: "123456" validators: - status_code: {"comparator": "eq", "expected": 500} - content.success: {"comparator": "eq", "expected": false} \ No newline at end of file + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} \ No newline at end of file diff --git a/test/data/simple_demo_no_auth.json b/test/data/simple_demo_no_auth.json index ae6c2aaaf..35bb895aa 100644 --- a/test/data/simple_demo_no_auth.json +++ b/test/data/simple_demo_no_auth.json @@ -14,10 +14,10 @@ "password": "123456" } }, - "validators": { - "status_code": {"comparator": "eq", "expected": 201}, - "content.success": {"comparator": "eq", "expected": true} - } + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 201}, + {"check": "content.success", "comparator": "eq", "expected": true} + ] } }, { @@ -34,10 +34,10 @@ "password": "123456" } }, - "validators": { - "status_code": {"comparator": "eq", "expected": 500}, - "content.success": {"comparator": "eq", "expected": false} - } + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 500}, + {"check": "content.success", "comparator": "eq", "expected": false} + ] } } ] \ No newline at end of file diff --git a/test/data/simple_demo_no_auth.yml b/test/data/simple_demo_no_auth.yml index 7a6bf078c..aa6335770 100644 --- a/test/data/simple_demo_no_auth.yml +++ b/test/data/simple_demo_no_auth.yml @@ -9,9 +9,9 @@ name: user1 password: 123456 validators: - status_code: {"comparator": "eq", "expected": 201} - headers.content-type: {"comparator": "eq", "expected": "application/json"} - content.success: {"comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "headers.content-type", "comparator": "eq", "expected": "application/json"} - test: name: create user which existed @@ -24,6 +24,6 @@ name: user1 password: 123456 validators: - status_code: {"comparator": "eq", "expected": 500} - headers.content-type: {"comparator": "eq", "expected": "application/json"} - content.success: {"comparator": "eq", "expected": false} \ No newline at end of file + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "headers.content-type", "comparator": "eq", "expected": "application/json"} diff --git a/test/test_response.py b/test/test_response.py index c4d02723c..cbeb01d6f 100644 --- a/test/test_response.py +++ b/test/test_response.py @@ -150,38 +150,39 @@ def test_validate(self): resp = requests.get(url) resp_obj = response.ResponseObject(resp) - validators = { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq", "expected": True} - } + validators = [ + {"check": "resp_status_code", "comparator": "eq", "expected": 201}, + {"check": "resp_body_success", "comparator": "eq", "expected": True} + ] variables_mapping = { "resp_status_code": 200, "resp_body_success": True } - diff_content_dict = resp_obj.validate(validators, variables_mapping) + diff_content_list = resp_obj.validate(validators, variables_mapping) self.assertFalse(resp_obj.success) self.assertEqual( - diff_content_dict, - { - "resp_status_code": { + diff_content_list, + [ + { + "check": "resp_status_code", "comparator": "eq", "expected": 201, "value": 200 } - } + ] ) - validators = { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq", "expected": True} - } + validators = [ + {"check": "resp_status_code", "comparator": "eq", "expected": 201}, + {"check": "resp_body_success", "comparator": "eq", "expected": True} + ] variables_mapping = { "resp_status_code": 201, "resp_body_success": True } - diff_content_dict = resp_obj.validate(validators, variables_mapping) + diff_content_list = resp_obj.validate(validators, variables_mapping) self.assertTrue(resp_obj.success) - self.assertEqual(diff_content_dict, {}) + self.assertEqual(diff_content_list, []) def test_validate_exception(self): url = "http://127.0.0.1:5000/" @@ -189,22 +190,19 @@ def test_validate_exception(self): resp_obj = response.ResponseObject(resp) # expected value missed in validators - validators = { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq"} - } - variables_mapping = { - "resp_status_code": 200, - "resp_body_success": True - } + validators = [ + {"check": "status_code", "comparator": "eq", "expected": 201}, + {"check": "body_success", "comparator": "eq"} + ] + variables_mapping = {} with self.assertRaises(exception.ParamsError): resp_obj.validate(validators, variables_mapping) - # expected value missed in validators - validators = { - "resp_status_code": {"comparator": "eq", "expected": 201}, - "resp_body_success": {"comparator": "eq", "expected": True} - } + # expected value missed in variables mapping + validators = [ + {"check": "resp_status_code", "comparator": "eq", "expected": 201}, + {"check": "body_success", "comparator": "eq"} + ] variables_mapping = { "resp_status_code": 200 } diff --git a/test/test_runner.py b/test/test_runner.py index 43b122a4a..729ec460f 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -46,26 +46,26 @@ def test_run_single_testcase_fail(self): "resp_body_success": "content.success", "resp_headers_contenttype": "headers.content-type" }, - "validators": { - "resp_status_code": {"comparator": "eq", "expected": 200}, - "resp_body_success": {"comparator": "eq", "expected": False}, - "resp_headers_contenttype": {"comparator": "eq", "expected": "html/text"} - } + "validators": [ + {"check": "resp_status_code", "comparator": "eq", "expected": 200}, + {"check": "resp_body_success", "comparator": "eq", "expected": False}, + {"check": "resp_headers_contenttype", "comparator": "eq", "expected": "html/text"} + ] } - success, diff_content = self.test_runner.run_test(testcase) + success, diff_content_list = self.test_runner.run_test(testcase) self.assertFalse(success) self.assertEqual( - diff_content['resp_status_code'], - {"comparator": "eq", "expected": 200, 'value': 201} + diff_content_list[0], + {"check": "resp_status_code", "comparator": "eq", "expected": 200, 'value': 201} ) self.assertEqual( - diff_content['resp_body_success'], - {"comparator": "eq", "expected": False, 'value': True} + diff_content_list[1], + {"check": "resp_body_success", "comparator": "eq", "expected": False, 'value': True} ) self.assertEqual( - diff_content['resp_headers_contenttype'], - {"comparator": "eq", "expected": "html/text", 'value': "application/json"} + diff_content_list[2], + {"check": "resp_headers_contenttype", "comparator": "eq", "expected": "html/text", 'value': "application/json"} ) def test_run_testset_json_success(self): @@ -73,25 +73,25 @@ def test_run_testset_json_success(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, {}), (True, {})]) + self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_json_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, {}), (True, {})]) + self.assertEqual(results[0], [(True, []), (True, [])]) def test_run_testset_yaml_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, {}), (True, {})]) + self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_yaml_success(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, {}), (True, {})]) + self.assertEqual(results[0], [(True, []), (True, [])]) diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index c8a764a5e..d7ae1b637 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -37,7 +37,7 @@ def test_run_testset_auth_yaml(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, {}), (True, {})]) + self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_auth_yaml(self): testcase_file_path = os.path.join( @@ -45,7 +45,7 @@ def test_run_testsets_auth_yaml(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, {}), (True, {})]) + self.assertEqual(results[0], [(True, []), (True, [])]) def test_run_testset_auth_json(self): testcase_file_path = os.path.join( @@ -53,7 +53,7 @@ def test_run_testset_auth_json(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, {}), (True, {})]) + self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_auth_json(self): testcase_file_path = os.path.join( @@ -61,7 +61,7 @@ def test_run_testsets_auth_json(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, {}), (True, {})]) + self.assertEqual(results[0], [(True, []), (True, [])]) def test_run_testcase_template_yaml(self): testcase_file_path = os.path.join( @@ -78,7 +78,7 @@ def test_run_testset_template_yaml(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, {}), (True, {})]) + self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_template_yaml(self): testcase_file_path = os.path.join( @@ -86,4 +86,4 @@ def test_run_testsets_template_yaml(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, {}), (True, {})]) + self.assertEqual(results[0], [(True, []), (True, [])]) From eff16d7232a894a29f8c77757072e2efe3a4b7e1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 29 Jun 2017 19:20:57 +0800 Subject: [PATCH 084/354] we can set public filed in testset.config, and each testcase can inherit from it --- ate/main.py | 2 +- ate/runner.py | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ate/main.py b/ate/main.py index 997b3da14..47626a8f5 100644 --- a/ate/main.py +++ b/ate/main.py @@ -26,7 +26,7 @@ def create_suite(testset): test_runner = runner.TestRunner() config_dict = testset.get("config", {}) - test_runner.update_context(config_dict) + test_runner.update_context(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: diff --git a/ate/runner.py b/ate/runner.py index f11198393..3b6dea70d 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,3 +1,4 @@ +import copy import requests from ate import exception, response @@ -10,10 +11,11 @@ class TestRunner(object): def __init__(self): self.client = requests.Session() self.context = Context() + self.testset_req_overall_configs = {} - def update_context(self, config_dict): + def update_context(self, config_dict, level="testcase"): """ create/update context variables binds - @param config_dict + @param (dict) config_dict { "name": "description content", "requires": ["random", "hashlib"], @@ -30,6 +32,8 @@ def update_context(self, config_dict): {"random": {"func": "gen_random_string", "args": [5]}}, ] } + @param (str) context level, testcase or testset + only when level is testset, shall we update testset_req_overall_configs """ requires = config_dict.get('requires', []) self.context.import_requires(requires) @@ -40,6 +44,9 @@ def update_context(self, config_dict): variable_binds = config_dict.get('variable_binds', []) self.context.bind_variables(variable_binds) + if level == "testset": + self.testset_req_overall_configs = config_dict.get('request', {}) + def run_test(self, testcase): """ run single testcase. @param (dict) testcase @@ -65,8 +72,14 @@ def run_test(self, testcase): (success, diff_content_list) """ self.update_context(testcase) - parsed_request = parse_template(testcase["request"], self.context.variables) + # each testcase shall inherit from testset request configs, + # but can not override testset configs, + # that's why we use copy.deepcopy here. + testcase_request = copy.deepcopy(self.testset_req_overall_configs) + testcase_request.update(testcase["request"]) + + parsed_request = parse_template(testcase_request, self.context.variables) try: url = parsed_request.pop('url') method = parsed_request.pop('method') @@ -94,7 +107,8 @@ def run_testset(self, testset): "name": "testset description", "requires": [], "function_binds": {}, - "variable_binds": [] + "variable_binds": [], + "request": {} }, "testcases": [ { From 80a775e97efa8048b7a27fb7d88d1a6d8bfe8e49 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 30 Jun 2017 12:01:17 +0800 Subject: [PATCH 085/354] api_server: return JSON format if 403 occurred --- test/api_server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/api_server.py b/test/api_server.py index bb2b36907..121af4625 100644 --- a/test/api_server.py +++ b/test/api_server.py @@ -41,7 +41,13 @@ def wrapper(*args, **kwds): assert authorization == req_authorization return func(*args, **kwds) except (KeyError, AssertionError): - return "Authorization failed!", 403 + result = { + 'success': False, + 'msg': "Authorization failed!" + } + response = make_response(json.dumps(result), 403) + response.headers["Content-Type"] = "application/json" + return response return wrapper From 434e82f95e8bd2348d83dea9892bb52baa8a4f18 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 30 Jun 2017 12:31:35 +0800 Subject: [PATCH 086/354] add support for import module custom functions --- ate/context.py | 20 +++++++++++++- ate/runner.py | 3 +++ test/data/__init__.py | 0 test/data/custom_functions.py | 42 +++++++++++++++++++++++++++++ test/data/demo_import_functions.yml | 41 ++++++++++++++++++++++++++++ test/test_runner_v2.py | 16 +++++++++++ testcases/__init__.py | 0 7 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 test/data/__init__.py create mode 100644 test/data/custom_functions.py create mode 100644 test/data/demo_import_functions.yml create mode 100644 testcases/__init__.py diff --git a/ate/context.py b/ate/context.py index 2665754de..aeef98db6 100644 --- a/ate/context.py +++ b/ate/context.py @@ -1,7 +1,17 @@ -import re import importlib +import re +import types + from ate import exception, utils + +def is_function(tup): + """ + Takes (name, object) tuple, returns True if it is a function. + """ + name, item = tup + return isinstance(item, types.FunctionType) + class Context(object): """ Manages binding of variables """ @@ -29,6 +39,14 @@ def bind_functions(self, function_binds): function = eval(function) self.functions[func_name] = function + def import_module_functions(self, modules): + """ import modules and bind all functions within the context + """ + for module_name in modules: + imported = importlib.import_module(module_name) + imported_functions_dict = dict(filter(is_function, vars(imported).items())) + self.functions.update(imported_functions_dict) + def bind_variables(self, variable_binds): """ Bind named variables to value within the context. This allows for passing in variables or functions. diff --git a/ate/runner.py b/ate/runner.py index 3b6dea70d..02814f625 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -41,6 +41,9 @@ def update_context(self, config_dict, level="testcase"): function_binds = config_dict.get('function_binds', {}) self.context.bind_functions(function_binds) + module_functions = config_dict.get('import_module_functions', []) + self.context.import_module_functions(module_functions) + variable_binds = config_dict.get('variable_binds', []) self.context.bind_variables(variable_binds) diff --git a/test/data/__init__.py b/test/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/data/custom_functions.py b/test/data/custom_functions.py new file mode 100644 index 000000000..10e098b94 --- /dev/null +++ b/test/data/custom_functions.py @@ -0,0 +1,42 @@ +import hashlib +import json +import random +import string + +try: + string_type = basestring + PYTHON_VERSION = 2 +except NameError: + string_type = str + PYTHON_VERSION = 3 + + +def gen_random_string(str_len): + return ''.join( + random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) + +def gen_md5(*args): + args = [handle_req_data(item) for item in args] + return hashlib.md5("".join(args).encode('utf-8')).hexdigest() + +def handle_req_data(data): + + if PYTHON_VERSION == 3 and isinstance(data, bytes): + # In Python3, convert bytes to str + data = data.decode('utf-8') + + if not data: + return data + + if isinstance(data, str): + # check if data in str can be converted to dict + try: + data = json.loads(data) + except ValueError: + pass + + if isinstance(data, dict): + # sort data in dict with keys, then convert to str + data = json.dumps(data, sort_keys=True) + + return data diff --git a/test/data/demo_import_functions.yml b/test/data/demo_import_functions.yml new file mode 100644 index 000000000..e48b5f69e --- /dev/null +++ b/test/data/demo_import_functions.yml @@ -0,0 +1,41 @@ +- config: + name: "create user testsets." + import_module_functions: + - test.data.custom_functions + variable_binds: + - TOKEN: debugtalk + - json: {"name": "user", "password": "123456"} + - random: {"func": "gen_random_string", "args": [5]} + - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${json}", "${random}"]} + +- test: + name: create user which does not exist + variable_binds: + - json: {"name": "user", "password": "123456"} + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + json: "${json}" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +- test: + name: create user which does not exist + variable_binds: + - json: {"name": "user", "password": "123456"} + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: "${authorization}" + random: "${random}" + json: "${json}" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index d7ae1b637..8cb901180 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -87,3 +87,19 @@ def test_run_testsets_template_yaml(self): results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results[0], [(True, []), (True, [])]) + + def test_run_testset_template_import_functions(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/demo_import_functions.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testset(testsets[0]) + self.assertEqual(len(results), 2) + self.assertEqual(results, [(True, []), (True, [])]) + + def test_run_testsets_template_import_functions(self): + testcase_file_path = os.path.join( + os.getcwd(), 'test/data/demo_import_functions.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results[0], [(True, []), (True, [])]) diff --git a/testcases/__init__.py b/testcases/__init__.py new file mode 100644 index 000000000..e69de29bb From 038a5d8aeff59cfa0b88e373f718739bb408f38d Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 30 Jun 2017 16:57:56 +0800 Subject: [PATCH 087/354] bugfix: length equal --- ate/utils.py | 2 +- test/test_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index a9685349d..97a3e7a54 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -195,7 +195,7 @@ def match_expected(value, expected, comparator="eq"): elif comparator in ["ne", "not_equals"]: assert value != expected elif comparator in ["len_eq", "length_equal", "count_eq"]: - assert len(value) == len(expected) + assert len(value) == expected elif comparator in ["lt", "less_than"]: assert value < expected elif comparator in ["le", "less_than_or_equals"]: diff --git a/test/test_utils.py b/test/test_utils.py index a607ce46d..8e4cbe286 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -184,7 +184,7 @@ def test_compare(self): self.assertFalse(utils.match_expected(123, "123", "eq")) self.assertFalse(utils.match_expected(123, "123")) - self.assertTrue(utils.match_expected("123", "345", "len_eq")) + self.assertTrue(utils.match_expected("123", 3, "len_eq")) self.assertTrue(utils.match_expected(123, "123", "str_eq")) self.assertTrue(utils.match_expected(123, "123", "ne")) From ae151c3b09ea1f77b254b10ed730691ded952b87 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 30 Jun 2017 17:04:20 +0800 Subject: [PATCH 088/354] match_expected comparator: add len_gt, len_lt, len_ge, len_le, etc. --- ate/utils.py | 10 ++++++++++ test/test_utils.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 97a3e7a54..bdc921b51 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -196,6 +196,16 @@ def match_expected(value, expected, comparator="eq"): assert value != expected elif comparator in ["len_eq", "length_equal", "count_eq"]: assert len(value) == expected + elif comparator in ["len_gt", "count_gt", "length_greater_than", "count_greater_than"]: + assert len(value) > expected + elif comparator in ["len_ge", "count_ge", "length_greater_than_or_equals", \ + "count_greater_than_or_equals"]: + assert len(value) >= expected + elif comparator in ["len_lt", "count_lt", "length_less_than", "count_less_than"]: + assert len(value) < expected + elif comparator in ["len_le", "count_le", "length_less_than_or_equals", \ + "count_less_than_or_equals"]: + assert len(value) <= expected elif comparator in ["lt", "less_than"]: assert value < expected elif comparator in ["le", "less_than_or_equals"]: diff --git a/test/test_utils.py b/test/test_utils.py index 8e4cbe286..36e930973 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -188,6 +188,11 @@ def test_compare(self): self.assertTrue(utils.match_expected(123, "123", "str_eq")) self.assertTrue(utils.match_expected(123, "123", "ne")) + self.assertTrue(utils.match_expected("123", 2, "len_gt")) + self.assertTrue(utils.match_expected("123", 3, "len_ge")) + self.assertTrue(utils.match_expected("123", 4, "len_lt")) + self.assertTrue(utils.match_expected("123", 3, "len_le")) + self.assertTrue(utils.match_expected(1, 2, "lt")) self.assertTrue(utils.match_expected(1, 1, "le")) self.assertTrue(utils.match_expected(2, 1, "gt")) From 1dbd86efebe0281b509e7b7c07e61409f2184ec6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 30 Jun 2017 19:56:19 +0800 Subject: [PATCH 089/354] add HttpNtlmAuth --- ate/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ate/runner.py b/ate/runner.py index 02814f625..730fa04ee 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -89,6 +89,12 @@ def run_test(self, testcase): except KeyError: raise exception.ParamsError("URL or METHOD missed!") + if "HttpNtlmAuth" in parsed_request: + from requests_ntlm import HttpNtlmAuth + auth_account = parsed_request.pop("HttpNtlmAuth") + parsed_request["auth"] = HttpNtlmAuth( + auth_account["username"], auth_account["password"]) + resp = self.client.request(url=url, method=method, **parsed_request) resp_obj = response.ResponseObject(resp) From 5dc5ca950d49a624ef4c098458a8186ed7003e50 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 1 Jul 2017 21:40:46 +0800 Subject: [PATCH 090/354] refactor context: 1, testset and testcase are in different context level; 2, testset context will be initialized when a file is loaded, and testcase level context initializes when each testcase starts; 3, testcase context should inherit from testset context configs, and the testcase context has high priority. --- ate/context.py | 129 +++++++++++++++++++++++++------ ate/main.py | 2 +- ate/runner.py | 45 +++++------ test/data/demo_binds.yml | 14 +--- test/data/demo_template_sets.yml | 2 +- test/test_context.py | 81 ++++++++----------- 6 files changed, 160 insertions(+), 113 deletions(-) diff --git a/ate/context.py b/ate/context.py index aeef98db6..977af7a04 100644 --- a/ate/context.py +++ b/ate/context.py @@ -1,23 +1,45 @@ +import copy import importlib import re import types +from collections import OrderedDict -from ate import exception, utils +from ate import exception, testcase, utils def is_function(tup): - """ - Takes (name, object) tuple, returns True if it is a function. + """ Takes (name, object) tuple, returns True if it is a function. """ name, item = tup return isinstance(item, types.FunctionType) class Context(object): - """ Manages binding of variables + """ Manages context functions and variables. + context has two levels, testset and testcase. """ def __init__(self): - self.functions = dict() - self.variables = dict() # Maps variable name to value + self.testset_config = {} + self.testset_shared_variables_mapping = dict() + + self.testcase_config = {} + self.testcase_variables_mapping = dict() + self.init_context() + + def init_context(self, level='testset'): + """ + testset level context initializes when a file is loaded, + testcase level context initializes when each testcase starts. + """ + if level == "testset": + self.testset_config["functions"] = {} + self.testset_config["variables"] = OrderedDict() + self.testset_config["request"] = {} + self.testset_shared_variables_mapping = {} + + self.testcase_config["functions"] = {} + self.testcase_config["variables"] = OrderedDict() + self.testcase_config["request"] = {} + self.testcase_variables_mapping = copy.deepcopy(self.testset_shared_variables_mapping) def import_requires(self, modules): """ import required modules dynamicly @@ -25,54 +47,111 @@ def import_requires(self, modules): for module_name in modules: globals()[module_name] = importlib.import_module(module_name) - def bind_functions(self, function_binds): + def bind_functions(self, function_binds, level="testcase"): """ Bind named functions within the context This allows for passing in self-defined functions in testing. e.g. function_binds: { - "add_one": lambda x: x + 1, - "add_two_nums": "lambda x, y: x + y" + "add_one": lambda x: x + 1, # lambda function + "add_two_nums": "lambda x, y: x + y" # lambda function in string } """ + eval_function_binds = {} for func_name, function in function_binds.items(): if isinstance(function, str): function = eval(function) - self.functions[func_name] = function + eval_function_binds[func_name] = function - def import_module_functions(self, modules): + self.__update_context_config(level, "functions", eval_function_binds) + + def import_module_functions(self, modules, level="testcase"): """ import modules and bind all functions within the context """ for module_name in modules: imported = importlib.import_module(module_name) imported_functions_dict = dict(filter(is_function, vars(imported).items())) - self.functions.update(imported_functions_dict) + self.__update_context_config(level, "functions", imported_functions_dict) - def bind_variables(self, variable_binds): - """ Bind named variables to value within the context. - This allows for passing in variables or functions. - e.g. variable_binds: + def register_variables_config(self, variable_binds, level="testcase"): + """ register variable configs + @param (list) variable_binds, variable can be value or custom function + e.g. [ {"TOKEN": "debugtalk"}, {"random": {"func": "gen_random_string", "args": [5]}}, {"json": {'name': 'user', 'password': '123456'}}, - {"md5": {"func": "gen_md5", "args": ["$TOKEN", "$json", "$random"]}} + {"md5": {"func": "gen_md5", "args": ["${TOKEN}", "${json}", "${random}"]}} ] """ - for variable_bind_map in variable_binds: - for var_name, var_value in variable_bind_map.items(): - self.variables[var_name] = self.get_eval_value(var_value) + if level == "testset": + for variable_bind in variable_binds: + self.testset_config["variables"].update(variable_bind) + elif level == "testcase": + self.testcase_config["variables"] = copy.deepcopy(self.testset_config["variables"]) + for variable_bind in variable_binds: + self.testcase_config["variables"].update(variable_bind) + + def register_request(self, request_dict, level="testcase"): + self.__update_context_config(level, "request", request_dict) + + def __update_context_config(self, level, config_type, config_mapping): + """ + @param level: testset or testcase + @param config_type: functions, variables or request + @param config_mapping: functions config mapping or variables config mapping + """ + if level == "testset": + self.testset_config[config_type].update(config_mapping) + elif level == "testcase": + self.testcase_config[config_type].update(config_mapping) - def update_variables(self, variables_mapping): - """ update context variables binds with new variables mapping + def get_parsed_request(self): + """ get parsed request, with each variable replaced by bind value. + testcase request shall inherit from testset request configs, + but can not change testset configs, that's why we use copy.deepcopy here. """ - self.variables.update(variables_mapping) + testcase_request_config = copy.deepcopy(self.testset_config["request"]) + testcase_request_config.update(self.testcase_config["request"]) + + parsed_request = testcase.parse_template( + testcase_request_config, + self._get_evaluated_testcase_variables() + ) + + return parsed_request + + def bind_extracted_variables(self, variables_mapping): + """ bind extracted variable to current testcase context and testset context. + since extracted variable maybe used in current testcase and next testcases. + """ + self.testset_shared_variables_mapping.update(variables_mapping) + self.testcase_variables_mapping.update(variables_mapping) + + def get_testcase_variables_mapping(self): + return self.testcase_variables_mapping + + def _get_evaluated_testcase_variables(self): + """ variables in variables_config will be evaluated each time + """ + testcase_functions_config = copy.deepcopy(self.testset_config["functions"]) + testcase_functions_config.update(self.testcase_config["functions"]) + self.testcase_config["functions"] = testcase_functions_config + + testcase_variables_config = copy.deepcopy(self.testset_config["variables"]) + testcase_variables_config.update(self.testcase_config["variables"]) + self.testcase_config["variables"] = testcase_variables_config + + for var_name, var_value in self.testcase_config["variables"].items(): + self.testcase_variables_mapping[var_name] = self.get_eval_value(var_value) + + return self.testcase_variables_mapping def get_eval_value(self, data): """ evaluate data recursively, each variable in data will be evaluated. variables marker: ${variable}. """ if isinstance(data, str): - return utils.parse_content_with_variables(data, self.variables) + return utils.parse_content_with_variables(data, self.testcase_variables_mapping) if isinstance(data, list): return [self.get_eval_value(item) for item in data] @@ -85,7 +164,7 @@ def get_eval_value(self, data): func_name = data['func'] args = self.get_eval_value(data.get('args', [])) kargs = self.get_eval_value(data.get('kargs', {})) - return self.functions[func_name](*args, **kargs) + return self.testcase_config["functions"][func_name](*args, **kargs) else: evaluated_data = {} for key, value in data.items(): diff --git a/ate/main.py b/ate/main.py index 47626a8f5..c2f1a1230 100644 --- a/ate/main.py +++ b/ate/main.py @@ -26,7 +26,7 @@ def create_suite(testset): test_runner = runner.TestRunner() config_dict = testset.get("config", {}) - test_runner.update_context(config_dict, level="testset") + test_runner.init_context(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: diff --git a/ate/runner.py b/ate/runner.py index 730fa04ee..08d6a003d 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,9 +1,7 @@ -import copy import requests from ate import exception, response from ate.context import Context -from ate.testcase import parse_template class TestRunner(object): @@ -11,9 +9,8 @@ class TestRunner(object): def __init__(self): self.client = requests.Session() self.context = Context() - self.testset_req_overall_configs = {} - def update_context(self, config_dict, level="testcase"): + def init_context(self, config_dict, level): """ create/update context variables binds @param (dict) config_dict { @@ -27,28 +24,28 @@ def update_context(self, config_dict, level="testcase"): "lambda *str_args: hashlib.md5(''.join(str_args).\ encode('utf-8')).hexdigest()" }, + "import_module_functions": ["test.data.custom_functions"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": {"func": "gen_random_string", "args": [5]}}, ] } @param (str) context level, testcase or testset - only when level is testset, shall we update testset_req_overall_configs """ requires = config_dict.get('requires', []) self.context.import_requires(requires) function_binds = config_dict.get('function_binds', {}) - self.context.bind_functions(function_binds) + self.context.bind_functions(function_binds, level) module_functions = config_dict.get('import_module_functions', []) - self.context.import_module_functions(module_functions) + self.context.import_module_functions(module_functions, level) variable_binds = config_dict.get('variable_binds', []) - self.context.bind_variables(variable_binds) + self.context.register_variables_config(variable_binds, level) - if level == "testset": - self.testset_req_overall_configs = config_dict.get('request', {}) + request_config = config_dict.get('request', {}) + self.context.register_request(request_config, level) def run_test(self, testcase): """ run single testcase. @@ -68,21 +65,14 @@ def run_test(self, testcase): }, "body": '{"name": "user", "password": "123456"}' }, - "extract_binds": {}, - "validators": [] + "extract_binds": {}, # optional + "validators": [] # optional } @return (tuple) test result of single testcase (success, diff_content_list) """ - self.update_context(testcase) - - # each testcase shall inherit from testset request configs, - # but can not override testset configs, - # that's why we use copy.deepcopy here. - testcase_request = copy.deepcopy(self.testset_req_overall_configs) - testcase_request.update(testcase["request"]) - - parsed_request = parse_template(testcase_request, self.context.variables) + self.init_context(testcase, level="testcase") + parsed_request = self.context.get_parsed_request() try: url = parsed_request.pop('url') method = parsed_request.pop('method') @@ -100,10 +90,11 @@ def run_test(self, testcase): extract_binds = testcase.get("extract_binds", {}) extracted_variables_mapping = resp_obj.extract_response(extract_binds) - self.context.update_variables(extracted_variables_mapping) + self.context.bind_extracted_variables(extracted_variables_mapping) validators = testcase.get("validators", []) - diff_content_list = resp_obj.validate(validators, self.context.variables) + diff_content_list = resp_obj.validate( + validators, self.context.get_testcase_variables_mapping()) return resp_obj.success, diff_content_list @@ -122,10 +113,10 @@ def run_testset(self, testset): "testcases": [ { "name": "testcase description", - "variable_binds": {}, # override + "variable_binds": {}, # optional, override "request": {}, - "extract_binds": {}, - "validators": {} + "extract_binds": {}, # optional + "validators": {} # optional }, testcase12 ] @@ -139,7 +130,7 @@ def run_testset(self, testset): results = [] config_dict = testset.get("config", {}) - self.update_context(config_dict) + self.init_context(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: result = self.run_test(testcase) diff --git a/test/data/demo_binds.yml b/test/data/demo_binds.yml index 0ac489423..d11d76210 100644 --- a/test/data/demo_binds.yml +++ b/test/data/demo_binds.yml @@ -1,21 +1,15 @@ -- +register_variables: variable_binds: - TOKEN: "debugtalk" - -- - variable_binds: - var: [1, 2, 3] - -- - variable_binds: - data: {'name': 'user', 'password': '123456'} -- +register_template_variables: variable_binds: - TOKEN: "debugtalk" - token: ${TOKEN} -- +bind_lambda_functions: function_binds: add_one: "lambda x: x + 1" add_two_nums: "lambda x, y: x + y" @@ -23,7 +17,7 @@ - add1: {"func": "add_one", "args": [2]} - sum2nums: {"func": "add_two_nums", "args": [2, 3]} -- +bind_lambda_functions_with_import: requires: - random - string diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 1036e7bdb..92f8427de 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -9,8 +9,8 @@ gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" variable_binds: - TOKEN: debugtalk + - data: "" - random: {"func": "gen_random_string", "args": [5]} - - data: '{"name": "user", "password": "123456"}' - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} - test: diff --git a/test/test_context.py b/test/test_context.py index fe6cbf407..d27d648d4 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -12,79 +12,53 @@ def setUp(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_binds.yml') self.testcases = utils.load_testcases(testcase_file_path) - def test_context_variable_string(self): + def test_context_register_variables(self): # testcase in JSON format testcase1 = { "variable_binds": [ - {"TOKEN": "debugtalk"} + {"TOKEN": "debugtalk"}, + {"var": [1, 2, 3]}, + {"data": {'name': 'user', 'password': '123456'}} ] } # testcase in YAML format - testcase2 = self.testcases[0] + testcase2 = self.testcases["register_variables"] for testcase in [testcase1, testcase2]: variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + self.context.register_variables_config(variable_binds) - context_variables = self.context.variables + context_variables = self.context._get_evaluated_testcase_variables() self.assertIn("TOKEN", context_variables) self.assertEqual(context_variables["TOKEN"], "debugtalk") - - def test_context_variable_list(self): - testcase1 = { - "variable_binds": [ - {"var": [1, 2, 3]} - ] - } - testcase2 = self.testcases[1] - - for testcase in [testcase1, testcase2]: - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) - - context_variables = self.context.variables self.assertIn("var", context_variables) self.assertEqual(context_variables["var"], [1, 2, 3]) - - def test_context_variable_json(self): - testcase1 = { - "variable_binds": [ - {"data": {'name': 'user', 'password': '123456'}} - ] - } - testcase2 = self.testcases[2] - - for testcase in [testcase1, testcase2]: - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) - - context_variables = self.context.variables self.assertIn("data", context_variables) self.assertEqual( context_variables["data"], {'name': 'user', 'password': '123456'} ) - def test_context_variable_variable(self): + def test_context_register_template_variables(self): testcase1 = { "variable_binds": [ {"GLOBAL_TOKEN": "debugtalk"}, {"token": "${GLOBAL_TOKEN}"} ] } - testcase2 = self.testcases[3] + testcase2 = self.testcases["register_template_variables"] for testcase in [testcase1, testcase2]: variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + self.context.register_variables_config(variable_binds) - context_variables = self.context.variables + context_variables = self.context._get_evaluated_testcase_variables() self.assertIn("GLOBAL_TOKEN", context_variables) self.assertEqual(context_variables["GLOBAL_TOKEN"], "debugtalk") self.assertIn("token", context_variables) self.assertEqual(context_variables["token"], "debugtalk") - def test_context_variable_function_lambda(self): + def test_context_bind_lambda_functions(self): testcase1 = { "function_binds": { "add_one": lambda x: x + 1, @@ -95,22 +69,22 @@ def test_context_variable_function_lambda(self): {"sum2nums": {"func": "add_two_nums", "args": [2, 3]}} ] } - testcase2 = self.testcases[4] + testcase2 = self.testcases["bind_lambda_functions"] for testcase in [testcase1, testcase2]: function_binds = testcase.get('function_binds', {}) self.context.bind_functions(function_binds) variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + self.context.register_variables_config(variable_binds) - context_variables = self.context.variables + context_variables = self.context._get_evaluated_testcase_variables() self.assertIn("add1", context_variables) self.assertEqual(context_variables["add1"], 3) self.assertIn("sum2nums", context_variables) self.assertEqual(context_variables["sum2nums"], 5) - def test_context_variable_function_lambda_with_import(self): + def test_context_bind_lambda_functions_with_import(self): testcase1 = { "requires": ["random", "string", "hashlib"], "function_binds": { @@ -120,11 +94,11 @@ def test_context_variable_function_lambda_with_import(self): "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": {"func": "gen_random_string", "args": [5]}}, - {"data": "{'name': 'user', 'password': '123456'}"}, - {"md5": {"func": "gen_md5", "args": ["$TOKEN", "$data", "$random"]}} + {"data": '{"name": "user", "password": "123456"}'}, + {"authorization": {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]}} ] } - testcase2 = self.testcases[5] + testcase2 = self.testcases["bind_lambda_functions_with_import"] for testcase in [testcase1, testcase2]: requires = testcase.get('requires', []) @@ -134,11 +108,20 @@ def test_context_variable_function_lambda_with_import(self): self.context.bind_functions(function_binds) variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + self.context.register_variables_config(variable_binds) + context_variables = self.context._get_evaluated_testcase_variables() - context_variables = self.context.variables + self.assertIn("TOKEN", context_variables) + TOKEN = context_variables["TOKEN"] + self.assertEqual(TOKEN, "debugtalk") self.assertIn("random", context_variables) self.assertIsInstance(context_variables["random"], str) self.assertEqual(len(context_variables["random"]), 5) - self.assertIn("md5", context_variables) - self.assertEqual(len(context_variables["md5"]), 32) + random = context_variables["random"] + self.assertIn("data", context_variables) + data = context_variables["data"] + self.assertIn("authorization", context_variables) + self.assertEqual(len(context_variables["authorization"]), 32) + authorization = context_variables["authorization"] + self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) + From d9ca96aba613c4c82115fdb8e6ce5d2ae2f139de Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 1 Jul 2017 21:54:14 +0800 Subject: [PATCH 091/354] add test for import_module_functions --- test/data/demo_binds.yml | 10 ++++++++++ test/test_context.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/test/data/demo_binds.yml b/test/data/demo_binds.yml index d11d76210..20d497b1d 100644 --- a/test/data/demo_binds.yml +++ b/test/data/demo_binds.yml @@ -30,3 +30,13 @@ bind_lambda_functions_with_import: - random: {"func": "gen_random_string", "args": [5]} - data: "{'name': 'user', 'password': '123456'}" - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + +bind_module_functions: + function_binds: + import_module_functions: + - test.data.custom_functions + variable_binds: + - TOKEN: debugtalk + - random: {"func": "gen_random_string", "args": [5]} + - data: "{'name': 'user', 'password': '123456'}" + - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} diff --git a/test/test_context.py b/test/test_context.py index d27d648d4..94dbe46c0 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -125,3 +125,36 @@ def test_context_bind_lambda_functions_with_import(self): authorization = context_variables["authorization"] self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) + def test_import_module_functions(self): + testcase1 = { + "import_module_functions": ["test.data.custom_functions"], + "variable_binds": [ + {"TOKEN": "debugtalk"}, + {"random": {"func": "gen_random_string", "args": [5]}}, + {"data": '{"name": "user", "password": "123456"}'}, + {"authorization": {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]}} + ] + } + testcase2 = self.testcases["bind_module_functions"] + + for testcase in [testcase1, testcase2]: + module_functions = testcase.get('import_module_functions', []) + self.context.import_module_functions(module_functions) + + variable_binds = testcase['variable_binds'] + self.context.register_variables_config(variable_binds) + context_variables = self.context._get_evaluated_testcase_variables() + + self.assertIn("TOKEN", context_variables) + TOKEN = context_variables["TOKEN"] + self.assertEqual(TOKEN, "debugtalk") + self.assertIn("random", context_variables) + self.assertIsInstance(context_variables["random"], str) + self.assertEqual(len(context_variables["random"]), 5) + random = context_variables["random"] + self.assertIn("data", context_variables) + data = context_variables["data"] + self.assertIn("authorization", context_variables) + self.assertEqual(len(context_variables["authorization"]), 32) + authorization = context_variables["authorization"] + self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) From 7ce4603944aea8571e5e0b4290d41967ef005049 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 1 Jul 2017 22:08:00 +0800 Subject: [PATCH 092/354] add test for get_parsed_request --- test/test_context.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/test_context.py b/test/test_context.py index 94dbe46c0..636d6bad9 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -1,7 +1,7 @@ import os import unittest -from ate import utils +from ate import utils, runner from ate.context import Context @@ -158,3 +158,33 @@ def test_import_module_functions(self): self.assertEqual(len(context_variables["authorization"]), 32) authorization = context_variables["authorization"] self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) + + def test_get_parsed_request(self): + test_runner = runner.TestRunner() + testcase = { + "import_module_functions": ["test.data.custom_functions"], + "variable_binds": [ + {"TOKEN": "debugtalk"}, + {"random": {"func": "gen_random_string", "args": [5]}}, + {"data": '{"name": "user", "password": "123456"}'}, + {"authorization": {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]}} + ], + "request": { + "url": "http://127.0.0.1:5000/api/users/1000", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "${authorization}", + "random": "${random}" + }, + "data": "${data}" + } + } + test_runner.init_context(testcase, level="testcase") + parsed_request = test_runner.context.get_parsed_request() + self.assertIn("authorization", parsed_request["headers"]) + self.assertEqual(len(parsed_request["headers"]["authorization"]), 32) + self.assertIn("random", parsed_request["headers"]) + self.assertEqual(len(parsed_request["headers"]["random"]), 5) + self.assertIn("data", parsed_request) + self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) From 562a0281299a09f4542f554a9a1a39a9ddabb95e Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 1 Jul 2017 22:31:49 +0800 Subject: [PATCH 093/354] add test for get_eval_value --- test/test_context.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/test_context.py b/test/test_context.py index 636d6bad9..09ba71154 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -188,3 +188,27 @@ def test_get_parsed_request(self): self.assertEqual(len(parsed_request["headers"]["random"]), 5) self.assertIn("data", parsed_request) self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) + + def test_get_eval_value(self): + self.context.testcase_variables_mapping = { + "str_1": "str_value1", + "str_2": "str_value2" + } + self.assertEqual(self.context.get_eval_value("${str_1}"), "str_value1") + self.assertEqual(self.context.get_eval_value("${str_2}"), "str_value2") + self.assertEqual( + self.context.get_eval_value(["${str_1}", "str3"]), + ["str_value1", "str3"] + ) + self.assertEqual( + self.context.get_eval_value({"key": "${str_1}"}), + {"key": "str_value1"} + ) + + import random, string + self.context.testcase_config["functions"]["gen_random_string"] = \ + lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ + for _ in range(str_len)) + result = self.context.get_eval_value( + {"func": "gen_random_string", "args": [5]}) + self.assertEqual(len(result), 5) From 9586915322e97f6a207355b67752dcf2d75e5985 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 12:07:40 +0800 Subject: [PATCH 094/354] create HttpSession as wrapper of requests.Session, in order to log more information of request and response --- ate/client.py | 149 ++++++++++++++++++++++++++++++++++++++++++++ ate/context.py | 3 +- ate/runner.py | 5 +- test/test_client.py | 34 ++++++++++ 4 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 ate/client.py create mode 100644 test/test_client.py diff --git a/ate/client.py b/ate/client.py new file mode 100644 index 000000000..8004e57c8 --- /dev/null +++ b/ate/client.py @@ -0,0 +1,149 @@ +import logging +import re +import time + +import requests +from requests import Request, Response +from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, + RequestException) + +from ate.exception import ParamsError + +log_level = getattr(logging, "INFO") +logging.basicConfig(level=log_level) +absolute_http_url_regexp = re.compile(r"^https?://", re.I) + + +class ApiResponse(Response): + + def raise_for_status(self): + if hasattr(self, 'error') and self.error: + raise self.error + Response.raise_for_status(self) + + +class HttpSession(requests.Session): + """ + Class for performing HTTP requests and holding (session-) cookies between requests (in order + to be able to log in and out of websites). Each request is logged so that ApiTestEngine can + display statistics. + + This is a slightly extended version of `python-request `_'s + :py:class:`requests.Session` class and mostly this class works exactly the same. However + the methods for making requests (get, post, delete, put, head, options, patch, request) + can now take a *url* argument that's only the path part of the URL, in which case the host + part of the URL will be prepended with the HttpSession.base_url which is normally inherited + from a ApiTestEngine class' host property. + """ + def __init__(self, base_url=None, *args, **kwargs): + super(HttpSession, self).__init__(*args, **kwargs) + self.base_url = base_url if base_url else "" + + def _build_url(self, path): + """ prepend url with hostname unless it's already an absolute URL """ + if absolute_http_url_regexp.match(path): + return path + elif self.base_url: + return "%s%s" % (self.base_url, path) + else: + raise ParamsError("base url missed!") + + def request(self, method, url, **kwargs): + """ + Constructs and sends a :py:class:`requests.Request`. + Returns :py:class:`requests.Response` object. + + :param method: + method for the new :class:`Request` object. + :param url: + URL for the new :class:`Request` object. + :param params: (optional) + Dictionary or bytes to be sent in the query string for the :class:`Request`. + :param data: (optional) + Dictionary or bytes to send in the body of the :class:`Request`. + :param headers: (optional) + Dictionary of HTTP Headers to send with the :class:`Request`. + :param cookies: (optional) + Dict or CookieJar object to send with the :class:`Request`. + :param files: (optional) + Dictionary of ``'filename': file-like-objects`` for multipart encoding upload. + :param auth: (optional) + Auth tuple or callable to enable Basic/Digest/Custom HTTP Auth. + :param timeout: (optional) + How long to wait for the server to send data before giving up, as a float, or \ + a (`connect timeout, read timeout `_) tuple. + :type timeout: float or tuple + :param allow_redirects: (optional) + Set to True by default. + :type allow_redirects: bool + :param proxies: (optional) + Dictionary mapping protocol to the URL of the proxy. + :param stream: (optional) + whether to immediately download the response content. Defaults to ``False``. + :param verify: (optional) + if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided. + :param cert: (optional) + if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair. + """ + + # prepend url with hostname unless it's already an absolute URL + url = self._build_url(url) + logging.info(" Start to {method} {url}".format(method=method, url=url)) + logging.debug(" kwargs: {kwargs}".format(kwargs=kwargs)) + # store meta data that is used when reporting the request to locust's statistics + 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) + request_meta["url"] = (response.history and response.history[0] or response)\ + .request.path_url + + # record the consumed time + request_meta["response_time"] = int((time.time() - request_meta["start_time"]) * 1000) + + # get the length of the content, but if the argument stream is set to True, we take + # the size from the content-length header, in order to not trigger fetching of the body + if kwargs.get("stream", False): + request_meta["content_size"] = int(response.headers.get("content-length") or 0) + else: + request_meta["content_size"] = len(response.content or "") + + request_meta["request_headers"] = response.request.headers + request_meta["request_body"] = response.request.body + request_meta["status_code"] = response.status_code + request_meta["response_headers"] = response.headers + request_meta["response_content"] = response.content + + logging.debug(" response: {response}".format(response=request_meta)) + + try: + response.raise_for_status() + except RequestException as e: + logging.error(" Failed to {method} {url}! exception msg: {exception}".format( + method=method, url=url, exception=str(e))) + else: + logging.info( + """ status_code: {}! response_time: {} ms, response_length: {} bytes"""\ + .format(request_meta["status_code"], request_meta["response_time"], \ + request_meta["content_size"])) + + return response + + def _send_request_safe_mode(self, method, url, **kwargs): + """ + Send a HTTP request, and catch any exception that might occur due to connection problems. + Safe mode has been removed from requests 1.x. + """ + try: + return requests.Session.request(self, method, url, **kwargs) + except (MissingSchema, InvalidSchema, InvalidURL): + raise + except RequestException as ex: + resp = ApiResponse() + resp.error = ex + resp.status_code = 0 # with this status_code, content returns None + resp.request = Request(method, url).prepare() + return resp diff --git a/ate/context.py b/ate/context.py index 977af7a04..042ad094e 100644 --- a/ate/context.py +++ b/ate/context.py @@ -160,7 +160,8 @@ def get_eval_value(self, data): if "func" in data: # this is a function, e.g. {"func": "gen_random_string", "args": [5]} # function marker: "func" key in dict - # the function will be called, and its return value will be binded to the variable. + # the function will be called, and its return value will be binded + # to the testcase context variable. func_name = data['func'] args = self.get_eval_value(data.get('args', [])) kargs = self.get_eval_value(data.get('kargs', {})) diff --git a/ate/runner.py b/ate/runner.py index 08d6a003d..9275c7d39 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,13 +1,12 @@ -import requests - from ate import exception, response +from ate.client import HttpSession from ate.context import Context class TestRunner(object): def __init__(self): - self.client = requests.Session() + self.client = HttpSession() self.context = Context() def init_context(self, config_dict, level): diff --git a/test/test_client.py b/test/test_client.py new file mode 100644 index 000000000..777351abb --- /dev/null +++ b/test/test_client.py @@ -0,0 +1,34 @@ +from ate.client import HttpSession +from test.base import ApiServerUnittest + +class TestHttpClient(ApiServerUnittest): + def setUp(self): + super(TestHttpClient, self).setUp() + self.host = "http://127.0.0.1:5000" + self.api_client = HttpSession(self.host) + self.clear_users() + + def tearDown(self): + super(TestHttpClient, self).tearDown() + + def clear_users(self): + url = "%s/api/users" % self.host + return self.api_client.delete(url) + + def create_user(self, uid, name, password): + url = "%s/api/users/%d" % (self.host, uid) + data = { + 'name': name, + 'password': password + } + return self.api_client.post(url, json=data) + + def test_create_user_not_existed(self): + resp = self.create_user(1000, 'user1', '123456') + self.assertEqual(201, resp.status_code) + self.assertEqual(True, resp.json()['success']) + + def test_create_user_existed(self): + resp = self.create_user(1000, 'user1', '123456') + resp = self.create_user(1000, 'user1', '123456') + self.assertEqual(500, resp.status_code) From 7fd0f715caa4138a6c755be6e377a5b2bea16ecf Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 12:13:56 +0800 Subject: [PATCH 095/354] move HttpNtlmAuth from runner to client --- ate/client.py | 6 ++++++ ate/runner.py | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ate/client.py b/ate/client.py index 8004e57c8..986b445b6 100644 --- a/ate/client.py +++ b/ate/client.py @@ -97,6 +97,12 @@ def request(self, method, url, **kwargs): request_meta["method"] = method request_meta["start_time"] = time.time() + if "HttpNtlmAuth" in kwargs: + from requests_ntlm import HttpNtlmAuth + auth_account = kwargs.pop("HttpNtlmAuth") + kwargs["auth"] = HttpNtlmAuth( + auth_account["username"], auth_account["password"]) + response = self._send_request_safe_mode(method, url, **kwargs) request_meta["url"] = (response.history and response.history[0] or response)\ .request.path_url diff --git a/ate/runner.py b/ate/runner.py index 9275c7d39..ac1ca0ef3 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -78,12 +78,6 @@ def run_test(self, testcase): except KeyError: raise exception.ParamsError("URL or METHOD missed!") - if "HttpNtlmAuth" in parsed_request: - from requests_ntlm import HttpNtlmAuth - auth_account = parsed_request.pop("HttpNtlmAuth") - parsed_request["auth"] = HttpNtlmAuth( - auth_account["username"], auth_account["password"]) - resp = self.client.request(url=url, method=method, **parsed_request) resp_obj = response.ResponseObject(resp) From 3c60bbfac3ea33fe1f8824d5e428809a769be5b6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 12:45:24 +0800 Subject: [PATCH 096/354] HttpSession can be called with full url or with only path --- ate/runner.py | 4 ++-- test/test_client.py | 26 ++++++++++++++------------ test/test_runner.py | 5 +++-- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index ac1ca0ef3..0edda4372 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -5,8 +5,8 @@ class TestRunner(object): - def __init__(self): - self.client = HttpSession() + def __init__(self, base_url=None): + self.client = HttpSession(base_url) self.context = Context() def init_context(self, config_dict, level): diff --git a/test/test_client.py b/test/test_client.py index 777351abb..f5fa6b5ab 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -15,20 +15,22 @@ def clear_users(self): url = "%s/api/users" % self.host return self.api_client.delete(url) - def create_user(self, uid, name, password): - url = "%s/api/users/%d" % (self.host, uid) + def test_request_with_full_url(self): + url = "%s/api/users/1000" % self.host data = { - 'name': name, - 'password': password + 'name': 'user1', + 'password': '123456' } - return self.api_client.post(url, json=data) - - def test_create_user_not_existed(self): - resp = self.create_user(1000, 'user1', '123456') + resp = self.api_client.post(url, json=data) self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()['success']) - def test_create_user_existed(self): - resp = self.create_user(1000, 'user1', '123456') - resp = self.create_user(1000, 'user1', '123456') - self.assertEqual(500, resp.status_code) + def test_request_without_base_url(self): + url = "/api/users/1000" + data = { + 'name': 'user1', + 'password': '123456' + } + resp = self.api_client.post(url, json=data) + self.assertEqual(201, resp.status_code) + self.assertEqual(True, resp.json()['success']) diff --git a/test/test_runner.py b/test/test_runner.py index 729ec460f..ec008bb13 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -6,7 +6,8 @@ class TestRunner(ApiServerUnittest): def setUp(self): - self.test_runner = runner.TestRunner() + base_url = "http://127.0.0.1:5000" + self.test_runner = runner.TestRunner(base_url) self.clear_users() def clear_users(self): @@ -31,7 +32,7 @@ def test_run_single_testcase_fail(self): testcase = { "name": "create user which does not exist", "request": { - "url": "http://127.0.0.1:5000/api/users/1000", + "url": "/api/users/1000", "method": "POST", "headers": { "content-type": "application/json" From e57e5d547cf144f006511bfaa5cff83ca837fce0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 16:00:43 +0800 Subject: [PATCH 097/354] change method name --- ate/main.py | 2 +- ate/runner.py | 6 +++--- test/test_context.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ate/main.py b/ate/main.py index c2f1a1230..5a00af43c 100644 --- a/ate/main.py +++ b/ate/main.py @@ -26,7 +26,7 @@ def create_suite(testset): test_runner = runner.TestRunner() config_dict = testset.get("config", {}) - test_runner.init_context(config_dict, level="testset") + test_runner.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: diff --git a/ate/runner.py b/ate/runner.py index 0edda4372..d184b13b4 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -9,7 +9,7 @@ def __init__(self, base_url=None): self.client = HttpSession(base_url) self.context = Context() - def init_context(self, config_dict, level): + def init_config(self, config_dict, level): """ create/update context variables binds @param (dict) config_dict { @@ -70,7 +70,7 @@ def run_test(self, testcase): @return (tuple) test result of single testcase (success, diff_content_list) """ - self.init_context(testcase, level="testcase") + self.init_config(testcase, level="testcase") parsed_request = self.context.get_parsed_request() try: url = parsed_request.pop('url') @@ -123,7 +123,7 @@ def run_testset(self, testset): results = [] config_dict = testset.get("config", {}) - self.init_context(config_dict, level="testset") + self.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: result = self.run_test(testcase) diff --git a/test/test_context.py b/test/test_context.py index 09ba71154..2c6ef09d4 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -180,7 +180,7 @@ def test_get_parsed_request(self): "data": "${data}" } } - test_runner.init_context(testcase, level="testcase") + test_runner.init_config(testcase, level="testcase") parsed_request = test_runner.context.get_parsed_request() self.assertIn("authorization", parsed_request["headers"]) self.assertEqual(len(parsed_request["headers"]["authorization"]), 32) From efb3890973c5a30e5ffe80ae4ffc52f1b3ad66b8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 16:03:37 +0800 Subject: [PATCH 098/354] change class name, TestRunner -> Runner --- ate/main.py | 4 ++-- ate/runner.py | 2 +- test/test_context.py | 2 +- test/test_runner.py | 2 +- test/test_runner_v2.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ate/main.py b/ate/main.py index 5a00af43c..9eccf5a8d 100644 --- a/ate/main.py +++ b/ate/main.py @@ -20,11 +20,11 @@ def runTest(self): def create_suite(testset): """ create test suite with a testset, it may include one or several testcases. - each suite should initialize a seperate TestRunner() with testset config. + each suite should initialize a seperate Runner() with testset config. """ suite = unittest.TestSuite() - test_runner = runner.TestRunner() + test_runner = runner.Runner() config_dict = testset.get("config", {}) test_runner.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) diff --git a/ate/runner.py b/ate/runner.py index d184b13b4..2988fbafa 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -3,7 +3,7 @@ from ate.context import Context -class TestRunner(object): +class Runner(object): def __init__(self, base_url=None): self.client = HttpSession(base_url) diff --git a/test/test_context.py b/test/test_context.py index 2c6ef09d4..cd6b89d5c 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -160,7 +160,7 @@ def test_import_module_functions(self): self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) def test_get_parsed_request(self): - test_runner = runner.TestRunner() + test_runner = runner.Runner() testcase = { "import_module_functions": ["test.data.custom_functions"], "variable_binds": [ diff --git a/test/test_runner.py b/test/test_runner.py index ec008bb13..bc61a1662 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -7,7 +7,7 @@ class TestRunner(ApiServerUnittest): def setUp(self): base_url = "http://127.0.0.1:5000" - self.test_runner = runner.TestRunner(base_url) + self.test_runner = runner.Runner(base_url) self.clear_users() def clear_users(self): diff --git a/test/test_runner_v2.py b/test/test_runner_v2.py index 8cb901180..5dc64a9af 100644 --- a/test/test_runner_v2.py +++ b/test/test_runner_v2.py @@ -8,7 +8,7 @@ class TestRunnerV2(ApiServerUnittest): authentication = True def setUp(self): - self.test_runner = runner.TestRunner() + self.test_runner = runner.Runner() self.clear_users() def clear_users(self): From 4bb6d3f5261c1bc1ecda6bbfdc6783bf1464df25 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 16:32:38 +0800 Subject: [PATCH 099/354] set base_url in testset config.request --- ate/runner.py | 4 ++++ test/data/demo_template_sets.yml | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 2988fbafa..309dbb0b8 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -44,6 +44,9 @@ def init_config(self, config_dict, level): self.context.register_variables_config(variable_binds, level) request_config = config_dict.get('request', {}) + if level == "testset": + base_url = request_config.pop("base_url", None) + self.client = HttpSession(base_url) self.context.register_request(request_config, level) def run_test(self, testcase): @@ -72,6 +75,7 @@ def run_test(self, testcase): """ self.init_config(testcase, level="testcase") parsed_request = self.context.get_parsed_request() + try: url = parsed_request.pop('url') method = parsed_request.pop('method') diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 92f8427de..a874110b1 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -12,13 +12,15 @@ - data: "" - random: {"func": "gen_random_string", "args": [5]} - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + request: + base_url: http://127.0.0.1:5000 - test: name: create user which does not exist variable_binds: - data: '{"name": "user", "password": "123456"}' request: - url: http://127.0.0.1:5000/api/users/1000 + url: /api/users/1000 method: POST headers: Content-Type: application/json @@ -35,7 +37,7 @@ - data: '{"name": "user", "password": "123456"}' - expected_status_code: 500 request: - url: http://127.0.0.1:5000/api/users/1000 + url: /api/users/1000 method: POST headers: Content-Type: application/json From b294107bcbf495ab791f2a461ccd70feb1693fe3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 16:40:59 +0800 Subject: [PATCH 100/354] specify logging level from CLI --- ate/client.py | 2 -- ate/main.py | 8 ++++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ate/client.py b/ate/client.py index 986b445b6..622d27659 100644 --- a/ate/client.py +++ b/ate/client.py @@ -9,8 +9,6 @@ from ate.exception import ParamsError -log_level = getattr(logging, "INFO") -logging.basicConfig(level=log_level) absolute_http_url_regexp = re.compile(r"^https?://", re.I) diff --git a/ate/main.py b/ate/main.py index 9eccf5a8d..e40be986a 100644 --- a/ate/main.py +++ b/ate/main.py @@ -1,4 +1,5 @@ import argparse +import logging import unittest from ate import runner, utils @@ -56,7 +57,14 @@ def main(): parser.add_argument( '--testcase-path', default='testcases', help="testcase file path") + parser.add_argument( + '--log-level', default='INFO', + help="Specify logging level, default is INFO.") args = parser.parse_args() + + log_level = getattr(logging, args.log_level.upper()) + logging.basicConfig(level=log_level) + task_suite = create_task(args.testcase_path) unittest.TextTestRunner().run(task_suite) From afff81ab6d942b94f24d8fc39692d9595533a62d Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 3 Jul 2017 23:50:01 +0800 Subject: [PATCH 101/354] fix testcase name --- test/data/demo_template_separate.yml | 2 +- test/data/demo_template_sets.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index 389d49b93..cc0cd8ea3 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -26,7 +26,7 @@ - {"check": "content.success", "comparator": "eq", "expected": true} - test: - name: create user which does not exist + name: create user which does exist requires: - random - string diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index a874110b1..3625c579b 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -32,7 +32,7 @@ - {"check": "content.success", "comparator": "eq", "expected": true} - test: - name: create user which does not exist + name: create user which does exist variable_binds: - data: '{"name": "user", "password": "123456"}' - expected_status_code: 500 From f80ebdc5116c2bab0009e4ef3a431372323cd665 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 14:45:03 +0800 Subject: [PATCH 102/354] add helpers to parse variable and functions --- ate/utils.py | 77 ++++++++++++++++++++++++++++++++++++++++++ test/test_utils.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index bdc921b51..c8a0768f1 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,3 +1,4 @@ +import ast import hashlib import json import os.path @@ -15,6 +16,8 @@ string_type = str PYTHON_VERSION = 3 +variable_regexp = re.compile(r"^\$(\w+)$") +function_regexp = re.compile(r"^\$\{(\w+)\(([\w =,]*)\)\}$") def gen_random_string(str_len): return ''.join( @@ -128,6 +131,80 @@ def load_testcases_by_path(path): else: return [] +def is_variable(content): + """ check if content is a variable, which is in format $variable + @param (str) content + @return (bool) True or False + + e.g. $variable => True + abc => False + """ + matched = variable_regexp.match(content) + return True if matched else False + +def parse_variable(content): + """ parse variable name from string content. + @param (str) content + @return (str) variable name + + e.g. $variable => variable + """ + matched = variable_regexp.match(content) + return matched.group(1) + +def is_functon(content): + """ check if content is a function, which is in format ${func()} + @param (str) content + @return (bool) True or False + + e.g. ${func()} => True + ${func(5)} => True + ${func(1, 2)} => True + ${func(a=1, b=2)} => True + $abc => False + abc => False + """ + matched = function_regexp.match(content) + return True if matched else False + +def parse_string_value(str_value): + try: + return ast.literal_eval(str_value) + except ValueError: + return str_value + +def parse_function(content): + """ parse function name and args from string content. + @param (str) content + @return (dict) function name and args + + e.g. ${func()} => {'func_name': 'func', 'args': [], 'kwargs': {}} + ${func(5)} => {'func_name': 'func', 'args': [5], 'kwargs': {}} + ${func(1, 2)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} + ${func(a=1, b=2)} => {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + ${func(1, 2, a=3, b=4)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4}} + """ + function_meta = { + "args": [], + "kwargs": {} + } + matched = function_regexp.match(content) + function_meta["func_name"] = matched.group(1) + + args_str = matched.group(2).replace(" ", "") + if args_str == "": + return function_meta + + args_list = args_str.split(',') + for arg in args_list: + if '=' in arg: + key, value = arg.split('=') + function_meta["kwargs"][key] = parse_string_value(value) + else: + function_meta["args"].append(parse_string_value(arg)) + + return function_meta + def parse_content_with_variables(content, variables_binds): """ replace variables with bind value """ diff --git a/test/test_utils.py b/test/test_utils.py index 36e930973..7af7e033f 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -137,6 +137,90 @@ def test_parse_content_with_variables(self): with self.assertRaises(exception.ParamsError): utils.parse_content_with_variables(content, variables_binds) + def test_is_variable(self): + content = "$var" + self.assertTrue(utils.is_variable(content)) + content = "$var123" + self.assertTrue(utils.is_variable(content)) + content = "$var_name" + self.assertTrue(utils.is_variable(content)) + content = "var" + self.assertFalse(utils.is_variable(content)) + content = "a$var" + self.assertFalse(utils.is_variable(content)) + content = "$v ar" + self.assertFalse(utils.is_variable(content)) + content = " " + self.assertFalse(utils.is_variable(content)) + content = "$abc*" + self.assertFalse(utils.is_variable(content)) + + def test_parse_variable(self): + content = "$var" + self.assertEqual(utils.parse_variable(content), "var") + content = "$var123" + self.assertEqual(utils.parse_variable(content), "var123") + content = "$var_name" + self.assertEqual(utils.parse_variable(content), "var_name") + + def test_is_functon(self): + content = "${func()}" + self.assertTrue(utils.is_functon(content)) + content = "${func(5)}" + self.assertTrue(utils.is_functon(content)) + content = "${func(1, 2)}" + self.assertTrue(utils.is_functon(content)) + content = "${func(a=1, b=2)}" + self.assertTrue(utils.is_functon(content)) + content = "${func(1, 2, a=3, b=4)}" + self.assertTrue(utils.is_functon(content)) + content = "${func}" + self.assertFalse(utils.is_functon(content)) + content = "$abc" + self.assertFalse(utils.is_functon(content)) + content = "abc" + self.assertFalse(utils.is_functon(content)) + + def test_parse_string_value(self): + str_value = "123" + self.assertEqual(utils.parse_string_value(str_value), 123) + str_value = "12.3" + self.assertEqual(utils.parse_string_value(str_value), 12.3) + str_value = "a123" + self.assertEqual(utils.parse_string_value(str_value), "a123") + + def test_parse_functon(self): + content = "${func()}" + self.assertEqual( + utils.parse_function(content), + {'func_name': 'func', 'args': [], 'kwargs': {}} + ) + content = "${func(5)}" + self.assertEqual( + utils.parse_function(content), + {'func_name': 'func', 'args': [5], 'kwargs': {}} + ) + content = "${func(1, 2)}" + self.assertEqual( + utils.parse_function(content), + {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} + ) + content = "${func(a=1, b=2)}" + self.assertEqual( + utils.parse_function(content), + {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + ) + content = "${func(a= 1, b =2)}" + self.assertEqual( + utils.parse_function(content), + {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + ) + content = "${func(1, 2, a=3, b=4)}" + self.assertEqual( + utils.parse_function(content), + {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a': 3, 'b': 4}} + ) + def test_query_json(self): json_content = { "ids": [1, 2, 3, 4], From d941f3a3d5d33bebdefbf90f3420abd8ac1f718b Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 15:10:18 +0800 Subject: [PATCH 103/354] parse functions: add support for variable argument --- ate/utils.py | 11 ++++++++++- test/test_utils.py | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index c8a0768f1..cc7214455 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -17,7 +17,7 @@ PYTHON_VERSION = 3 variable_regexp = re.compile(r"^\$(\w+)$") -function_regexp = re.compile(r"^\$\{(\w+)\(([\w =,]*)\)\}$") +function_regexp = re.compile(r"^\$\{(\w+)\(([\$\w =,]*)\)\}$") def gen_random_string(str_len): return ''.join( @@ -168,10 +168,19 @@ def is_functon(content): return True if matched else False def parse_string_value(str_value): + """ parse string to number if possible + e.g. "123" => 123 + "12.2" => 12.3 + "abc" => "abc" + "$var" => "$var" + """ try: return ast.literal_eval(str_value) except ValueError: return str_value + except SyntaxError: + # e.g. $var, ${func} + return str_value def parse_function(content): """ parse function name and args from string content. diff --git a/test/test_utils.py b/test/test_utils.py index 7af7e033f..c20da35d6 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -170,10 +170,14 @@ def test_is_functon(self): self.assertTrue(utils.is_functon(content)) content = "${func(1, 2)}" self.assertTrue(utils.is_functon(content)) + content = "${func($a, $b)}" + self.assertTrue(utils.is_functon(content)) content = "${func(a=1, b=2)}" self.assertTrue(utils.is_functon(content)) content = "${func(1, 2, a=3, b=4)}" self.assertTrue(utils.is_functon(content)) + content = "${func(1, $b, c=$x, d=4)}" + self.assertTrue(utils.is_functon(content)) content = "${func}" self.assertFalse(utils.is_functon(content)) content = "$abc" @@ -188,6 +192,10 @@ def test_parse_string_value(self): self.assertEqual(utils.parse_string_value(str_value), 12.3) str_value = "a123" self.assertEqual(utils.parse_string_value(str_value), "a123") + str_value = "$var" + self.assertEqual(utils.parse_string_value(str_value), "$var") + str_value = "${func}" + self.assertEqual(utils.parse_string_value(str_value), "${func}") def test_parse_functon(self): content = "${func()}" From 17bf07f012f0288fcb6b6aed7b97c9beca7d0da5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 16:45:01 +0800 Subject: [PATCH 104/354] change variable marker and function marker: 1, variable marker: ${var} => $var; 2, function marker: {'func': 'gen_random_string', 'args': [5]} => ${gen_random_string(5). --- ate/context.py | 58 ++++++++++++++++------------ ate/runner.py | 6 +-- ate/testcase.py | 36 +++++++++++++---- ate/utils.py | 20 ---------- test/data/demo_binds.yml | 14 +++---- test/data/demo_import_functions.yml | 18 ++++----- test/data/demo_template_separate.yml | 20 +++++----- test/data/demo_template_sets.yml | 16 ++++---- test/test_context.py | 40 ++++++++++--------- test/test_testcase.py | 44 +++++++++++++++------ test/test_utils.py | 22 ----------- 11 files changed, 154 insertions(+), 140 deletions(-) diff --git a/ate/context.py b/ate/context.py index 042ad094e..01e07461f 100644 --- a/ate/context.py +++ b/ate/context.py @@ -78,9 +78,9 @@ def register_variables_config(self, variable_binds, level="testcase"): e.g. [ {"TOKEN": "debugtalk"}, - {"random": {"func": "gen_random_string", "args": [5]}}, + {"random": "${gen_random_string(5)}"}, {"json": {'name': 'user', 'password': '123456'}}, - {"md5": {"func": "gen_md5", "args": ["${TOKEN}", "${json}", "${random}"]}} + {"md5": "${gen_md5($TOKEN, $json, $random)}"} ] """ if level == "testset": @@ -148,29 +148,39 @@ def _get_evaluated_testcase_variables(self): def get_eval_value(self, data): """ evaluate data recursively, each variable in data will be evaluated. - variables marker: ${variable}. """ - if isinstance(data, str): - return utils.parse_content_with_variables(data, self.testcase_variables_mapping) - - if isinstance(data, list): + if isinstance(data, (list, tuple)): return [self.get_eval_value(item) for item in data] if isinstance(data, dict): - if "func" in data: - # this is a function, e.g. {"func": "gen_random_string", "args": [5]} - # function marker: "func" key in dict - # the function will be called, and its return value will be binded - # to the testcase context variable. - func_name = data['func'] - args = self.get_eval_value(data.get('args', [])) - kargs = self.get_eval_value(data.get('kargs', {})) - return self.testcase_config["functions"][func_name](*args, **kargs) - else: - evaluated_data = {} - for key, value in data.items(): - evaluated_data[key] = self.get_eval_value(value) - - return evaluated_data - - return data + evaluated_data = {} + for key, value in data.items(): + evaluated_data[key] = self.get_eval_value(value) + + return evaluated_data + + if isinstance(data, (int, float)): + return data + + # data is in string format here + data = data.strip() + if utils.is_variable(data): + # variable marker: $var + variable_name = utils.parse_variable(data) + value = self.testcase_variables_mapping.get(variable_name) + if value is None: + raise exception.ParamsError( + "%s is not defined in bind variables!" % variable_name) + return value + + elif utils.is_functon(data): + # function marker: ${func(1, 2, a=3, b=4)} + fuction_meta = utils.parse_function(data) + func_name = fuction_meta['func_name'] + args = fuction_meta.get('args', []) + kargs = fuction_meta.get('kargs', {}) + args = self.get_eval_value(args) + kargs = self.get_eval_value(kargs) + return self.testcase_config["functions"][func_name](*args, **kargs) + else: + return data diff --git a/ate/runner.py b/ate/runner.py index 309dbb0b8..7cfde2a9b 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -26,7 +26,7 @@ def init_config(self, config_dict, level): "import_module_functions": ["test.data.custom_functions"], "variable_binds": [ {"TOKEN": "debugtalk"}, - {"random": {"func": "gen_random_string", "args": [5]}}, + {"random": "${gen_random_string(5)}"}, ] } @param (str) context level, testcase or testset @@ -62,8 +62,8 @@ def run_test(self, testcase): "method": "POST", "headers": { "Content-Type": "application/json", - "authorization": "${authorization}", - "random": "${random}" + "authorization": "$authorization", + "random": "$random" }, "body": '{"name": "user", "password": "123456"}' }, diff --git a/ate/testcase.py b/ate/testcase.py index 884edbf5c..3ba54bc71 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,19 +1,40 @@ -from ate import utils +import re +from ate.exception import ParamsError +def parse_content_with_variables(content, variables_binds): + """ replace variables with bind value + """ + # check if content includes $variable + matched = re.match(r"^(.*)\$(\w+)(.*)$", content) + if matched: + # this is a variable, and will replace with its bind value + variable_name = matched.group(2) + value = variables_binds.get(variable_name) + if value is None: + raise ParamsError( + "%s is not defined in bind variables!" % variable_name) + if matched.group(1) or matched.group(3): + # e.g. /api/users/$uid + return content.replace("$%s" % variable_name, value) + + return value + + return content + def parse_template(testcase_template, variables_binds): """ parse testcase_template, replace all variables with bind value. - variables marker: ${variable}. + variables marker: $variable. @param (dict) testcase_template { - "url": "http://127.0.0.1:5000/api/users/${uid}", + "url": "http://127.0.0.1:5000/api/users/$uid", "method": "POST", "headers": { "Content-Type": "application/json", - "authorization": "${authorization}", - "random": "${random}" + "authorization": "$authorization", + "random": "$random" }, - "body": "${data}" + "body": "$data" } @param (dict) variables binds mapping { @@ -36,10 +57,9 @@ def parse_template(testcase_template, variables_binds): def substitute(content): """ substitute content recursively, each variable will be replaced with bind value. - variables marker: ${variable}. """ if isinstance(content, str): - return utils.parse_content_with_variables(content, variables_binds) + return parse_content_with_variables(content, variables_binds) if isinstance(content, list): return [substitute(item) for item in content] diff --git a/ate/utils.py b/ate/utils.py index cc7214455..31d96f7da 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -214,26 +214,6 @@ def parse_function(content): return function_meta -def parse_content_with_variables(content, variables_binds): - """ replace variables with bind value - """ - # check if content includes ${variable} - matched = re.match(r"(.*)\$\{(.*)\}(.*)", content) - if matched: - # this is a variable, and will replace with its bind value - variable_name = matched.group(2) - value = variables_binds.get(variable_name) - if value is None: - raise ParamsError( - "%s is not defined in bind variables!" % variable_name) - if matched.group(1) or matched.group(3): - # e.g. /api/users/${uid} - return re.sub(r"\$\{.*\}", value, content) - - return value - - return content - def query_json(json_content, query, delimiter='.'): """ Do an xpath-like query with json_content. @param (json_content) json_content diff --git a/test/data/demo_binds.yml b/test/data/demo_binds.yml index 20d497b1d..3977f37a0 100644 --- a/test/data/demo_binds.yml +++ b/test/data/demo_binds.yml @@ -7,15 +7,15 @@ register_variables: register_template_variables: variable_binds: - TOKEN: "debugtalk" - - token: ${TOKEN} + - token: $TOKEN bind_lambda_functions: function_binds: add_one: "lambda x: x + 1" add_two_nums: "lambda x, y: x + y" variable_binds: - - add1: {"func": "add_one", "args": [2]} - - sum2nums: {"func": "add_two_nums", "args": [2, 3]} + - add1: ${add_one(2)} + - sum2nums: ${add_two_nums(2, 3)} bind_lambda_functions_with_import: requires: @@ -27,9 +27,9 @@ bind_lambda_functions_with_import: gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" variable_binds: - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} + - random: ${gen_random_string(5)} - data: "{'name': 'user', 'password': '123456'}" - - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + - authorization: ${gen_md5($TOKEN, $data, $random)} bind_module_functions: function_binds: @@ -37,6 +37,6 @@ bind_module_functions: - test.data.custom_functions variable_binds: - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} + - random: ${gen_random_string(5)} - data: "{'name': 'user', 'password': '123456'}" - - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + - authorization: ${gen_md5($TOKEN, $data, $random)} diff --git a/test/data/demo_import_functions.yml b/test/data/demo_import_functions.yml index e48b5f69e..25d2ea11a 100644 --- a/test/data/demo_import_functions.yml +++ b/test/data/demo_import_functions.yml @@ -4,9 +4,9 @@ - test.data.custom_functions variable_binds: - TOKEN: debugtalk - - json: {"name": "user", "password": "123456"} - - random: {"func": "gen_random_string", "args": [5]} - - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${json}", "${random}"]} + - json: {} + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $json, $random)} - test: name: create user which does not exist @@ -17,9 +17,9 @@ method: POST headers: Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - json: "${json}" + authorization: $authorization + random: $random + json: $json validators: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -33,9 +33,9 @@ method: POST headers: Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - json: "${json}" + authorization: $authorization + random: $random + json: $json validators: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/test/data/demo_template_separate.yml b/test/data/demo_template_separate.yml index cc0cd8ea3..75e52d84b 100644 --- a/test/data/demo_template_separate.yml +++ b/test/data/demo_template_separate.yml @@ -10,17 +10,17 @@ gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" variable_binds: - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} + - random: ${gen_random_string(5)} - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + - authorization: ${gen_md5($TOKEN, $data, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - data: "${data}" + authorization: $authorization + random: $random + data: $data validators: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -36,17 +36,17 @@ gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" variable_binds: - TOKEN: debugtalk - - random: {"func": "gen_random_string", "args": [5]} + - random: ${gen_random_string(5)} - data: '{"name": "user", "password": "123456"}' - - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + - authorization: ${gen_md5($TOKEN, $data, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST headers: Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - data: "${data}" + authorization: $authorization + random: $random + data: $data validators: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/test/data/demo_template_sets.yml b/test/data/demo_template_sets.yml index 3625c579b..4296541f2 100644 --- a/test/data/demo_template_sets.yml +++ b/test/data/demo_template_sets.yml @@ -10,8 +10,8 @@ variable_binds: - TOKEN: debugtalk - data: "" - - random: {"func": "gen_random_string", "args": [5]} - - authorization: {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]} + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $data, $random)} request: base_url: http://127.0.0.1:5000 @@ -24,9 +24,9 @@ method: POST headers: Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - data: "${data}" + authorization: $authorization + random: $random + data: $data validators: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -41,9 +41,9 @@ method: POST headers: Content-Type: application/json - authorization: "${authorization}" - random: "${random}" - data: "${data}" + authorization: $authorization + random: $random + data: $data validators: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/test/test_context.py b/test/test_context.py index cd6b89d5c..c091a8bc7 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -43,7 +43,7 @@ def test_context_register_template_variables(self): testcase1 = { "variable_binds": [ {"GLOBAL_TOKEN": "debugtalk"}, - {"token": "${GLOBAL_TOKEN}"} + {"token": "$GLOBAL_TOKEN"} ] } testcase2 = self.testcases["register_template_variables"] @@ -65,8 +65,8 @@ def test_context_bind_lambda_functions(self): "add_two_nums": lambda x, y: x + y }, "variable_binds": [ - {"add1": {"func": "add_one", "args": [2]}}, - {"sum2nums": {"func": "add_two_nums", "args": [2, 3]}} + {"add1": "${add_one(2)}"}, + {"sum2nums": "${add_two_nums(2,3)}"} ] } testcase2 = self.testcases["bind_lambda_functions"] @@ -93,9 +93,9 @@ def test_context_bind_lambda_functions_with_import(self): }, "variable_binds": [ {"TOKEN": "debugtalk"}, - {"random": {"func": "gen_random_string", "args": [5]}}, + {"random": "${gen_random_string(5)}"}, {"data": '{"name": "user", "password": "123456"}'}, - {"authorization": {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]}} + {"authorization": "${gen_md5($TOKEN, $data, $random)}"} ] } testcase2 = self.testcases["bind_lambda_functions_with_import"] @@ -130,9 +130,9 @@ def test_import_module_functions(self): "import_module_functions": ["test.data.custom_functions"], "variable_binds": [ {"TOKEN": "debugtalk"}, - {"random": {"func": "gen_random_string", "args": [5]}}, + {"random": "${gen_random_string(5)}"}, {"data": '{"name": "user", "password": "123456"}'}, - {"authorization": {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]}} + {"authorization": "${gen_md5($TOKEN, $data, $random)}"} ] } testcase2 = self.testcases["bind_module_functions"] @@ -165,19 +165,19 @@ def test_get_parsed_request(self): "import_module_functions": ["test.data.custom_functions"], "variable_binds": [ {"TOKEN": "debugtalk"}, - {"random": {"func": "gen_random_string", "args": [5]}}, + {"random": "${gen_random_string(5)}"}, {"data": '{"name": "user", "password": "123456"}'}, - {"authorization": {"func": "gen_md5", "args": ["${TOKEN}", "${data}", "${random}"]}} + {"authorization": "${gen_md5($TOKEN, $data, $random)}"} ], "request": { "url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", "headers": { "Content-Type": "application/json", - "authorization": "${authorization}", - "random": "${random}" + "authorization": "$authorization", + "random": "$random" }, - "data": "${data}" + "data": "$data" } } test_runner.init_config(testcase, level="testcase") @@ -194,14 +194,14 @@ def test_get_eval_value(self): "str_1": "str_value1", "str_2": "str_value2" } - self.assertEqual(self.context.get_eval_value("${str_1}"), "str_value1") - self.assertEqual(self.context.get_eval_value("${str_2}"), "str_value2") + self.assertEqual(self.context.get_eval_value("$str_1"), "str_value1") + self.assertEqual(self.context.get_eval_value("$str_2"), "str_value2") self.assertEqual( - self.context.get_eval_value(["${str_1}", "str3"]), + self.context.get_eval_value(["$str_1", "str3"]), ["str_value1", "str3"] ) self.assertEqual( - self.context.get_eval_value({"key": "${str_1}"}), + self.context.get_eval_value({"key": "$str_1"}), {"key": "str_value1"} ) @@ -209,6 +209,10 @@ def test_get_eval_value(self): self.context.testcase_config["functions"]["gen_random_string"] = \ lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ for _ in range(str_len)) - result = self.context.get_eval_value( - {"func": "gen_random_string", "args": [5]}) + result = self.context.get_eval_value("${gen_random_string(5)}") self.assertEqual(len(result), 5) + + add_two_nums = lambda a, b=1: a + b + self.context.testcase_config["functions"]["add_two_nums"] = add_two_nums + self.assertEqual(self.context.get_eval_value("${add_two_nums(1)}"), 2) + self.assertEqual(self.context.get_eval_value("${add_two_nums(1, 2)}"), 3) diff --git a/test/test_testcase.py b/test/test_testcase.py index a91c284f0..d32c9a3e6 100644 --- a/test/test_testcase.py +++ b/test/test_testcase.py @@ -1,6 +1,6 @@ import unittest -from ate.testcase import parse_template +from ate.testcase import parse_template, parse_content_with_variables from ate import exception @@ -22,22 +22,22 @@ def setUp(self): def test_parse_testcase_template(self): testcase = { "request": { - "url": "http://127.0.0.1:5000/api/users/${uid}", + "url": "http://127.0.0.1:5000/api/users/$uid", "method": "POST", "headers": { "Content-Type": "application/json", - "authorization": "${authorization}", - "random": "${random}" + "authorization": "$authorization", + "random": "$random" }, - "body": "${json}" + "body": "$json" }, "response": { - "status_code": "${expected_status}", + "status_code": "$expected_status", "headers": { "Content-Type": "application/json" }, "body": { - "success": "${expected_success}", + "success": "$expected_success", "msg": "user created successfully." } } @@ -72,8 +72,8 @@ def test_parse_testcase_template(self): def test_parse_testcase_template_miss_bind_variable(self): testcase = { "request": { - "url": "http://127.0.0.1:5000/api/users/${uid}", - "method": "${method}" + "url": "http://127.0.0.1:5000/api/users/$uid", + "method": "$method" } } with self.assertRaises(exception.ParamsError): @@ -82,8 +82,8 @@ def test_parse_testcase_template_miss_bind_variable(self): def test_parse_testcase_with_new_variable_binds(self): testcase = { "request": { - "url": "http://127.0.0.1:5000/api/users/${uid}", - "method": "${method}" + "url": "http://127.0.0.1:5000/api/users/$uid", + "method": "$method" } } new_variable_binds = { @@ -96,3 +96,25 @@ def test_parse_testcase_with_new_variable_binds(self): parsed_testcase["request"]["method"], new_variable_binds["method"] ) + + def test_parse_content_with_variables(self): + content = "$var" + variables_binds = { + "var": "abc" + } + result = parse_content_with_variables(content, variables_binds) + self.assertEqual(result, "abc") + + content = "123$var/456" + variables_binds = { + "var": "abc" + } + result = parse_content_with_variables(content, variables_binds) + self.assertEqual(result, "123abc/456") + + content = "$var1" + variables_binds = { + "var2": "abc" + } + with self.assertRaises(exception.ParamsError): + parse_content_with_variables(content, variables_binds) diff --git a/test/test_utils.py b/test/test_utils.py index c20da35d6..da701d30d 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -115,28 +115,6 @@ def test_load_testcases_by_path_not_exist(self): testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_3, []) - def test_parse_content_with_variables(self): - content = "${var}" - variables_binds = { - "var": "abc" - } - result = utils.parse_content_with_variables(content, variables_binds) - self.assertEqual(result, "abc") - - content = "123${var}456" - variables_binds = { - "var": "abc" - } - result = utils.parse_content_with_variables(content, variables_binds) - self.assertEqual(result, "123abc456") - - content = "${var1}" - variables_binds = { - "var2": "abc" - } - with self.assertRaises(exception.ParamsError): - utils.parse_content_with_variables(content, variables_binds) - def test_is_variable(self): content = "$var" self.assertTrue(utils.is_variable(content)) From 8707b9c110872de889a625f81b8330e1f7d10355 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 17:05:02 +0800 Subject: [PATCH 105/354] bugfix: correct kwargs key name --- ate/context.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ate/context.py b/ate/context.py index 01e07461f..1eb6865e9 100644 --- a/ate/context.py +++ b/ate/context.py @@ -178,9 +178,9 @@ def get_eval_value(self, data): fuction_meta = utils.parse_function(data) func_name = fuction_meta['func_name'] args = fuction_meta.get('args', []) - kargs = fuction_meta.get('kargs', {}) + kwargs = fuction_meta.get('kwargs', {}) args = self.get_eval_value(args) - kargs = self.get_eval_value(kargs) - return self.testcase_config["functions"][func_name](*args, **kargs) + kwargs = self.get_eval_value(kwargs) + return self.testcase_config["functions"][func_name](*args, **kwargs) else: return data From 99e5430206215e6945c964b7fa2fdfcf8a3108f8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 17:37:47 +0800 Subject: [PATCH 106/354] bugfix: initialize context before each test --- ate/runner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ate/runner.py b/ate/runner.py index 7cfde2a9b..f56c1a34f 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -31,6 +31,8 @@ def init_config(self, config_dict, level): } @param (str) context level, testcase or testset """ + self.context.init_context(level) + requires = config_dict.get('requires', []) self.context.import_requires(requires) From 3e8647e5fdb1c0fa64824b576c5b09467c023edc Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 18:27:39 +0800 Subject: [PATCH 107/354] deep_update_dict: update origin dict with override dict recursively --- ate/utils.py | 15 +++++++++++++++ test/test_utils.py | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 31d96f7da..f3f00ce2f 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -294,3 +294,18 @@ def match_expected(value, expected, comparator="eq"): return True except AssertionError: return False + +def deep_update_dict(origin_dict, override_dict): + """ update origin dict with override dict recursively + e.g. origin_dict = {'a': 1, 'b': {'c': 2, 'd': 4}} + override_dict = {'b': {'c': 3}} + return: {'a': 1, 'b': {'c': 3, 'd': 4}} + """ + for key, val in override_dict.items(): + if isinstance(val, dict): + tmp = deep_update_dict(origin_dict.get(key, {}), val) + origin_dict[key] = tmp + else: + origin_dict[key] = override_dict[key] + + return origin_dict diff --git a/test/test_utils.py b/test/test_utils.py index da701d30d..c648d04a4 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -279,3 +279,12 @@ def test_compare(self): self.assertTrue(utils.match_expected("2017-06-29 17:29:58", 19, "str_len")) self.assertTrue(utils.match_expected("2017-06-29 17:29:58", "19", "str_len")) + + def test_deep_update_dict(self): + origin_dict = {'a': 1, 'b': {'c': 3, 'd': 4}, 'f': 6} + override_dict = {'a': 2, 'b': {'c': 33, 'e': 5}, 'g': 7} + updated_dict = utils.deep_update_dict(origin_dict, override_dict) + self.assertEqual( + updated_dict, + {'a': 2, 'b': {'c': 33, 'd': 4, 'e': 5}, 'f': 6, 'g': 7} + ) From af095731194892bfc409464333b18ffa1d340e41 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 4 Jul 2017 18:29:24 +0800 Subject: [PATCH 108/354] bugfix: update testset request config with testcase request config recursively --- ate/context.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ate/context.py b/ate/context.py index 1eb6865e9..f234ffad2 100644 --- a/ate/context.py +++ b/ate/context.py @@ -110,8 +110,10 @@ def get_parsed_request(self): testcase request shall inherit from testset request configs, but can not change testset configs, that's why we use copy.deepcopy here. """ - testcase_request_config = copy.deepcopy(self.testset_config["request"]) - testcase_request_config.update(self.testcase_config["request"]) + testcase_request_config = utils.deep_update_dict( + copy.deepcopy(self.testset_config["request"]), + self.testcase_config["request"] + ) parsed_request = testcase.parse_template( testcase_request_config, From fc6e4c8eef7f0ca0a2a15c51cc7346ad60a433d6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 5 Jul 2017 15:14:10 +0800 Subject: [PATCH 109/354] add match method: startswith --- ate/utils.py | 2 ++ test/test_utils.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index f3f00ce2f..b75ddfe22 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -288,6 +288,8 @@ def match_expected(value, expected, comparator="eq"): assert re.match(expected, value) elif comparator in ["str_len", "string_length"]: assert len(value) == int(expected) + elif comparator in ["startswith"]: + assert str(value).startswith(str(expected)) else: raise ParamsError("comparator not supported!") diff --git a/test/test_utils.py b/test/test_utils.py index c648d04a4..44c89c1fc 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -247,7 +247,7 @@ def test_query_json(self): result = utils.query_json(json_content, query) self.assertEqual(result, "Leo") - def test_compare(self): + def test_match_expected(self): self.assertTrue(utils.match_expected(1, 1, "eq")) self.assertTrue(utils.match_expected("abc", "abc", "eq")) self.assertTrue(utils.match_expected("abc", "abc")) @@ -280,6 +280,10 @@ def test_compare(self): self.assertTrue(utils.match_expected("2017-06-29 17:29:58", 19, "str_len")) self.assertTrue(utils.match_expected("2017-06-29 17:29:58", "19", "str_len")) + self.assertTrue(utils.match_expected("abc123", "ab", "startswith")) + self.assertTrue(utils.match_expected("123abc", 12, "startswith")) + self.assertTrue(utils.match_expected(12345, 123, "startswith")) + def test_deep_update_dict(self): origin_dict = {'a': 1, 'b': {'c': 3, 'd': 4}, 'f': 6} override_dict = {'a': 2, 'b': {'c': 33, 'e': 5}, 'g': 7} From 952d682dbb6b635c6331ee0bf7f25dc280a98683 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 7 Jul 2017 14:49:17 +0800 Subject: [PATCH 110/354] optimize Runner initialization --- ate/runner.py | 13 +++++++++---- test/test_runner.py | 5 ++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index f56c1a34f..072c5b9e4 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,3 +1,5 @@ +import requests + from ate import exception, response from ate.client import HttpSession from ate.context import Context @@ -5,8 +7,8 @@ class Runner(object): - def __init__(self, base_url=None): - self.client = HttpSession(base_url) + def __init__(self, http_client_session=None): + self.http_client_session = http_client_session self.context = Context() def init_config(self, config_dict, level): @@ -48,7 +50,10 @@ def init_config(self, config_dict, level): request_config = config_dict.get('request', {}) if level == "testset": base_url = request_config.pop("base_url", None) - self.client = HttpSession(base_url) + self.http_client_session = self.http_client_session or HttpSession(base_url) + else: + # testcase + self.http_client_session = self.http_client_session or requests.Session() self.context.register_request(request_config, level) def run_test(self, testcase): @@ -84,7 +89,7 @@ def run_test(self, testcase): except KeyError: raise exception.ParamsError("URL or METHOD missed!") - resp = self.client.request(url=url, method=method, **parsed_request) + resp = self.http_client_session.request(url=url, method=method, **parsed_request) resp_obj = response.ResponseObject(resp) extract_binds = testcase.get("extract_binds", {}) diff --git a/test/test_runner.py b/test/test_runner.py index bc61a1662..1fd3f4626 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -6,8 +6,7 @@ class TestRunner(ApiServerUnittest): def setUp(self): - base_url = "http://127.0.0.1:5000" - self.test_runner = runner.Runner(base_url) + self.test_runner = runner.Runner() self.clear_users() def clear_users(self): @@ -32,7 +31,7 @@ def test_run_single_testcase_fail(self): testcase = { "name": "create user which does not exist", "request": { - "url": "/api/users/1000", + "url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", "headers": { "content-type": "application/json" From 3c95c7dc16c4110cb091901e2b7d7319ea37a523 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 10 Jul 2017 12:26:10 +0800 Subject: [PATCH 111/354] update README --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ff6149566..da17b81e2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,74 @@ Run unittest to make sure everything is OK. $ python -m unittest discover ``` +## 编写测试用例 + +推荐采用`YAML`格式编写测试用例。 + +如下是一个典型的接口测试用例示例。具体的编写方式请阅读详细文档。 + +```python +- config: + name: "create user testsets." + requires: + - random + - string + - hashlib + function_binds: + gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + variable_binds: + - TOKEN: debugtalk + - data: "" + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $data, $random)} + request: + base_url: http://127.0.0.1:5000 + +- test: + name: create user which does not exist + variable_binds: + - data: '{"name": "user", "password": "123456"}' + request: + url: /api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: $authorization + random: $random + data: $data + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +- test: + name: create user which does exist + variable_binds: + - data: '{"name": "user", "password": "123456"}' + - expected_status_code: 500 + request: + url: /api/users/1000 + method: POST + headers: + Content-Type: application/json + authorization: $authorization + random: $random + data: $data + validators: + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} +``` + +## 运行测试用例 + +`ApiTestEngine`可指定运行特定的测试用例文件,或运行指定目录下的所有测试用例。 + +```bash +$ python main.py --testcase-path filepath/testcase.yml + +$ python main.py --testcase-path testcases_folder_path +``` + ## Supported Python Versions Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. @@ -35,5 +103,6 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. ## 阅读更多 - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) -- [《ApiTestEngine 演化之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) -- [《ApiTestEngine 演化之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) +- [《ApiTestEngine 演进之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) +- [《ApiTestEngine 演进之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) +- [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) From 37d08ac2f9b13a1f1a303536b08828c0066d22e3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 11 Jul 2017 17:24:10 +0800 Subject: [PATCH 112/354] update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index da17b81e2..da0cdaf9f 100644 --- a/README.md +++ b/README.md @@ -106,3 +106,4 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. - [《ApiTestEngine 演进之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) - [《ApiTestEngine 演进之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) - [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) +- [《ApiTestEngine 演进之路(3)测试用例中实现 Python 函数的定义》](http://debugtalk.com/post/ApiTestEngine-3-define-functions-in-yaml-testcases/) From a06c61a883918332c3b2fac1634ee5149285aa87 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 14 Jul 2017 12:44:44 +0800 Subject: [PATCH 113/354] generate html test report with HtmlTestRunner --- ate/main.py | 9 ++++++++- requirements.txt | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ate/main.py b/ate/main.py index e40be986a..e9119f9c0 100644 --- a/ate/main.py +++ b/ate/main.py @@ -2,6 +2,8 @@ import logging import unittest +import HtmlTestRunner + from ate import runner, utils @@ -31,6 +33,11 @@ def create_suite(testset): testcases = testset.get("testcases", []) for testcase in testcases: + if utils.PYTHON_VERSION == 3: + ApiTestCase.runTest.__doc__ = testcase['name'] + else: + ApiTestCase.runTest.__func__.__doc__ = testcase['name'] + test = ApiTestCase(test_runner, testcase) suite.addTest(test) @@ -67,4 +74,4 @@ def main(): logging.basicConfig(level=log_level) task_suite = create_task(args.testcase_path) - unittest.TextTestRunner().run(task_suite) + HtmlTestRunner.HTMLTestRunner(output="test-reports").run(task_suite) diff --git a/requirements.txt b/requirements.txt index e1615c3df..d9a0582ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ termcolor flask PyYAML coveralls -coverage \ No newline at end of file +coverage +-e git+https://github.com/debugtalk/HtmlTestRunner.git#egg=HtmlTestRunner From 318f5b41309db4ac48de1f8814d6bf6287f65d8b Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 14 Jul 2017 15:16:47 +0800 Subject: [PATCH 114/354] add custom functions: gen_urlencode_str, get_timestamp --- ate/client.py | 2 +- test/data/custom_functions.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ate/client.py b/ate/client.py index 622d27659..a6d29ea0e 100644 --- a/ate/client.py +++ b/ate/client.py @@ -130,7 +130,7 @@ def request(self, method, url, **kwargs): method=method, url=url, exception=str(e))) else: logging.info( - """ status_code: {}! response_time: {} ms, response_length: {} bytes"""\ + """ status_code: {}, response_time: {} ms, response_length: {} bytes"""\ .format(request_meta["status_code"], request_meta["response_time"], \ request_meta["content_size"])) diff --git a/test/data/custom_functions.py b/test/data/custom_functions.py index 10e098b94..4f9d7d567 100644 --- a/test/data/custom_functions.py +++ b/test/data/custom_functions.py @@ -2,13 +2,16 @@ import json import random import string +import time try: string_type = basestring PYTHON_VERSION = 2 + import urllib except NameError: string_type = str PYTHON_VERSION = 3 + import urllib.parse as urllib def gen_random_string(str_len): @@ -40,3 +43,30 @@ def handle_req_data(data): data = json.dumps(data, sort_keys=True) return data + +def gen_urlencode_str(**kargs): + urlencoded_str = "" + quote_times = int(kargs.pop("quote_times", 1)) + + for key, value in kargs.items(): + urlencoded_str += key + urlencoded_str += "=" + if value == "undefined": + urlencoded_str += "undefined" + else: + if isinstance(value, (dict, list)): + value = json.dumps(value) + elif isinstance(value, (int, float)): + value = str(value) + + value_str = value.encode('utf-8') + for _ in range(quote_times): + value_str = urllib.quote_plus(value_str) + urlencoded_str += value_str + + urlencoded_str += "&" + + return urlencoded_str.strip("&") + +def get_timestamp(): + return int(time.time() * 1000) From 7109911ad905596b08ba3ced8869c9e9fa7cde2d Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 14 Jul 2017 15:41:44 +0800 Subject: [PATCH 115/354] bugfix: make compatible with None field in testcase --- ate/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/context.py b/ate/context.py index f234ffad2..ed59e7908 100644 --- a/ate/context.py +++ b/ate/context.py @@ -165,7 +165,7 @@ def get_eval_value(self, data): return data # data is in string format here - data = data.strip() + data = "" if data is None else data.strip() if utils.is_variable(data): # variable marker: $var variable_name = utils.parse_variable(data) From a99d1aeb370fc65baf25a8189c97b8099c4192f9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 14 Jul 2017 15:55:24 +0800 Subject: [PATCH 116/354] set testcase filename as output folder name --- ate/main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ate/main.py b/ate/main.py index e9119f9c0..e843e3ed9 100644 --- a/ate/main.py +++ b/ate/main.py @@ -1,3 +1,4 @@ +import os import argparse import logging import unittest @@ -73,5 +74,8 @@ def main(): log_level = getattr(logging, args.log_level.upper()) logging.basicConfig(level=log_level) - task_suite = create_task(args.testcase_path) - HtmlTestRunner.HTMLTestRunner(output="test-reports").run(task_suite) + testcase_path = args.testcase_path.rstrip('/') + task_suite = create_task(testcase_path) + + output_folder_name = os.path.basename(os.path.splitext(testcase_path)[0]) + HtmlTestRunner.HTMLTestRunner(output=output_folder_name).run(task_suite) From fa4b542763db89756fa7c6ce4c99caf48b4cd1e0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 14 Jul 2017 18:22:03 +0800 Subject: [PATCH 117/354] change HtmlTestRunner to PyUnitReport --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d9a0582ef..1bda70be8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ flask PyYAML coveralls coverage --e git+https://github.com/debugtalk/HtmlTestRunner.git#egg=HtmlTestRunner +-e git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport From e0c00ca3262aa7ffb750497b92a61289dcc0d0c3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 17 Jul 2017 18:36:23 +0800 Subject: [PATCH 118/354] add report_name argument to specify report name, if not specified, use current time as default --- ate/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ate/main.py b/ate/main.py index e843e3ed9..7b19cea3a 100644 --- a/ate/main.py +++ b/ate/main.py @@ -68,6 +68,9 @@ def main(): parser.add_argument( '--log-level', default='INFO', help="Specify logging level, default is INFO.") + parser.add_argument( + '--report-name', + help="Specify report name, default is generated time.") args = parser.parse_args() @@ -78,4 +81,8 @@ def main(): task_suite = create_task(testcase_path) output_folder_name = os.path.basename(os.path.splitext(testcase_path)[0]) - HtmlTestRunner.HTMLTestRunner(output=output_folder_name).run(task_suite) + kwargs = { + "output": output_folder_name, + "report_name": args.report_name + } + HtmlTestRunner.HTMLTestRunner(**kwargs).run(task_suite) From d5bd14c4bc01360a53b6f2cd3efd999afffbad29 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 18 Jul 2017 12:24:51 +0800 Subject: [PATCH 119/354] update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index da0cdaf9f..a70c73a59 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,4 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. - [《ApiTestEngine 演进之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) - [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) - [《ApiTestEngine 演进之路(3)测试用例中实现 Python 函数的定义》](http://debugtalk.com/post/ApiTestEngine-3-define-functions-in-yaml-testcases/) +- [《ApiTestEngine 演进之路(4)测试用例中实现 Python 函数的调用》](http://debugtalk.com/post/ApiTestEngine-4-call-functions-in-yaml-testcases/) From 4d1c78354fc15cc3a46facfda0e9757106e1386b Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 19 Jul 2017 15:49:15 +0800 Subject: [PATCH 120/354] split ate/main.py to ate/cli.py and ate/task.py --- ate/cli.py | 37 +++++++++++++++++++++++++++++ ate/{main.py => task.py} | 35 --------------------------- main.py | 2 +- test/{test_main.py => test_task.py} | 10 ++++---- 4 files changed, 43 insertions(+), 41 deletions(-) create mode 100644 ate/cli.py rename ate/{main.py => task.py} (60%) rename test/{test_main.py => test_task.py} (75%) diff --git a/ate/cli.py b/ate/cli.py new file mode 100644 index 000000000..d6f8f6296 --- /dev/null +++ b/ate/cli.py @@ -0,0 +1,37 @@ +import os +import argparse +import logging + +import HtmlTestRunner + +from ate.task import create_task + +def main(): + """ parse command line options and run commands. + """ + parser = argparse.ArgumentParser( + description='Api Test Engine.') + parser.add_argument( + '--testcase-path', default='testcases', + help="testcase file path") + parser.add_argument( + '--log-level', default='INFO', + help="Specify logging level, default is INFO.") + parser.add_argument( + '--report-name', + help="Specify report name, default is generated time.") + + args = parser.parse_args() + + log_level = getattr(logging, args.log_level.upper()) + logging.basicConfig(level=log_level) + + testcase_path = args.testcase_path.rstrip('/') + task_suite = create_task(testcase_path) + + output_folder_name = os.path.basename(os.path.splitext(testcase_path)[0]) + kwargs = { + "output": output_folder_name, + "report_name": args.report_name + } + HtmlTestRunner.HTMLTestRunner(**kwargs).run(task_suite) diff --git a/ate/main.py b/ate/task.py similarity index 60% rename from ate/main.py rename to ate/task.py index 7b19cea3a..83dfc6f89 100644 --- a/ate/main.py +++ b/ate/task.py @@ -1,10 +1,5 @@ -import os -import argparse -import logging import unittest -import HtmlTestRunner - from ate import runner, utils @@ -56,33 +51,3 @@ def create_task(testcase_path): task_suite.addTest(suite) return task_suite - -def main(): - """ parse command line options and run commands. - """ - parser = argparse.ArgumentParser( - description='Api Test Engine.') - parser.add_argument( - '--testcase-path', default='testcases', - help="testcase file path") - parser.add_argument( - '--log-level', default='INFO', - help="Specify logging level, default is INFO.") - parser.add_argument( - '--report-name', - help="Specify report name, default is generated time.") - - args = parser.parse_args() - - log_level = getattr(logging, args.log_level.upper()) - logging.basicConfig(level=log_level) - - testcase_path = args.testcase_path.rstrip('/') - task_suite = create_task(testcase_path) - - output_folder_name = os.path.basename(os.path.splitext(testcase_path)[0]) - kwargs = { - "output": output_folder_name, - "report_name": args.report_name - } - HtmlTestRunner.HTMLTestRunner(**kwargs).run(task_suite) diff --git a/main.py b/main.py index 6bb44538f..18fdc38c8 100644 --- a/main.py +++ b/main.py @@ -1,2 +1,2 @@ -from ate.main import main +from ate.cli import main main() \ No newline at end of file diff --git a/test/test_main.py b/test/test_task.py similarity index 75% rename from test/test_main.py rename to test/test_task.py index 8e335a716..8e2e7b0e7 100644 --- a/test/test_main.py +++ b/test/test_task.py @@ -2,7 +2,7 @@ import random import requests from test.base import ApiServerUnittest -from ate import main, utils +from ate import task, utils class TestMain(ApiServerUnittest): @@ -16,15 +16,15 @@ def clear_users(self): def test_create_suite(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') testsets = utils.load_testcases_by_path(testcase_file_path) - suite = main.create_suite(testsets[0]) + suite = task.create_suite(testsets[0]) self.assertEqual(suite.countTestCases(), 2) for testcase in suite: - self.assertIsInstance(testcase, main.ApiTestCase) + self.assertIsInstance(testcase, task.ApiTestCase) def test_create_task(self): testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') - task_suite = main.create_task(testcase_file_path) + task_suite = task.create_task(testcase_file_path) self.assertEqual(task_suite.countTestCases(), 2) for suite in task_suite: for testcase in suite: - self.assertIsInstance(testcase, main.ApiTestCase) + self.assertIsInstance(testcase, task.ApiTestCase) From e3f4249f53091e324b01334d112b6802425a1e13 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 19 Jul 2017 19:06:02 +0800 Subject: [PATCH 121/354] change with PyUnitReport --- ate/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index d6f8f6296..654bcc25e 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -2,7 +2,7 @@ import argparse import logging -import HtmlTestRunner +import PyUnitReport from ate.task import create_task @@ -34,4 +34,4 @@ def main(): "output": output_folder_name, "report_name": args.report_name } - HtmlTestRunner.HTMLTestRunner(**kwargs).run(task_suite) + PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) From aaeffeb14fc61788b147b93b4316aa4246ca1a98 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 19 Jul 2017 19:06:25 +0800 Subject: [PATCH 122/354] add setup --- README.md | 12 ++---- ate/__init__.py | 1 + requirements.txt => requirements_dev.txt | 0 setup.py | 49 ++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) rename requirements.txt => requirements_dev.txt (100%) create mode 100644 setup.py diff --git a/README.md b/README.md index a70c73a59..a91de3714 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,7 @@ ## Install ```bash -$ pip install -r requirements.txt -``` - -Run unittest to make sure everything is OK. - -```bash -$ python -m unittest discover +$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine ``` ## 编写测试用例 @@ -91,9 +85,9 @@ $ python -m unittest discover `ApiTestEngine`可指定运行特定的测试用例文件,或运行指定目录下的所有测试用例。 ```bash -$ python main.py --testcase-path filepath/testcase.yml +$ ate --testcase-path filepath/testcase.yml -$ python main.py --testcase-path testcases_folder_path +$ ate --testcase-path testcases_folder_path ``` ## Supported Python Versions diff --git a/ate/__init__.py b/ate/__init__.py index e69de29bb..541f859dc 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.0' \ No newline at end of file diff --git a/requirements.txt b/requirements_dev.txt similarity index 100% rename from requirements.txt rename to requirements_dev.txt diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..589ef2ad1 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +#encoding: utf-8 +import os +import re +from setuptools import setup, find_packages + +# parse version from ate/__init__.py +with open(os.path.join(os.path.dirname(__file__), 'ate', '__init__.py')) as f: + version = re.compile(r"__version__\s+=\s+'(.*)'", re.I).match(f.read()).group(1) + +with open('README.md') as f: + long_description = f.read() + +setup( + name='ApiTestEngine', + version=version, + description='An API test engine.', + long_description=long_description, + author='Leo Lee', + author_email='mail@debugtalk.com', + url='https://github.com/debugtalk/ApiTestEngine', + license='MIT', + packages=find_packages(exclude=['test.*', 'test']), + keywords='api test', + install_requires=[ + "requests", + "termcolor", + "flask", + "PyYAML", + "coveralls", + "coverage", + "PyUnitReport" + ], + dependency_links=[ + "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport" + ], + classifiers=[ + "Development Status :: 3 - Alpha", + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6' + ], + entry_points={ + 'console_scripts': [ + 'ate=ate.cli:main' + ] + } +) From 15c337f2019603ebef31b7da0457708f879ad779 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 19 Jul 2017 19:18:02 +0800 Subject: [PATCH 123/354] : --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index a5a29e036..b0ec3a619 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ python: - 3.5 - 3.6 install: - - pip install -r requirements.txt + - pip install -r requirements_dev.txt script: - coverage run --source=ate -m unittest discover after_success: From 26523621859a024053b177915c45412d96b78068 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 19 Jul 2017 21:24:37 +0800 Subject: [PATCH 124/354] add command argument: show version --- ate/cli.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ate/cli.py b/ate/cli.py index 654bcc25e..81a6d7abe 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -4,6 +4,7 @@ import PyUnitReport +from ate import __version__ from ate.task import create_task def main(): @@ -11,6 +12,9 @@ def main(): """ parser = argparse.ArgumentParser( description='Api Test Engine.') + parser.add_argument( + '-V', '--version', dest='version', action='store_true', + help="show version") parser.add_argument( '--testcase-path', default='testcases', help="testcase file path") @@ -23,6 +27,10 @@ def main(): args = parser.parse_args() + if args.version: + print(__version__) + exit(0) + log_level = getattr(logging, args.log_level.upper()) logging.basicConfig(level=log_level) From 031727c6138ea9d5217daa7af661d7504228a83d Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 19 Jul 2017 23:29:29 +0800 Subject: [PATCH 125/354] change method to specify testset path --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++---- ate/cli.py | 28 ++++++++++++++++++---------- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a91de3714..c92bec7d0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,34 @@ $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine ``` +执行`ate -V`,检测安装是否成功。 + +```text +$ ate -V +0.1.0 +``` + +执行`ate -h`,查看命令的帮助说明。 + +```text +$ ate -h +usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] + [testset_paths [testset_paths ...]] + +Api Test Engine. + +positional arguments: + testset_paths testset file path + +optional arguments: + -h, --help show this help message and exit + -V, --version show version + --log-level LOG_LEVEL + Specify logging level, default is INFO. + --report-name REPORT_NAME + Specify report name, default is generated time. +``` + ## 编写测试用例 推荐采用`YAML`格式编写测试用例。 @@ -82,12 +110,24 @@ $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngi ## 运行测试用例 -`ApiTestEngine`可指定运行特定的测试用例文件,或运行指定目录下的所有测试用例。 +`ApiTestEngine`可指定运行特定的测试用例集文件,或运行指定目录下的所有测试用例集文件。 -```bash -$ ate --testcase-path filepath/testcase.yml +执行单个测试用例集: + +```text +$ ate filepath/testcase.yml +``` + +执行多个测试用例集: + +```text +$ ate filepath1/testcase1.yml filepath2/testcase2.yml +``` + +执行指定目录下的所有测试用例集: -$ ate --testcase-path testcases_folder_path +```text +$ ate testcases_folder_path ``` ## Supported Python Versions diff --git a/ate/cli.py b/ate/cli.py index 81a6d7abe..aafed92fa 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -16,8 +16,8 @@ def main(): '-V', '--version', dest='version', action='store_true', help="show version") parser.add_argument( - '--testcase-path', default='testcases', - help="testcase file path") + 'testset_paths', nargs='*', + help="testset file path") parser.add_argument( '--log-level', default='INFO', help="Specify logging level, default is INFO.") @@ -34,12 +34,20 @@ def main(): log_level = getattr(logging, args.log_level.upper()) logging.basicConfig(level=log_level) - testcase_path = args.testcase_path.rstrip('/') - task_suite = create_task(testcase_path) + report_name = args.report_name + if report_name and len(args.testset_paths) > 1: + report_name = None + logging.warning("More than one testset paths specified, \ + report name is ignored, use generated time instead.") - output_folder_name = os.path.basename(os.path.splitext(testcase_path)[0]) - kwargs = { - "output": output_folder_name, - "report_name": args.report_name - } - PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) + for testset_path in args.testset_paths: + + testset_path = testset_path.strip('/') + task_suite = create_task(testset_path) + + output_folder_name = os.path.basename(os.path.splitext(testset_path)[0]) + kwargs = { + "output": output_folder_name, + "report_name": report_name + } + PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) From 43bb240da9a2b8ce92f96cd076170b65c66b6ee6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 20 Jul 2017 10:32:58 +0800 Subject: [PATCH 126/354] make custom module importable anywhere --- ate/context.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ate/context.py b/ate/context.py index ed59e7908..c3685f902 100644 --- a/ate/context.py +++ b/ate/context.py @@ -1,6 +1,8 @@ import copy import importlib +import os import re +import sys import types from collections import OrderedDict @@ -67,6 +69,7 @@ def bind_functions(self, function_binds, level="testcase"): def import_module_functions(self, modules, level="testcase"): """ import modules and bind all functions within the context """ + sys.path.insert(0, os.getcwd()) for module_name in modules: imported = importlib.import_module(module_name) imported_functions_dict = dict(filter(is_function, vars(imported).items())) From f30ea4ae831f52768f3133bf43933831b83ca4b5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 20 Jul 2017 10:49:58 +0800 Subject: [PATCH 127/354] remove testcases folder --- testcases/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 testcases/__init__.py diff --git a/testcases/__init__.py b/testcases/__init__.py deleted file mode 100644 index e69de29bb..000000000 From 7f88efd866c4fcbfc0d4e8b5b847b102cc423400 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 20 Jul 2017 11:08:59 +0800 Subject: [PATCH 128/354] add FAQ for installing --- README.md | 4 +++- docs/FAQ.md | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 docs/FAQ.md diff --git a/README.md b/README.md index c92bec7d0..b9bc95a7c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine ``` -执行`ate -V`,检测安装是否成功。 +若安装出现问题,请查看[`FAQ`](docs/FAQ.md)。 + +执行`ate -V`,若正常显示版本号,则说明安装成功。 ```text $ ate -V diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 000000000..475a3bbd4 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,20 @@ +## 无法自动安装PyUnitReport依赖库 + +如果安装过程中出现如下报错: + +```text +Downloading/unpacking PyUnitReport (from ApiTestEngine) + Could not find any downloads that satisfy the requirement PyUnitReport (from ApiTestEngine) +``` + +那么需要先手动安装`PyUnitReport`,安装方式如下: + +```bash +$ pip install git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport +``` + +然后再重新安装`ApiTestEngine`即可。 + +```bash +$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine +``` From 95649800e120e1cb1ebbc1d74b830fe7e9c161d9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 20 Jul 2017 11:18:09 +0800 Subject: [PATCH 129/354] rename test folder to tests --- {test => tests}/__init__.py | 0 {test => tests}/api_server.py | 0 {test => tests}/base.py | 2 +- {test => tests}/data/__init__.py | 0 {test => tests}/data/custom_functions.py | 0 {test => tests}/data/demo_binds.yml | 2 +- .../data/demo_import_functions.yml | 2 +- .../data/demo_template_separate.yml | 0 {test => tests}/data/demo_template_sets.yml | 0 .../data/simple_demo_auth_hardcode.json | 0 .../data/simple_demo_auth_hardcode.yml | 0 {test => tests}/data/simple_demo_no_auth.json | 0 {test => tests}/data/simple_demo_no_auth.yml | 0 {test => tests}/test_apiserver.py | 2 +- {test => tests}/test_apiserver_v2.py | 2 +- {test => tests}/test_client.py | 2 +- {test => tests}/test_context.py | 6 +-- {test => tests}/test_response.py | 2 +- {test => tests}/test_runner.py | 14 +++---- {test => tests}/test_runner_v2.py | 24 ++++++------ {test => tests}/test_task.py | 6 +-- {test => tests}/test_testcase.py | 0 {test => tests}/test_utils.py | 38 +++++++++---------- 23 files changed, 51 insertions(+), 51 deletions(-) rename {test => tests}/__init__.py (100%) rename {test => tests}/api_server.py (100%) rename {test => tests}/base.py (97%) rename {test => tests}/data/__init__.py (100%) rename {test => tests}/data/custom_functions.py (100%) rename {test => tests}/data/demo_binds.yml (96%) rename {test => tests}/data/demo_import_functions.yml (97%) rename {test => tests}/data/demo_template_separate.yml (100%) rename {test => tests}/data/demo_template_sets.yml (100%) rename {test => tests}/data/simple_demo_auth_hardcode.json (100%) rename {test => tests}/data/simple_demo_auth_hardcode.yml (100%) rename {test => tests}/data/simple_demo_no_auth.json (100%) rename {test => tests}/data/simple_demo_no_auth.yml (100%) rename {test => tests}/test_apiserver.py (99%) rename {test => tests}/test_apiserver_v2.py (99%) rename {test => tests}/test_client.py (96%) rename {test => tests}/test_context.py (97%) rename {test => tests}/test_response.py (99%) rename {test => tests}/test_runner.py (85%) rename {test => tests}/test_runner_v2.py (83%) rename {test => tests}/test_task.py (78%) rename {test => tests}/test_testcase.py (100%) rename {test => tests}/test_utils.py (91%) diff --git a/test/__init__.py b/tests/__init__.py similarity index 100% rename from test/__init__.py rename to tests/__init__.py diff --git a/test/api_server.py b/tests/api_server.py similarity index 100% rename from test/api_server.py rename to tests/api_server.py diff --git a/test/base.py b/tests/base.py similarity index 97% rename from test/base.py rename to tests/base.py index 36be48df4..a3fdc8e13 100644 --- a/test/base.py +++ b/tests/base.py @@ -3,7 +3,7 @@ import unittest from ate import utils -from test import api_server +from tests import api_server class ApiServerUnittest(unittest.TestCase): diff --git a/test/data/__init__.py b/tests/data/__init__.py similarity index 100% rename from test/data/__init__.py rename to tests/data/__init__.py diff --git a/test/data/custom_functions.py b/tests/data/custom_functions.py similarity index 100% rename from test/data/custom_functions.py rename to tests/data/custom_functions.py diff --git a/test/data/demo_binds.yml b/tests/data/demo_binds.yml similarity index 96% rename from test/data/demo_binds.yml rename to tests/data/demo_binds.yml index 3977f37a0..180d9dae3 100644 --- a/test/data/demo_binds.yml +++ b/tests/data/demo_binds.yml @@ -34,7 +34,7 @@ bind_lambda_functions_with_import: bind_module_functions: function_binds: import_module_functions: - - test.data.custom_functions + - tests.data.custom_functions variable_binds: - TOKEN: debugtalk - random: ${gen_random_string(5)} diff --git a/test/data/demo_import_functions.yml b/tests/data/demo_import_functions.yml similarity index 97% rename from test/data/demo_import_functions.yml rename to tests/data/demo_import_functions.yml index 25d2ea11a..89a488890 100644 --- a/test/data/demo_import_functions.yml +++ b/tests/data/demo_import_functions.yml @@ -1,7 +1,7 @@ - config: name: "create user testsets." import_module_functions: - - test.data.custom_functions + - tests.data.custom_functions variable_binds: - TOKEN: debugtalk - json: {} diff --git a/test/data/demo_template_separate.yml b/tests/data/demo_template_separate.yml similarity index 100% rename from test/data/demo_template_separate.yml rename to tests/data/demo_template_separate.yml diff --git a/test/data/demo_template_sets.yml b/tests/data/demo_template_sets.yml similarity index 100% rename from test/data/demo_template_sets.yml rename to tests/data/demo_template_sets.yml diff --git a/test/data/simple_demo_auth_hardcode.json b/tests/data/simple_demo_auth_hardcode.json similarity index 100% rename from test/data/simple_demo_auth_hardcode.json rename to tests/data/simple_demo_auth_hardcode.json diff --git a/test/data/simple_demo_auth_hardcode.yml b/tests/data/simple_demo_auth_hardcode.yml similarity index 100% rename from test/data/simple_demo_auth_hardcode.yml rename to tests/data/simple_demo_auth_hardcode.yml diff --git a/test/data/simple_demo_no_auth.json b/tests/data/simple_demo_no_auth.json similarity index 100% rename from test/data/simple_demo_no_auth.json rename to tests/data/simple_demo_no_auth.json diff --git a/test/data/simple_demo_no_auth.yml b/tests/data/simple_demo_no_auth.yml similarity index 100% rename from test/data/simple_demo_no_auth.yml rename to tests/data/simple_demo_no_auth.yml diff --git a/test/test_apiserver.py b/tests/test_apiserver.py similarity index 99% rename from test/test_apiserver.py rename to tests/test_apiserver.py index 29694f303..6e0c59c38 100644 --- a/test/test_apiserver.py +++ b/tests/test_apiserver.py @@ -1,6 +1,6 @@ import requests import random -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestApiServer(ApiServerUnittest): def setUp(self): diff --git a/test/test_apiserver_v2.py b/tests/test_apiserver_v2.py similarity index 99% rename from test/test_apiserver_v2.py rename to tests/test_apiserver_v2.py index c95ab935e..50c42a041 100644 --- a/test/test_apiserver_v2.py +++ b/tests/test_apiserver_v2.py @@ -1,7 +1,7 @@ import random import requests -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestApiServerV2(ApiServerUnittest): diff --git a/test/test_client.py b/tests/test_client.py similarity index 96% rename from test/test_client.py rename to tests/test_client.py index f5fa6b5ab..a42d7239b 100644 --- a/test/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,5 @@ from ate.client import HttpSession -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestHttpClient(ApiServerUnittest): def setUp(self): diff --git a/test/test_context.py b/tests/test_context.py similarity index 97% rename from test/test_context.py rename to tests/test_context.py index c091a8bc7..414364a2c 100644 --- a/test/test_context.py +++ b/tests/test_context.py @@ -9,7 +9,7 @@ class VariableBindsUnittest(unittest.TestCase): def setUp(self): self.context = Context() - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo_binds.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_binds.yml') self.testcases = utils.load_testcases(testcase_file_path) def test_context_register_variables(self): @@ -127,7 +127,7 @@ def test_context_bind_lambda_functions_with_import(self): def test_import_module_functions(self): testcase1 = { - "import_module_functions": ["test.data.custom_functions"], + "import_module_functions": ["tests.data.custom_functions"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, @@ -162,7 +162,7 @@ def test_import_module_functions(self): def test_get_parsed_request(self): test_runner = runner.Runner() testcase = { - "import_module_functions": ["test.data.custom_functions"], + "import_module_functions": ["tests.data.custom_functions"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, diff --git a/test/test_response.py b/tests/test_response.py similarity index 99% rename from test/test_response.py rename to tests/test_response.py index cbeb01d6f..01fde5d43 100644 --- a/test/test_response.py +++ b/tests/test_response.py @@ -1,6 +1,6 @@ import requests from ate import response, exception -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestResponse(ApiServerUnittest): diff --git a/test/test_runner.py b/tests/test_runner.py similarity index 85% rename from test/test_runner.py rename to tests/test_runner.py index 1fd3f4626..ab5506683 100644 --- a/test/test_runner.py +++ b/tests/test_runner.py @@ -1,7 +1,7 @@ import os import requests from ate import runner, exception, utils -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestRunner(ApiServerUnittest): @@ -14,14 +14,14 @@ def clear_users(self): return requests.delete(url) def test_run_single_testcase_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') testcases = utils.load_testcases(testcase_file_path) testcase = testcases[0]["test"] success, _ = self.test_runner.run_test(testcase) self.assertTrue(success) def test_run_single_testcase_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) testcase = testcases[0]["test"] success, _ = self.test_runner.run_test(testcase) @@ -69,28 +69,28 @@ def test_run_single_testcase_fail(self): ) def test_run_testset_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results[0], [(True, []), (True, [])]) def test_run_testset_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) self.assertEqual(results, [(True, []), (True, [])]) def test_run_testsets_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) diff --git a/test/test_runner_v2.py b/tests/test_runner_v2.py similarity index 83% rename from test/test_runner_v2.py rename to tests/test_runner_v2.py index 5dc64a9af..b7af1f0d9 100644 --- a/test/test_runner_v2.py +++ b/tests/test_runner_v2.py @@ -1,7 +1,7 @@ import os import requests from ate import runner, exception, utils -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestRunnerV2(ApiServerUnittest): @@ -17,7 +17,7 @@ def clear_users(self): def test_run_single_testcase_yaml(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') + os.getcwd(), 'tests/data/simple_demo_auth_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) testcase = testcases[0]["test"] success, _ = self.test_runner.run_test(testcase) @@ -25,7 +25,7 @@ def test_run_single_testcase_yaml(self): def test_run_single_testcase_json(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') + os.getcwd(), 'tests/data/simple_demo_auth_hardcode.json') testcases = utils.load_testcases(testcase_file_path) testcase = testcases[0]["test"] success, _ = self.test_runner.run_test(testcase) @@ -33,7 +33,7 @@ def test_run_single_testcase_json(self): def test_run_testset_auth_yaml(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') + os.getcwd(), 'tests/data/simple_demo_auth_hardcode.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) @@ -41,7 +41,7 @@ def test_run_testset_auth_yaml(self): def test_run_testsets_auth_yaml(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_auth_hardcode.yml') + os.getcwd(), 'tests/data/simple_demo_auth_hardcode.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) @@ -49,7 +49,7 @@ def test_run_testsets_auth_yaml(self): def test_run_testset_auth_json(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') + os.getcwd(), 'tests/data/simple_demo_auth_hardcode.json') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) @@ -57,7 +57,7 @@ def test_run_testset_auth_json(self): def test_run_testsets_auth_json(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_auth_hardcode.json') + os.getcwd(), 'tests/data/simple_demo_auth_hardcode.json') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) @@ -65,7 +65,7 @@ def test_run_testsets_auth_json(self): def test_run_testcase_template_yaml(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/demo_template_separate.yml') + os.getcwd(), 'tests/data/demo_template_separate.yml') testcases = utils.load_testcases(testcase_file_path) success, _ = self.test_runner.run_test(testcases[0]["test"]) self.assertTrue(success) @@ -74,7 +74,7 @@ def test_run_testcase_template_yaml(self): def test_run_testset_template_yaml(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/demo_template_sets.yml') + os.getcwd(), 'tests/data/demo_template_sets.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) @@ -82,7 +82,7 @@ def test_run_testset_template_yaml(self): def test_run_testsets_template_yaml(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/demo_template_sets.yml') + os.getcwd(), 'tests/data/demo_template_sets.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) @@ -90,7 +90,7 @@ def test_run_testsets_template_yaml(self): def test_run_testset_template_import_functions(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/demo_import_functions.yml') + os.getcwd(), 'tests/data/demo_import_functions.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 2) @@ -98,7 +98,7 @@ def test_run_testset_template_import_functions(self): def test_run_testsets_template_import_functions(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/demo_import_functions.yml') + os.getcwd(), 'tests/data/demo_import_functions.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) diff --git a/test/test_task.py b/tests/test_task.py similarity index 78% rename from test/test_task.py rename to tests/test_task.py index 8e2e7b0e7..624ebcd7c 100644 --- a/test/test_task.py +++ b/tests/test_task.py @@ -1,7 +1,7 @@ import os import random import requests -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest from ate import task, utils class TestMain(ApiServerUnittest): @@ -14,7 +14,7 @@ def clear_users(self): return requests.delete(url) def test_create_suite(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') testsets = utils.load_testcases_by_path(testcase_file_path) suite = task.create_suite(testsets[0]) self.assertEqual(suite.countTestCases(), 2) @@ -22,7 +22,7 @@ def test_create_suite(self): self.assertIsInstance(testcase, task.ApiTestCase) def test_create_task(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') task_suite = task.create_task(testcase_file_path) self.assertEqual(task_suite.countTestCases(), 2) for suite in task_suite: diff --git a/test/test_testcase.py b/tests/test_testcase.py similarity index 100% rename from test/test_testcase.py rename to tests/test_testcase.py diff --git a/test/test_utils.py b/tests/test_utils.py similarity index 91% rename from test/test_utils.py rename to tests/test_utils.py index 44c89c1fc..f327fa069 100644 --- a/test/test_utils.py +++ b/tests/test_utils.py @@ -1,18 +1,18 @@ import os from ate import utils from ate import exception -from test.base import ApiServerUnittest +from tests.base import ApiServerUnittest class TestUtils(ApiServerUnittest): def test_load_testcases_bad_filepath(self): - testcase_file_path = os.path.join(os.getcwd(), 'test/data/demo') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo') with self.assertRaises(exception.ParamsError): utils.load_testcases(testcase_file_path) def test_load_json_testcases(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_no_auth.json') + os.getcwd(), 'tests/data/simple_demo_no_auth.json') testcases = utils.load_testcases(testcase_file_path) self.assertEqual(len(testcases), 2) testcase = testcases[0]["test"] @@ -23,7 +23,7 @@ def test_load_json_testcases(self): def test_load_yaml_testcases(self): testcase_file_path = os.path.join( - os.getcwd(), 'test/data/simple_demo_no_auth.yml') + os.getcwd(), 'tests/data/simple_demo_no_auth.yml') testcases = utils.load_testcases(testcase_file_path) self.assertEqual(len(testcases), 2) testcase = testcases[0]["test"] @@ -33,10 +33,10 @@ def test_load_yaml_testcases(self): self.assertIn('method', testcase['request']) def test_load_foler_files(self): - folder = os.path.join(os.getcwd(), 'test') + folder = os.path.join(os.getcwd(), 'tests') files = utils.load_foler_files(folder) - file1 = os.path.join(os.getcwd(), 'test', 'test_utils.py') - file2 = os.path.join(os.getcwd(), 'test', 'data', 'demo_binds.yml') + file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') + file2 = os.path.join(os.getcwd(), 'tests', 'data', 'demo_binds.yml') self.assertIn(file1, files) self.assertIn(file2, files) @@ -45,14 +45,14 @@ def test_load_testcases_by_path_files(self): # absolute file path path = os.path.join( - os.getcwd(), 'test/data/simple_demo_no_auth.json') + os.getcwd(), 'tests/data/simple_demo_no_auth.json') testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) self.assertEqual(len(testset_list[0]["testcases"]), 2) testsets_list.extend(testset_list) # relative file path - path = 'test/data/simple_demo_no_auth.yml' + path = 'tests/data/simple_demo_no_auth.yml' testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) self.assertEqual(len(testset_list[0]["testcases"]), 2) @@ -60,8 +60,8 @@ def test_load_testcases_by_path_files(self): # list/set container with file(s) path = [ - os.path.join(os.getcwd(), 'test/data/simple_demo_no_auth.json'), - 'test/data/simple_demo_no_auth.yml' + os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json'), + 'tests/data/simple_demo_no_auth.yml' ] testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 2) @@ -79,38 +79,38 @@ def test_load_testcases_by_path_files(self): def test_load_testcases_by_path_folder(self): # absolute folder path - path = os.path.join(os.getcwd(), 'test/data') + path = os.path.join(os.getcwd(), 'tests/data') testset_list_1 = utils.load_testcases_by_path(path) self.assertGreater(len(testset_list_1), 6) # relative folder path - path = 'test/data/' + path = 'tests/data/' testset_list_2 = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list_1), len(testset_list_2)) # list/set container with file(s) path = [ - os.path.join(os.getcwd(), 'test/data'), - 'test/data/' + os.path.join(os.getcwd(), 'tests/data'), + 'tests/data/' ] testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list_3), 2 * len(testset_list_1)) def test_load_testcases_by_path_not_exist(self): # absolute folder path - path = os.path.join(os.getcwd(), 'test/data_not_exist') + path = os.path.join(os.getcwd(), 'tests/data_not_exist') testset_list_1 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_1, []) # relative folder path - path = 'test/data_not_exist' + path = 'tests/data_not_exist' testset_list_2 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_2, []) # list/set container with file(s) path = [ - os.path.join(os.getcwd(), 'test/data_not_exist'), - 'test/data_not_exist/' + os.path.join(os.getcwd(), 'tests/data_not_exist'), + 'tests/data_not_exist/' ] testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_3, []) From da04c8793eb8c130b978278e55661ddda3c3e09d Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 20 Jul 2017 12:24:44 +0800 Subject: [PATCH 130/354] bugfix: variable name and function name could have _ --- ate/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index b75ddfe22..12e2619c5 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -16,8 +16,8 @@ string_type = str PYTHON_VERSION = 3 -variable_regexp = re.compile(r"^\$(\w+)$") -function_regexp = re.compile(r"^\$\{(\w+)\(([\$\w =,]*)\)\}$") +variable_regexp = re.compile(r"^\$([\w_]+)$") +function_regexp = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") def gen_random_string(str_len): return ''.join( From 4849a25ea295f28ecbccf57011bea4e5cedd7ebf Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 20 Jul 2017 22:43:18 +0800 Subject: [PATCH 131/354] refactor variable context: 1, variable context has two level, testset and testcase; 2, testset level variables can be used in whole test suite, while testcase level variables can only be used in testcase; 3, when variable binds with functions, the funtions will be called and the result will be set to the variable. --- ate/__init__.py | 2 +- ate/context.py | 98 ++++++++++++---------------- ate/response.py | 30 +++++---- ate/runner.py | 6 +- tests/data/demo_binds.yml | 8 +-- tests/data/demo_import_functions.yml | 7 +- tests/data/demo_template_sets.yml | 7 +- tests/test_context.py | 64 +++++++++--------- tests/test_response.py | 62 ++++++++++-------- tests/test_runner.py | 10 +-- 10 files changed, 143 insertions(+), 151 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 541f859dc..a9fdc5cfe 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' \ No newline at end of file +__version__ = '0.2.0' \ No newline at end of file diff --git a/ate/context.py b/ate/context.py index c3685f902..d11ac406d 100644 --- a/ate/context.py +++ b/ate/context.py @@ -21,10 +21,10 @@ class Context(object): """ def __init__(self): self.testset_config = {} - self.testset_shared_variables_mapping = dict() + self.testset_shared_variables_mapping = OrderedDict() self.testcase_config = {} - self.testcase_variables_mapping = dict() + self.testcase_variables_mapping = OrderedDict() self.init_context() def init_context(self, level='testset'): @@ -34,12 +34,12 @@ def init_context(self, level='testset'): """ if level == "testset": self.testset_config["functions"] = {} - self.testset_config["variables"] = OrderedDict() self.testset_config["request"] = {} self.testset_shared_variables_mapping = {} - self.testcase_config["functions"] = {} - self.testcase_config["variables"] = OrderedDict() + # testcase config shall inherit from testset configs, + # but can not change testset configs, that's why we use copy.deepcopy here. + self.testcase_config["functions"] = copy.deepcopy(self.testset_config["functions"]) self.testcase_config["request"] = {} self.testcase_variables_mapping = copy.deepcopy(self.testset_shared_variables_mapping) @@ -64,7 +64,7 @@ def bind_functions(self, function_binds, level="testcase"): function = eval(function) eval_function_binds[func_name] = function - self.__update_context_config(level, "functions", eval_function_binds) + self.__update_context_functions_config(level, eval_function_binds) def import_module_functions(self, modules, level="testcase"): """ import modules and bind all functions within the context @@ -73,11 +73,14 @@ def import_module_functions(self, modules, level="testcase"): for module_name in modules: imported = importlib.import_module(module_name) imported_functions_dict = dict(filter(is_function, vars(imported).items())) - self.__update_context_config(level, "functions", imported_functions_dict) + self.__update_context_functions_config(level, imported_functions_dict) - def register_variables_config(self, variable_binds, level="testcase"): - """ register variable configs - @param (list) variable_binds, variable can be value or custom function + def bind_variables(self, variable_binds, level="testcase"): + """ bind variables to testset context or current testcase context. + variables in testset context can be used in all testcases of current test suite. + + @param (list) variable_binds, variable can be value or custom function. + if value is function, it will be called and bind result to variable. e.g. [ {"TOKEN": "debugtalk"}, @@ -86,71 +89,56 @@ def register_variables_config(self, variable_binds, level="testcase"): {"md5": "${gen_md5($TOKEN, $json, $random)}"} ] """ + for variable_bind in variable_binds: + for variable_name, value in variable_bind.items(): + variable_evale_value = self.get_eval_value(value) + + if level == "testset": + self.testset_shared_variables_mapping[variable_name] = variable_evale_value + + self.testcase_variables_mapping[variable_name] = variable_evale_value + + def __update_context_functions_config(self, level, config_mapping): + """ + @param level: testset or testcase + @param config_type: functions + @param config_mapping: functions config mapping + """ if level == "testset": - for variable_bind in variable_binds: - self.testset_config["variables"].update(variable_bind) - elif level == "testcase": - self.testcase_config["variables"] = copy.deepcopy(self.testset_config["variables"]) - for variable_bind in variable_binds: - self.testcase_config["variables"].update(variable_bind) + self.testset_config["functions"].update(config_mapping) + + self.testcase_config["functions"].update(config_mapping) def register_request(self, request_dict, level="testcase"): - self.__update_context_config(level, "request", request_dict) + self.__update_context_request_config(level, request_dict) - def __update_context_config(self, level, config_type, config_mapping): + def __update_context_request_config(self, level, config_mapping): """ @param level: testset or testcase - @param config_type: functions, variables or request - @param config_mapping: functions config mapping or variables config mapping + @param config_type: request + @param config_mapping: request config mapping """ if level == "testset": - self.testset_config[config_type].update(config_mapping) - elif level == "testcase": - self.testcase_config[config_type].update(config_mapping) + self.testset_config["request"].update(config_mapping) - def get_parsed_request(self): - """ get parsed request, with each variable replaced by bind value. - testcase request shall inherit from testset request configs, - but can not change testset configs, that's why we use copy.deepcopy here. - """ - testcase_request_config = utils.deep_update_dict( + self.testcase_config["request"] = utils.deep_update_dict( copy.deepcopy(self.testset_config["request"]), - self.testcase_config["request"] + config_mapping ) + def get_parsed_request(self): + """ get parsed request, with each variable replaced by bind value. + """ parsed_request = testcase.parse_template( - testcase_request_config, - self._get_evaluated_testcase_variables() + self.testcase_config["request"], + self.testcase_variables_mapping ) return parsed_request - def bind_extracted_variables(self, variables_mapping): - """ bind extracted variable to current testcase context and testset context. - since extracted variable maybe used in current testcase and next testcases. - """ - self.testset_shared_variables_mapping.update(variables_mapping) - self.testcase_variables_mapping.update(variables_mapping) - def get_testcase_variables_mapping(self): return self.testcase_variables_mapping - def _get_evaluated_testcase_variables(self): - """ variables in variables_config will be evaluated each time - """ - testcase_functions_config = copy.deepcopy(self.testset_config["functions"]) - testcase_functions_config.update(self.testcase_config["functions"]) - self.testcase_config["functions"] = testcase_functions_config - - testcase_variables_config = copy.deepcopy(self.testset_config["variables"]) - testcase_variables_config.update(self.testcase_config["variables"]) - self.testcase_config["variables"] = testcase_variables_config - - for var_name, var_value in self.testcase_config["variables"].items(): - self.testcase_variables_mapping[var_name] = self.get_eval_value(var_value) - - return self.testcase_variables_mapping - def get_eval_value(self, data): """ evaluate data recursively, each variable in data will be evaluated. """ diff --git a/ate/response.py b/ate/response.py index a5d73f93a..f7ba8dc78 100644 --- a/ate/response.py +++ b/ate/response.py @@ -54,23 +54,27 @@ def extract_field(self, field, delimiter='.'): def extract_response(self, extract_binds): """ extract content from requests.Response - @param (dict) extract_binds - { - "resp_status_code": "status_code", - "resp_headers_content_type": "headers.content-type", - "resp_content": "content", - "resp_content_person_first_name": "content.person.name.first_name" - } + @param (list) extract_binds + [ + {"resp_status_code": "status_code"}, + {"resp_headers_content_type": "headers.content-type"}, + {"resp_content": "content"}, + {"resp_content_person_first_name": "content.person.name.first_name"} + ] + @return (list) variable binds list """ - extracted_variables_mapping = {} + extracted_variables_mapping_list = [] - for key, field in extract_binds.items(): - if not isinstance(field, utils.string_type): - raise exception.ParamsError("invalid extract_binds!") + for extract_bind in extract_binds: + for key, field in extract_bind.items(): + if not isinstance(field, utils.string_type): + raise exception.ParamsError("invalid extract_binds!") - extracted_variables_mapping[key] = self.extract_field(field) + extracted_variables_mapping_list.append( + {key: self.extract_field(field)} + ) - return extracted_variables_mapping + return extracted_variables_mapping_list def validate(self, validators, variables_mapping): """ Bind named validators to value within the context. diff --git a/ate/runner.py b/ate/runner.py index 072c5b9e4..46b935ffe 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -45,7 +45,7 @@ def init_config(self, config_dict, level): self.context.import_module_functions(module_functions, level) variable_binds = config_dict.get('variable_binds', []) - self.context.register_variables_config(variable_binds, level) + self.context.bind_variables(variable_binds, level) request_config = config_dict.get('request', {}) if level == "testset": @@ -93,8 +93,8 @@ def run_test(self, testcase): resp_obj = response.ResponseObject(resp) extract_binds = testcase.get("extract_binds", {}) - extracted_variables_mapping = resp_obj.extract_response(extract_binds) - self.context.bind_extracted_variables(extracted_variables_mapping) + extracted_variables_mapping_list = resp_obj.extract_response(extract_binds) + self.context.bind_variables(extracted_variables_mapping_list, level="testset") validators = testcase.get("validators", []) diff_content_list = resp_obj.validate( diff --git a/tests/data/demo_binds.yml b/tests/data/demo_binds.yml index 180d9dae3..f1e5327e2 100644 --- a/tests/data/demo_binds.yml +++ b/tests/data/demo_binds.yml @@ -1,10 +1,4 @@ -register_variables: - variable_binds: - - TOKEN: "debugtalk" - - var: [1, 2, 3] - - data: {'name': 'user', 'password': '123456'} - -register_template_variables: +bind_variables: variable_binds: - TOKEN: "debugtalk" - token: $TOKEN diff --git a/tests/data/demo_import_functions.yml b/tests/data/demo_import_functions.yml index 89a488890..bc9a50a4f 100644 --- a/tests/data/demo_import_functions.yml +++ b/tests/data/demo_import_functions.yml @@ -4,14 +4,13 @@ - tests.data.custom_functions variable_binds: - TOKEN: debugtalk - - json: {} - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $json, $random)} - test: name: create user which does not exist variable_binds: - json: {"name": "user", "password": "123456"} + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $json, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST @@ -28,6 +27,8 @@ name: create user which does not exist variable_binds: - json: {"name": "user", "password": "123456"} + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $json, $random)} request: url: http://127.0.0.1:5000/api/users/1000 method: POST diff --git a/tests/data/demo_template_sets.yml b/tests/data/demo_template_sets.yml index 4296541f2..9b9415ae5 100644 --- a/tests/data/demo_template_sets.yml +++ b/tests/data/demo_template_sets.yml @@ -9,9 +9,6 @@ gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" variable_binds: - TOKEN: debugtalk - - data: "" - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $data, $random)} request: base_url: http://127.0.0.1:5000 @@ -19,6 +16,8 @@ name: create user which does not exist variable_binds: - data: '{"name": "user", "password": "123456"}' + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $data, $random)} request: url: /api/users/1000 method: POST @@ -35,6 +34,8 @@ name: create user which does exist variable_binds: - data: '{"name": "user", "password": "123456"}' + - random: ${gen_random_string(5)} + - authorization: ${gen_md5($TOKEN, $data, $random)} - expected_status_code: 500 request: url: /api/users/1000 diff --git a/tests/test_context.py b/tests/test_context.py index 414364a2c..50697ccda 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -12,51 +12,51 @@ def setUp(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_binds.yml') self.testcases = utils.load_testcases(testcase_file_path) - def test_context_register_variables(self): + def test_context_bind_testset_variables(self): # testcase in JSON format testcase1 = { "variable_binds": [ - {"TOKEN": "debugtalk"}, - {"var": [1, 2, 3]}, - {"data": {'name': 'user', 'password': '123456'}} + {"GLOBAL_TOKEN": "debugtalk"}, + {"token": "$GLOBAL_TOKEN"} ] } # testcase in YAML format - testcase2 = self.testcases["register_variables"] + testcase2 = self.testcases["bind_variables"] for testcase in [testcase1, testcase2]: variable_binds = testcase['variable_binds'] - self.context.register_variables_config(variable_binds) - - context_variables = self.context._get_evaluated_testcase_variables() - self.assertIn("TOKEN", context_variables) - self.assertEqual(context_variables["TOKEN"], "debugtalk") - self.assertIn("var", context_variables) - self.assertEqual(context_variables["var"], [1, 2, 3]) - self.assertIn("data", context_variables) - self.assertEqual( - context_variables["data"], - {'name': 'user', 'password': '123456'} - ) - - def test_context_register_template_variables(self): + self.context.bind_variables(variable_binds, level="testset") + + testset_variables = self.context.testset_shared_variables_mapping + testcase_variables = self.context.get_testcase_variables_mapping() + self.assertIn("GLOBAL_TOKEN", testset_variables) + self.assertIn("GLOBAL_TOKEN", testcase_variables) + self.assertEqual(testset_variables["GLOBAL_TOKEN"], "debugtalk") + self.assertIn("token", testset_variables) + self.assertIn("token", testcase_variables) + self.assertEqual(testset_variables["token"], "debugtalk") + + def test_context_bind_testcase_variables(self): testcase1 = { "variable_binds": [ {"GLOBAL_TOKEN": "debugtalk"}, {"token": "$GLOBAL_TOKEN"} ] } - testcase2 = self.testcases["register_template_variables"] + testcase2 = self.testcases["bind_variables"] for testcase in [testcase1, testcase2]: variable_binds = testcase['variable_binds'] - self.context.register_variables_config(variable_binds) + self.context.bind_variables(variable_binds) - context_variables = self.context._get_evaluated_testcase_variables() - self.assertIn("GLOBAL_TOKEN", context_variables) - self.assertEqual(context_variables["GLOBAL_TOKEN"], "debugtalk") - self.assertIn("token", context_variables) - self.assertEqual(context_variables["token"], "debugtalk") + testset_variables = self.context.testset_shared_variables_mapping + testcase_variables = self.context.get_testcase_variables_mapping() + self.assertNotIn("GLOBAL_TOKEN", testset_variables) + self.assertIn("GLOBAL_TOKEN", testcase_variables) + self.assertEqual(testcase_variables["GLOBAL_TOKEN"], "debugtalk") + self.assertNotIn("token", testset_variables) + self.assertIn("token", testcase_variables) + self.assertEqual(testcase_variables["token"], "debugtalk") def test_context_bind_lambda_functions(self): testcase1 = { @@ -76,9 +76,9 @@ def test_context_bind_lambda_functions(self): self.context.bind_functions(function_binds) variable_binds = testcase['variable_binds'] - self.context.register_variables_config(variable_binds) + self.context.bind_variables(variable_binds) - context_variables = self.context._get_evaluated_testcase_variables() + context_variables = self.context.get_testcase_variables_mapping() self.assertIn("add1", context_variables) self.assertEqual(context_variables["add1"], 3) self.assertIn("sum2nums", context_variables) @@ -108,8 +108,8 @@ def test_context_bind_lambda_functions_with_import(self): self.context.bind_functions(function_binds) variable_binds = testcase['variable_binds'] - self.context.register_variables_config(variable_binds) - context_variables = self.context._get_evaluated_testcase_variables() + self.context.bind_variables(variable_binds) + context_variables = self.context.get_testcase_variables_mapping() self.assertIn("TOKEN", context_variables) TOKEN = context_variables["TOKEN"] @@ -142,8 +142,8 @@ def test_import_module_functions(self): self.context.import_module_functions(module_functions) variable_binds = testcase['variable_binds'] - self.context.register_variables_config(variable_binds) - context_variables = self.context._get_evaluated_testcase_variables() + self.context.bind_variables(variable_binds) + context_variables = self.context.get_testcase_variables_mapping() self.assertIn("TOKEN", context_variables) TOKEN = context_variables["TOKEN"] diff --git a/tests/test_response.py b/tests/test_response.py index 01fde5d43..28316c178 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -49,40 +49,44 @@ def test_extract_response_json(self): } ) - extract_binds = { - "resp_status_code": "status_code", - "resp_headers_content_type": "headers.content-type", - "resp_content_body_success": "body.success", - "resp_content_content_success": "content.success", - "resp_content_text_success": "text.success", - "resp_content_person_first_name": "content.person.name.first_name", - "resp_content_cities_1": "content.person.cities.1" - } + extract_binds_list = [ + {"resp_status_code": "status_code"}, + {"resp_headers_content_type": "headers.content-type"}, + {"resp_content_body_success": "body.success"}, + {"resp_content_content_success": "content.success"}, + {"resp_content_text_success": "text.success"}, + {"resp_content_person_first_name": "content.person.name.first_name"}, + {"resp_content_cities_1": "content.person.cities.1"} + ] resp_obj = response.ResponseObject(resp) - extract_binds_dict = resp_obj.extract_response(extract_binds) + extract_binds_dict_list = resp_obj.extract_response(extract_binds_list) self.assertEqual( - extract_binds_dict["resp_status_code"], + extract_binds_dict_list[0]["resp_status_code"], 200 ) self.assertEqual( - extract_binds_dict["resp_headers_content_type"], + extract_binds_dict_list[1]["resp_headers_content_type"], "application/json" ) self.assertEqual( - extract_binds_dict["resp_content_content_success"], + extract_binds_dict_list[2]["resp_content_body_success"], + False + ) + self.assertEqual( + extract_binds_dict_list[3]["resp_content_content_success"], False ) self.assertEqual( - extract_binds_dict["resp_content_text_success"], + extract_binds_dict_list[4]["resp_content_text_success"], False ) self.assertEqual( - extract_binds_dict["resp_content_person_first_name"], + extract_binds_dict_list[5]["resp_content_person_first_name"], "Leo" ) self.assertEqual( - extract_binds_dict["resp_content_cities_1"], + extract_binds_dict_list[6]["resp_content_cities_1"], "Shenzhen" ) @@ -107,21 +111,21 @@ def test_extract_response_fail(self): } ) - extract_binds = { - "resp_content_dict_key_error": "content.not_exist" - } + extract_binds_list = [ + {"resp_content_dict_key_error": "content.not_exist"} + ] resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - resp_obj.extract_response(extract_binds) + resp_obj.extract_response(extract_binds_list) - extract_binds = { - "resp_content_list_index_error": "content.person.cities.3" - } + extract_binds_list = [ + {"resp_content_list_index_error": "content.person.cities.3"} + ] resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): - resp_obj.extract_response(extract_binds) + resp_obj.extract_response(extract_binds_list) def test_extract_response_json_string(self): resp = requests.post( @@ -134,14 +138,14 @@ def test_extract_response_json_string(self): } ) - extract_binds = { - "resp_content_body": "content" - } + extract_binds_list = [ + {"resp_content_body": "content"} + ] resp_obj = response.ResponseObject(resp) - extract_binds_dict = resp_obj.extract_response(extract_binds) + extract_binds_dict_list = resp_obj.extract_response(extract_binds_list) self.assertEqual( - extract_binds_dict["resp_content_body"], + extract_binds_dict_list[0]["resp_content_body"], "abc" ) diff --git a/tests/test_runner.py b/tests/test_runner.py index ab5506683..8556a8c13 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -41,11 +41,11 @@ def test_run_single_testcase_fail(self): "password": "123456" } }, - "extract_binds": { - "resp_status_code": "status_code", - "resp_body_success": "content.success", - "resp_headers_contenttype": "headers.content-type" - }, + "extract_binds": [ + {"resp_status_code": "status_code"}, + {"resp_body_success": "content.success"}, + {"resp_headers_contenttype": "headers.content-type"} + ], "validators": [ {"check": "resp_status_code", "comparator": "eq", "expected": 200}, {"check": "resp_body_success", "comparator": "eq", "expected": False}, From 68a00bcc0d0c65adf5c121999a98fa34308d18bf Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 21 Jul 2017 09:26:33 +0800 Subject: [PATCH 132/354] change context variable name --- ate/context.py | 25 +++++++++++-------------- tests/test_context.py | 4 ++-- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/ate/context.py b/ate/context.py index d11ac406d..b7fbb8072 100644 --- a/ate/context.py +++ b/ate/context.py @@ -20,10 +20,7 @@ class Context(object): context has two levels, testset and testcase. """ def __init__(self): - self.testset_config = {} self.testset_shared_variables_mapping = OrderedDict() - - self.testcase_config = {} self.testcase_variables_mapping = OrderedDict() self.init_context() @@ -33,14 +30,14 @@ def init_context(self, level='testset'): testcase level context initializes when each testcase starts. """ if level == "testset": - self.testset_config["functions"] = {} - self.testset_config["request"] = {} + self.testset_functions_config = {} + self.testset_request_config = {} self.testset_shared_variables_mapping = {} # testcase config shall inherit from testset configs, # but can not change testset configs, that's why we use copy.deepcopy here. - self.testcase_config["functions"] = copy.deepcopy(self.testset_config["functions"]) - self.testcase_config["request"] = {} + self.testcase_functions_config = copy.deepcopy(self.testset_functions_config) + self.testcase_request_config = {} self.testcase_variables_mapping = copy.deepcopy(self.testset_shared_variables_mapping) def import_requires(self, modules): @@ -105,9 +102,9 @@ def __update_context_functions_config(self, level, config_mapping): @param config_mapping: functions config mapping """ if level == "testset": - self.testset_config["functions"].update(config_mapping) + self.testset_functions_config.update(config_mapping) - self.testcase_config["functions"].update(config_mapping) + self.testcase_functions_config.update(config_mapping) def register_request(self, request_dict, level="testcase"): self.__update_context_request_config(level, request_dict) @@ -119,10 +116,10 @@ def __update_context_request_config(self, level, config_mapping): @param config_mapping: request config mapping """ if level == "testset": - self.testset_config["request"].update(config_mapping) + self.testset_request_config.update(config_mapping) - self.testcase_config["request"] = utils.deep_update_dict( - copy.deepcopy(self.testset_config["request"]), + self.testcase_request_config = utils.deep_update_dict( + copy.deepcopy(self.testset_request_config), config_mapping ) @@ -130,7 +127,7 @@ def get_parsed_request(self): """ get parsed request, with each variable replaced by bind value. """ parsed_request = testcase.parse_template( - self.testcase_config["request"], + self.testcase_request_config, self.testcase_variables_mapping ) @@ -174,6 +171,6 @@ def get_eval_value(self, data): kwargs = fuction_meta.get('kwargs', {}) args = self.get_eval_value(args) kwargs = self.get_eval_value(kwargs) - return self.testcase_config["functions"][func_name](*args, **kwargs) + return self.testcase_functions_config[func_name](*args, **kwargs) else: return data diff --git a/tests/test_context.py b/tests/test_context.py index 50697ccda..d7ae5d96a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -206,13 +206,13 @@ def test_get_eval_value(self): ) import random, string - self.context.testcase_config["functions"]["gen_random_string"] = \ + self.context.testcase_functions_config["gen_random_string"] = \ lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ for _ in range(str_len)) result = self.context.get_eval_value("${gen_random_string(5)}") self.assertEqual(len(result), 5) add_two_nums = lambda a, b=1: a + b - self.context.testcase_config["functions"]["add_two_nums"] = add_two_nums + self.context.testcase_functions_config["add_two_nums"] = add_two_nums self.assertEqual(self.context.get_eval_value("${add_two_nums(1)}"), 2) self.assertEqual(self.context.get_eval_value("${add_two_nums(1, 2)}"), 3) From 5e59951e7387966dfebab4bb52825ab8a5d1f686 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 21 Jul 2017 09:35:31 +0800 Subject: [PATCH 133/354] bugfix: testset_shared_variables_mapping should be OrderedDict --- ate/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/context.py b/ate/context.py index b7fbb8072..e7efa9a95 100644 --- a/ate/context.py +++ b/ate/context.py @@ -32,7 +32,7 @@ def init_context(self, level='testset'): if level == "testset": self.testset_functions_config = {} self.testset_request_config = {} - self.testset_shared_variables_mapping = {} + self.testset_shared_variables_mapping = OrderedDict() # testcase config shall inherit from testset configs, # but can not change testset configs, that's why we use copy.deepcopy here. From 16307e2ebe7a274e2c8fcfb51e9278d712b10c2b Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 22 Jul 2017 15:14:52 +0800 Subject: [PATCH 134/354] refactor mock server: 1, remove authentication switcher; 2, change authentication method: from verify request data's md5 to get token at the beginning. --- README.md | 63 +++---- ate/__init__.py | 2 +- ate/testcase.py | 3 +- ate/utils.py | 30 +--- tests/api_server.py | 87 ++++++---- tests/base.py | 41 +++-- tests/data/custom_functions.py | 44 +++-- tests/data/demo_import_functions.yml | 42 ----- tests/data/demo_template_separate.yml | 52 ------ tests/data/demo_template_sets.yml | 50 ------ tests/data/demo_testset_hardcode.json | 74 ++++++++ ...hardcode.yml => demo_testset_hardcode.yml} | 27 ++- ...demo_testset_template_import_functions.yml | 64 +++++++ ...demo_testset_template_lambda_functions.yml | 74 ++++++++ tests/data/demo_testset_variables.yml | 62 +++++++ tests/data/simple_demo_auth_hardcode.json | 46 ----- tests/data/simple_demo_no_auth.json | 43 ----- tests/data/simple_demo_no_auth.yml | 29 ---- tests/test_apiserver.py | 53 +++--- tests/test_apiserver_v2.py | 158 ------------------ tests/test_client.py | 15 +- tests/test_runner.py | 125 ++++++++------ tests/test_runner_v2.py | 105 ------------ tests/test_task.py | 21 ++- tests/test_utils.py | 26 +-- 25 files changed, 582 insertions(+), 754 deletions(-) delete mode 100644 tests/data/demo_import_functions.yml delete mode 100644 tests/data/demo_template_separate.yml delete mode 100644 tests/data/demo_template_sets.yml create mode 100644 tests/data/demo_testset_hardcode.json rename tests/data/{simple_demo_auth_hardcode.yml => demo_testset_hardcode.yml} (53%) create mode 100644 tests/data/demo_testset_template_import_functions.yml create mode 100644 tests/data/demo_testset_template_lambda_functions.yml create mode 100644 tests/data/demo_testset_variables.yml delete mode 100644 tests/data/simple_demo_auth_hardcode.json delete mode 100644 tests/data/simple_demo_no_auth.json delete mode 100644 tests/data/simple_demo_no_auth.yml delete mode 100644 tests/test_apiserver_v2.py delete mode 100644 tests/test_runner_v2.py diff --git a/README.md b/README.md index b9bc95a7c..d7a7f8fce 100644 --- a/README.md +++ b/README.md @@ -58,56 +58,57 @@ optional arguments: 如下是一个典型的接口测试用例示例。具体的编写方式请阅读详细文档。 -```python +```yaml - config: name: "create user testsets." - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" + import_module_functions: + - tests.data.custom_functions variable_binds: - - TOKEN: debugtalk - - data: "" - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $data, $random)} + - user_agent: 'iOS/10.3' + - device_sn: ${gen_random_string(15)} + - os_platform: 'ios' + - app_version: '2.8.6' + - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: base_url: http://127.0.0.1:5000 + headers: + Content-Type: application/json + device_sn: $device_sn - test: - name: create user which does not exist - variable_binds: - - data: '{"name": "user", "password": "123456"}' + name: get token request: - url: /api/users/1000 + url: /api/get-token method: POST headers: - Content-Type: application/json - authorization: $authorization - random: $random - data: $data + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: $sign + extract_binds: + - token: content.token validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} - test: - name: create user which does exist + name: create user which does not exist variable_binds: - - data: '{"name": "user", "password": "123456"}' - - expected_status_code: 500 + - user_name: "user1" + - user_password: "123456" request: url: /api/users/1000 method: POST headers: - Content-Type: application/json - authorization: $authorization - random: $random - data: $data + token: $token + json: + name: $user_name + password: $user_password validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} ``` ## 运行测试用例 diff --git a/ate/__init__.py b/ate/__init__.py index a9fdc5cfe..fb13a3556 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.2.0' \ No newline at end of file +__version__ = '0.2.1' \ No newline at end of file diff --git a/ate/testcase.py b/ate/testcase.py index 3ba54bc71..c4c58769e 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,5 +1,6 @@ import re from ate.exception import ParamsError +from ate.utils import string_type def parse_content_with_variables(content, variables_binds): @@ -58,7 +59,7 @@ def parse_template(testcase_template, variables_binds): def substitute(content): """ substitute content recursively, each variable will be replaced with bind value. """ - if isinstance(content, str): + if isinstance(content, string_type): return parse_content_with_variables(content, variables_binds) if isinstance(content, list): diff --git a/ate/utils.py b/ate/utils.py index 12e2619c5..4e915bff6 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,12 +1,13 @@ import ast import hashlib +import hmac import json import os.path import random import re import string -import yaml +import yaml from ate.exception import ParamsError try: @@ -16,6 +17,7 @@ string_type = str PYTHON_VERSION = 3 +SECRET_KEY = "DebugTalk" variable_regexp = re.compile(r"^\$([\w_]+)$") function_regexp = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") @@ -26,27 +28,11 @@ def gen_random_string(str_len): def gen_md5(*str_args): return hashlib.md5("".join(str_args).encode('utf-8')).hexdigest() -def handle_req_data(data): - - if PYTHON_VERSION == 3 and isinstance(data, bytes): - # In Python3, convert bytes to str - data = data.decode('utf-8') - - if not data: - return data - - if isinstance(data, str): - # check if data in str can be converted to dict - try: - data = json.loads(data) - except ValueError: - pass - - if isinstance(data, dict): - # sort data in dict with keys, then convert to str - data = json.dumps(data, sort_keys=True) - - return data +def get_sign(*args): + content = ''.join(args).encode('ascii') + sign_key = SECRET_KEY.encode('ascii') + sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() + return sign def load_yaml_file(yaml_file): with open(yaml_file, 'r+') as stream: diff --git a/tests/api_server.py b/tests/api_server.py index 121af4625..98a090327 100644 --- a/tests/api_server.py +++ b/tests/api_server.py @@ -1,9 +1,10 @@ import hashlib +import hmac import json from functools import wraps -from flask import Flask, make_response, request from ate import utils +from flask import Flask, make_response, request app = Flask(__name__) @@ -22,25 +23,32 @@ """ users_dict = {} -AUTHENTICATION = False -TOKEN = "debugtalk" +""" storage all token data +data structure: + token_dict = { + 'device_sn1': 'token1', + 'device_sn2': 'token1' + } +""" +token_dict = {} def validate_request(func): @wraps(func) - def wrapper(*args, **kwds): - if not AUTHENTICATION: - return func(*args, **kwds) - - try: - req_headers = request.headers - req_authorization = req_headers['Authorization'] - random_str = req_headers['Random'] - data = utils.handle_req_data(request.data) - authorization = utils.gen_md5(TOKEN, data, random_str) - assert authorization == req_authorization - return func(*args, **kwds) - except (KeyError, AssertionError): + def wrapper(*args, **kwargs): + device_sn = request.headers.get('device_sn', "") + token = request.headers.get('token', "") + + if not device_sn or not token: + result = { + 'success': False, + 'msg': "device_sn or token is null." + } + response = make_response(json.dumps(result), 401) + response.headers["Content-Type"] = "application/json" + return response + + if token_dict[device_sn] != token: result = { 'success': False, 'msg': "Authorization failed!" @@ -49,16 +57,46 @@ def wrapper(*args, **kwds): response.headers["Content-Type"] = "application/json" return response + return func(*args, **kwargs) + return wrapper @app.route('/') -@validate_request def index(): return "Hello World!" +@app.route('/api/get-token', methods=['POST']) +def get_token(): + user_agent = request.headers.get('User-Agent', "") + device_sn = request.headers.get('device_sn', "") + os_platform = request.headers.get('os_platform', "") + app_version = request.headers.get('app_version', "") + data = request.get_json() + sign = data.get('sign', "") + + expected_sign = utils.get_sign(user_agent, device_sn, os_platform, app_version) + + if expected_sign != sign: + result = { + 'success': False, + 'msg': "Authorization failed!" + } + response = make_response(json.dumps(result), 403) + else: + token = utils.gen_random_string(16) + token_dict[device_sn] = token + + result = { + 'success': True, + 'token': token + } + response = make_response(json.dumps(result)) + + response.headers["Content-Type"] = "application/json" + return response + @app.route('/customize-response', methods=['POST']) -@validate_request def get_customized_response(): expected_resp_json = request.get_json() status_code = expected_resp_json.get('status_code', 200) @@ -71,17 +109,6 @@ def get_customized_response(): return response -@app.route('/api/token') -@validate_request -def get_token(): - result = { - 'success': True, - 'token': utils.gen_random_string(8) - } - response = make_response(json.dumps(result)) - response.headers["Content-Type"] = "application/json" - return response - @app.route('/api/users') @validate_request def get_users(): @@ -95,7 +122,7 @@ def get_users(): response.headers["Content-Type"] = "application/json" return response -@app.route('/api/users', methods=['DELETE']) +@app.route('/api/reset-all') @validate_request def clear_users(): users_dict.clear() diff --git a/tests/base.py b/tests/base.py index a3fdc8e13..947c4663b 100644 --- a/tests/base.py +++ b/tests/base.py @@ -2,6 +2,7 @@ import time import unittest +import requests from ate import utils from tests import api_server @@ -10,29 +11,49 @@ class ApiServerUnittest(unittest.TestCase): """ Test case class that sets up an HTTP server which can be used within the tests """ - authentication = False - @classmethod def setUpClass(cls): - api_server.AUTHENTICATION = cls.authentication + cls.host = "http://127.0.0.1:5000" cls.api_server_process = multiprocessing.Process( target=api_server.app.run ) cls.api_server_process.start() time.sleep(0.1) + cls.api_client = requests.Session() @classmethod def tearDownClass(cls): cls.api_server_process.terminate() - def prepare_headers(self, data=""): - token = api_server.TOKEN - data = utils.handle_req_data(data) - random_str = utils.gen_random_string(5) - authorization = utils.gen_md5(token, data, random_str) + def get_token(self, user_agent, device_sn, os_platform, app_version): + url = "%s/api/get-token" % self.host + headers = { + 'Content-Type': 'application/json', + 'User-Agent': user_agent, + 'device_sn': device_sn, + 'os_platform': os_platform, + 'app_version': app_version + } + data = { + 'sign': utils.get_sign(user_agent, device_sn, os_platform, app_version) + } + + resp = self.api_client.post(url, json=data, headers=headers) + resp_json = resp.json() + self.assertTrue(resp_json["success"]) + self.assertIn("token", resp_json) + self.assertEqual(len(resp_json["token"]), 16) + return resp_json["token"] + + def get_authenticated_headers(self): + user_agent = 'iOS/10.3' + device_sn = utils.gen_random_string(15) + os_platform = 'ios' + app_version = '2.8.6' + token = self.get_token(user_agent, device_sn, os_platform, app_version) headers = { - 'authorization': authorization, - 'random': random_str + 'device_sn': device_sn, + 'token': token } return headers diff --git a/tests/data/custom_functions.py b/tests/data/custom_functions.py index 4f9d7d567..1cd6de6fa 100644 --- a/tests/data/custom_functions.py +++ b/tests/data/custom_functions.py @@ -1,4 +1,5 @@ import hashlib +import hmac import json import random import string @@ -13,36 +14,33 @@ PYTHON_VERSION = 3 import urllib.parse as urllib +SECRET_KEY = "DebugTalk" def gen_random_string(str_len): - return ''.join( - random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) + random_char_list = [] + for _ in range(str_len): + random_char = random.choice(string.ascii_letters + string.digits) + random_char_list.append(random_char) -def gen_md5(*args): - args = [handle_req_data(item) for item in args] - return hashlib.md5("".join(args).encode('utf-8')).hexdigest() - -def handle_req_data(data): + random_string = ''.join(random_char_list) + return random_string - if PYTHON_VERSION == 3 and isinstance(data, bytes): - # In Python3, convert bytes to str - data = data.decode('utf-8') +gen_random_string_lambda = lambda str_len: ''.join( + random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) - if not data: - return data +def get_sign(*args): + content = ''.join(args).encode('ascii') + sign_key = SECRET_KEY.encode('ascii') + sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() + return sign - if isinstance(data, str): - # check if data in str can be converted to dict - try: - data = json.loads(data) - except ValueError: - pass +get_sign_lambda = lambda *args: hmac.new( + 'DebugTalk'.encode('ascii'), + ''.join(args).encode('ascii'), + hashlib.sha1).hexdigest() - if isinstance(data, dict): - # sort data in dict with keys, then convert to str - data = json.dumps(data, sort_keys=True) - - return data +def gen_md5(*args): + return hashlib.md5("".join(args).encode('utf-8')).hexdigest() def gen_urlencode_str(**kargs): urlencoded_str = "" diff --git a/tests/data/demo_import_functions.yml b/tests/data/demo_import_functions.yml deleted file mode 100644 index bc9a50a4f..000000000 --- a/tests/data/demo_import_functions.yml +++ /dev/null @@ -1,42 +0,0 @@ -- config: - name: "create user testsets." - import_module_functions: - - tests.data.custom_functions - variable_binds: - - TOKEN: debugtalk - -- test: - name: create user which does not exist - variable_binds: - - json: {"name": "user", "password": "123456"} - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $json, $random)} - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: $authorization - random: $random - json: $json - validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} - -- test: - name: create user which does not exist - variable_binds: - - json: {"name": "user", "password": "123456"} - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $json, $random)} - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: $authorization - random: $random - json: $json - validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_template_separate.yml b/tests/data/demo_template_separate.yml deleted file mode 100644 index 75e52d84b..000000000 --- a/tests/data/demo_template_separate.yml +++ /dev/null @@ -1,52 +0,0 @@ - -- test: - name: create user which does not exist - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: - - TOKEN: debugtalk - - random: ${gen_random_string(5)} - - data: '{"name": "user", "password": "123456"}' - - authorization: ${gen_md5($TOKEN, $data, $random)} - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: $authorization - random: $random - data: $data - validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} - -- test: - name: create user which does exist - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: - - TOKEN: debugtalk - - random: ${gen_random_string(5)} - - data: '{"name": "user", "password": "123456"}' - - authorization: ${gen_md5($TOKEN, $data, $random)} - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: $authorization - random: $random - data: $data - validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_template_sets.yml b/tests/data/demo_template_sets.yml deleted file mode 100644 index 9b9415ae5..000000000 --- a/tests/data/demo_template_sets.yml +++ /dev/null @@ -1,50 +0,0 @@ -- config: - name: "create user testsets." - requires: - - random - - string - - hashlib - function_binds: - gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" - gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: - - TOKEN: debugtalk - request: - base_url: http://127.0.0.1:5000 - -- test: - name: create user which does not exist - variable_binds: - - data: '{"name": "user", "password": "123456"}' - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $data, $random)} - request: - url: /api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: $authorization - random: $random - data: $data - validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} - -- test: - name: create user which does exist - variable_binds: - - data: '{"name": "user", "password": "123456"}' - - random: ${gen_random_string(5)} - - authorization: ${gen_md5($TOKEN, $data, $random)} - - expected_status_code: 500 - request: - url: /api/users/1000 - method: POST - headers: - Content-Type: application/json - authorization: $authorization - random: $random - data: $data - validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json new file mode 100644 index 000000000..75c84f283 --- /dev/null +++ b/tests/data/demo_testset_hardcode.json @@ -0,0 +1,74 @@ +[ + { + "test": { + "name": "get token", + "request": { + "url": "http://127.0.0.1:5000/api/get-token", + "method": "POST", + "headers": { + "content-type": "application/json", + "user_agent": "iOS/10.3", + "device_sn": "HZfFBh6tU59EdXJ", + "os_platform": "ios", + "app_version": "2.8.6" + }, + "json": { + "sign": "f1219719911caae89ccc301679857ebfda115ca2" + } + }, + "extract_binds": [ + { + "token": "content.token" + } + ], + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 200}, + {"check": "content.token", "comparator": "len_eq", "expected": 16} + ] + } + }, + { + "test": { + "name": "create user which does not exist", + "request": { + "url": "http://127.0.0.1:5000/api/users/2000", + "method": "POST", + "headers": { + "content-type": "application/json", + "device_sn": "HZfFBh6tU59EdXJ", + "token": "$token" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 201}, + {"check": "content.success", "comparator": "eq", "expected": true} + ] + } + }, + { + "test": { + "name": "create user which existed", + "request": { + "url": "http://127.0.0.1:5000/api/users/2000", + "method": "POST", + "headers": { + "content-type": "application/json", + "device_sn": "HZfFBh6tU59EdXJ", + "token": "$token" + }, + "json": { + "name": "user1", + "password": "123456" + } + }, + "validators": [ + {"check": "status_code", "comparator": "eq", "expected": 500}, + {"check": "content.success", "comparator": "eq", "expected": false} + ] + } + } +] \ No newline at end of file diff --git a/tests/data/simple_demo_auth_hardcode.yml b/tests/data/demo_testset_hardcode.yml similarity index 53% rename from tests/data/simple_demo_auth_hardcode.yml rename to tests/data/demo_testset_hardcode.yml index 21f432dbe..204fab590 100644 --- a/tests/data/simple_demo_auth_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -1,3 +1,22 @@ +- test: + name: get token + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + Content-Type: application/json + user_agent: 'iOS/10.3' + device_sn: 'HZfFBh6tU59EdXJ' + os_platform: 'ios' + app_version: '2.8.6' + json: + sign: f1219719911caae89ccc301679857ebfda115ca2 + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - test: name: create user which does not exist request: @@ -5,8 +24,8 @@ method: POST headers: Content-Type: application/json - authorization: a83de0ff8d2e896dbd8efb81ba14e17d - random: A2dEx + device_sn: 'HZfFBh6tU59EdXJ' + token: $token json: name: "user1" password: "123456" @@ -21,8 +40,8 @@ method: POST headers: Content-Type: application/json - authorization: a83de0ff8d2e896dbd8efb81ba14e17d - random: A2dEx + device_sn: 'HZfFBh6tU59EdXJ' + token: $token json: name: "user1" password: "123456" diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml new file mode 100644 index 000000000..c781d2959 --- /dev/null +++ b/tests/data/demo_testset_template_import_functions.yml @@ -0,0 +1,64 @@ +- config: + name: "create user testsets." + import_module_functions: + - tests.data.custom_functions + variable_binds: + - user_agent: 'iOS/10.3' + - device_sn: ${gen_random_string(15)} + - os_platform: 'ios' + - app_version: '2.8.6' + - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + request: + base_url: http://127.0.0.1:5000 + headers: + Content-Type: application/json + device_sn: $device_sn + +- test: + name: get token + request: + url: /api/get-token + method: POST + headers: + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: $sign + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + variable_binds: + - user_name: "user1" + - user_password: "123456" + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: $user_name + password: $user_password + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +- test: + name: create user which does not exist + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml new file mode 100644 index 000000000..ff581fd94 --- /dev/null +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -0,0 +1,74 @@ +- config: + name: "create user testsets." + requires: + - random + - string + - hashlib + - hmac + function_binds: + gen_random_string_lambda: "lambda str_len: ''.join( + random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" + get_sign_lambda: "lambda *args: hmac.new( + 'DebugTalk'.encode('ascii'), + ''.join(args).encode('ascii'), + hashlib.sha1).hexdigest()" + variable_binds: + - user_agent: 'iOS/10.3' + - device_sn: ${gen_random_string_lambda(15)} + - os_platform: 'ios' + - app_version: '2.8.6' + - sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} + request: + base_url: http://127.0.0.1:5000 + headers: + Content-Type: application/json + device_sn: $device_sn + +- test: + name: get token + request: + url: /api/get-token + method: POST + headers: + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: $sign + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + variable_binds: + - user_name: "user1" + - user_password: "123456" + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: $user_name + password: $user_password + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +- test: + name: create user which does not exist + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml new file mode 100644 index 000000000..505fe748e --- /dev/null +++ b/tests/data/demo_testset_variables.yml @@ -0,0 +1,62 @@ +- config: + name: "create user testsets." + variable_binds: + - user_agent: 'iOS/10.3' + - device_sn: 'HZfFBh6tU59EdXJ' + - os_platform: 'ios' + - app_version: '2.8.6' + request: + base_url: http://127.0.0.1:5000 + headers: + Content-Type: application/json + device_sn: $device_sn + +- test: + name: get token + request: + url: /api/get-token + method: POST + headers: + Content-Type: application/json + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: f1219719911caae89ccc301679857ebfda115ca2 + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + variable_binds: + - user_name: "user1" + - user_password: "123456" + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: $user_name + password: $user_password + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +- test: + name: create user which does not exist + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/simple_demo_auth_hardcode.json b/tests/data/simple_demo_auth_hardcode.json deleted file mode 100644 index 213c61b1f..000000000 --- a/tests/data/simple_demo_auth_hardcode.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "test": { - "name": "create user which does not exist", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validators": [ - {"check": "status_code", "comparator": "eq", "expected": 201}, - {"check": "content.success", "comparator": "eq", "expected": true} - ] - } - }, - { - "test": { - "name": "create user which existed", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validators": [ - {"check": "status_code", "comparator": "eq", "expected": 500}, - {"check": "content.success", "comparator": "eq", "expected": false} - ] - } - } -] \ No newline at end of file diff --git a/tests/data/simple_demo_no_auth.json b/tests/data/simple_demo_no_auth.json deleted file mode 100644 index 35bb895aa..000000000 --- a/tests/data/simple_demo_no_auth.json +++ /dev/null @@ -1,43 +0,0 @@ -[ - { - "test": { - "name": "create user which does not exist", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json" - }, - "cookies": {}, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validators": [ - {"check": "status_code", "comparator": "eq", "expected": 201}, - {"check": "content.success", "comparator": "eq", "expected": true} - ] - } - }, - { - "test": { - "name": "create user which existed", - "request": { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "content-type": "application/json" - }, - "json": { - "name": "user1", - "password": "123456" - } - }, - "validators": [ - {"check": "status_code", "comparator": "eq", "expected": 500}, - {"check": "content.success", "comparator": "eq", "expected": false} - ] - } - } -] \ No newline at end of file diff --git a/tests/data/simple_demo_no_auth.yml b/tests/data/simple_demo_no_auth.yml deleted file mode 100644 index aa6335770..000000000 --- a/tests/data/simple_demo_no_auth.yml +++ /dev/null @@ -1,29 +0,0 @@ -- test: - name: create user which does not exist - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - json: - name: user1 - password: 123456 - validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} - - {"check": "headers.content-type", "comparator": "eq", "expected": "application/json"} - -- test: - name: create user which existed - request: - url: http://127.0.0.1:5000/api/users/1000 - method: POST - headers: - Content-Type: application/json - json: - name: user1 - password: 123456 - validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} - - {"check": "headers.content-type", "comparator": "eq", "expected": "application/json"} diff --git a/tests/test_apiserver.py b/tests/test_apiserver.py index 6e0c59c38..73d953877 100644 --- a/tests/test_apiserver.py +++ b/tests/test_apiserver.py @@ -1,24 +1,30 @@ -import requests import random +import requests + from tests.base import ApiServerUnittest + class TestApiServer(ApiServerUnittest): + def setUp(self): super(TestApiServer, self).setUp() - self.host = "http://127.0.0.1:5000" - self.api_client = requests.Session() - self.clear_users() + self.headers = self.get_authenticated_headers() + self.reset_all() def tearDown(self): super(TestApiServer, self).tearDown() - def clear_users(self): - url = "%s/api/users" % self.host - return self.api_client.delete(url) + def test_index(self): + resp = self.api_client.get(self.host) + self.assertEqual(200, resp.status_code) + + def reset_all(self): + url = "%s/api/reset-all" % self.host + return self.api_client.get(url, headers=self.headers) def get_users(self): url = "%s/api/users" % self.host - return self.api_client.get(url) + return self.api_client.get(url, headers=self.headers) def create_user(self, uid, name, password): url = "%s/api/users/%d" % (self.host, uid) @@ -26,11 +32,11 @@ def create_user(self, uid, name, password): 'name': name, 'password': password } - return self.api_client.post(url, json=data) + return self.api_client.post(url, headers=self.headers, json=data) def get_user(self, uid): url = "%s/api/users/%d" % (self.host, uid) - return self.api_client.get(url) + return self.api_client.get(url, headers=self.headers) def update_user(self, uid, name, password): url = "%s/api/users/%d" % (self.host, uid) @@ -38,14 +44,14 @@ def update_user(self, uid, name, password): 'name': name, 'password': password } - return self.api_client.put(url, json=data) + return self.api_client.put(url, headers=self.headers, json=data) def delete_user(self, uid): url = "%s/api/users/%d" % (self.host, uid) - return self.api_client.delete(url) + return self.api_client.delete(url, headers=self.headers) - def test_clear_users(self): - resp = self.clear_users() + def test_reset_all(self): + resp = self.reset_all() self.assertEqual(200, resp.status_code) self.assertEqual(True, resp.json()['success']) @@ -114,7 +120,11 @@ def test_get_customized_response_status_code(self): expected_response = { 'status_code': status_code, } - resp = self.api_client.post(url, json=expected_response) + resp = self.api_client.post( + url, + headers=self.headers, + json=expected_response + ) self.assertEqual(status_code, resp.status_code) def test_get_customized_response_headers(self): @@ -125,13 +135,10 @@ def test_get_customized_response_headers(self): } } url = "%s/customize-response" % self.host - resp = self.api_client.post(url, json=expected_response) + resp = self.api_client.post( + url, + headers=self.headers, + json=expected_response + ) self.assertIn('abc', resp.headers) self.assertIn('123', resp.headers['abc']) - - def test_get_token(self): - url = "%s/api/token" % self.host - resp = self.api_client.get(url) - resp_json = resp.json() - self.assertTrue(resp_json["success"]) - self.assertEqual(len(resp_json["token"]), 8) diff --git a/tests/test_apiserver_v2.py b/tests/test_apiserver_v2.py deleted file mode 100644 index 50c42a041..000000000 --- a/tests/test_apiserver_v2.py +++ /dev/null @@ -1,158 +0,0 @@ -import random -import requests - -from tests.base import ApiServerUnittest - - -class TestApiServerV2(ApiServerUnittest): - - authentication = True - - def setUp(self): - super(TestApiServerV2, self).setUp() - self.host = "http://127.0.0.1:5000" - self.api_client = requests.Session() - self.clear_users() - - def tearDown(self): - super(TestApiServerV2, self).tearDown() - - def test_index(self): - headers = self.prepare_headers() - resp = self.api_client.get(self.host, headers=headers) - self.assertEqual(200, resp.status_code) - - def clear_users(self): - url = "%s/api/users" % self.host - return self.api_client.delete(url, headers=self.prepare_headers()) - - def get_users(self): - url = "%s/api/users" % self.host - return self.api_client.get(url, headers=self.prepare_headers()) - - def create_user(self, uid, name, password): - url = "%s/api/users/%d" % (self.host, uid) - data = { - 'name': name, - 'password': password - } - headers = self.prepare_headers(data) - return self.api_client.post(url, headers=headers, json=data) - - def get_user(self, uid): - url = "%s/api/users/%d" % (self.host, uid) - return self.api_client.get(url, headers=self.prepare_headers()) - - def update_user(self, uid, name, password): - url = "%s/api/users/%d" % (self.host, uid) - data = { - 'name': name, - 'password': password - } - headers = self.prepare_headers(data) - return self.api_client.put(url, headers=headers, json=data) - - def delete_user(self, uid): - url = "%s/api/users/%d" % (self.host, uid) - return self.api_client.delete(url, headers=self.prepare_headers()) - - def test_clear_users(self): - resp = self.clear_users() - self.assertEqual(200, resp.status_code) - self.assertEqual(True, resp.json()['success']) - - def test_create_user_not_existed(self): - resp = self.create_user(1000, 'user1', '123456') - self.assertEqual(201, resp.status_code) - self.assertEqual(True, resp.json()['success']) - - def test_create_user_existed(self): - resp = self.create_user(1000, 'user1', '123456') - resp = self.create_user(1000, 'user1', '123456') - self.assertEqual(500, resp.status_code) - - def test_get_users_empty(self): - resp = self.get_users() - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['count'], 0) - - def test_get_users_not_empty(self): - resp = self.create_user(1000, 'user1', '123456') - resp = self.get_users() - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['count'], 1) - - resp = self.create_user(1001, 'user2', '123456') - resp = self.get_users() - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['count'], 2) - - def test_get_user_not_existed(self): - resp = self.get_user(1000) - self.assertEqual(404, resp.status_code) - self.assertEqual(resp.json()['success'], False) - - def test_get_user_existed(self): - self.create_user(1000, 'user1', '123456') - resp = self.get_user(1000) - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['success'], True) - - def test_update_user_not_existed(self): - resp = self.update_user(1000, 'user1', '123456') - self.assertEqual(404, resp.status_code) - self.assertEqual(resp.json()['success'], False) - - def test_update_user_existed(self): - self.create_user(1000, 'user1', '123456') - resp = self.update_user(1000, 'user2', '123456') - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['data']['name'], 'user2') - - def test_delete_user_not_existed(self): - resp = self.delete_user(1000) - self.assertEqual(404, resp.status_code) - self.assertEqual(resp.json()['success'], False) - - def test_delete_user_existed(self): - self.create_user(1000, 'leo', '123456') - resp = self.delete_user(1000) - self.assertEqual(200, resp.status_code) - self.assertEqual(resp.json()['success'], True) - - def test_get_customized_response_status_code(self): - status_code = random.randint(200, 511) - url = "%s/customize-response" % self.host - expected_response = { - 'status_code': status_code, - } - resp = self.api_client.post( - url, - headers=self.prepare_headers(expected_response), - json=expected_response - ) - self.assertEqual(status_code, resp.status_code) - - def test_get_customized_response_headers(self): - expected_response = { - 'headers': { - 'abc': 123, - 'def': 456 - } - } - url = "%s/customize-response" % self.host - resp = self.api_client.post( - url, - headers=self.prepare_headers(expected_response), - json=expected_response - ) - self.assertIn('abc', resp.headers) - self.assertIn('123', resp.headers['abc']) - - def test_get_token(self): - url = "%s/api/token" % self.host - headers = self.prepare_headers() - resp = self.api_client.get(url, headers=headers) - resp_json = resp.json() - self.assertTrue(resp_json["success"]) - self.assertEqual(len(resp_json["token"]), 8) diff --git a/tests/test_client.py b/tests/test_client.py index a42d7239b..b6ac1009b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,16 +4,17 @@ class TestHttpClient(ApiServerUnittest): def setUp(self): super(TestHttpClient, self).setUp() - self.host = "http://127.0.0.1:5000" self.api_client = HttpSession(self.host) - self.clear_users() + self.headers = self.get_authenticated_headers() + self.reset_all() def tearDown(self): super(TestHttpClient, self).tearDown() - def clear_users(self): - url = "%s/api/users" % self.host - return self.api_client.delete(url) + def reset_all(self): + url = "%s/api/reset-all" % self.host + headers = self.get_authenticated_headers() + return self.api_client.get(url, headers=headers) def test_request_with_full_url(self): url = "%s/api/users/1000" % self.host @@ -21,7 +22,7 @@ def test_request_with_full_url(self): 'name': 'user1', 'password': '123456' } - resp = self.api_client.post(url, json=data) + resp = self.api_client.post(url, json=data, headers=self.headers) self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()['success']) @@ -31,6 +32,6 @@ def test_request_without_base_url(self): 'name': 'user1', 'password': '123456' } - resp = self.api_client.post(url, json=data) + resp = self.api_client.post(url, json=data, headers=self.headers) self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()['success']) diff --git a/tests/test_runner.py b/tests/test_runner.py index 8556a8c13..4ef52015c 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -7,49 +7,58 @@ class TestRunner(ApiServerUnittest): def setUp(self): self.test_runner = runner.Runner() - self.clear_users() + self.reset_all() - def clear_users(self): - url = "http://127.0.0.1:5000/api/users" - return requests.delete(url) + self.testcase_file_path_list = [ + os.path.join( + os.getcwd(), 'tests/data/demo_testset_hardcode.yml'), + os.path.join( + os.getcwd(), 'tests/data/demo_testset_hardcode.json') + ] - def test_run_single_testcase_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') - testcases = utils.load_testcases(testcase_file_path) - testcase = testcases[0]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) + def reset_all(self): + url = "%s/api/reset-all" % self.host + headers = self.get_authenticated_headers() + return self.api_client.get(url, headers=headers) - def test_run_single_testcase_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json') - testcases = utils.load_testcases(testcase_file_path) - testcase = testcases[0]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) + def test_run_single_testcase(self): + for testcase_file_path in self.testcase_file_path_list: + testcases = utils.load_testcases(testcase_file_path) + testcase = testcases[0]["test"] + success, _ = self.test_runner.run_test(testcase) + self.assertTrue(success) + + testcase = testcases[1]["test"] + success, _ = self.test_runner.run_test(testcase) + self.assertTrue(success) + + testcase = testcases[2]["test"] + success, _ = self.test_runner.run_test(testcase) + self.assertTrue(success) def test_run_single_testcase_fail(self): testcase = { - "name": "create user which does not exist", + "name": "get token", "request": { - "url": "http://127.0.0.1:5000/api/users/1000", + "url": "http://127.0.0.1:5000/api/get-token", "method": "POST", "headers": { - "content-type": "application/json" + "content-type": "application/json", + "user_agent": "iOS/10.3", + "device_sn": "HZfFBh6tU59EdXJ", + "os_platform": "ios", + "app_version": "2.8.6" }, "json": { - "name": "user1", - "password": "123456" + "sign": "f1219719911caae89ccc301679857ebfda115ca2" } }, "extract_binds": [ - {"resp_status_code": "status_code"}, - {"resp_body_success": "content.success"}, - {"resp_headers_contenttype": "headers.content-type"} + {"token": "content.token"} ], "validators": [ - {"check": "resp_status_code", "comparator": "eq", "expected": 200}, - {"check": "resp_body_success", "comparator": "eq", "expected": False}, - {"check": "resp_headers_contenttype", "comparator": "eq", "expected": "html/text"} + {"check": "status_code", "comparator": "eq", "expected": 205}, + {"check": "content.token", "comparator": "len_eq", "expected": 19} ] } @@ -57,41 +66,51 @@ def test_run_single_testcase_fail(self): self.assertFalse(success) self.assertEqual( diff_content_list[0], - {"check": "resp_status_code", "comparator": "eq", "expected": 200, 'value': 201} - ) - self.assertEqual( - diff_content_list[1], - {"check": "resp_body_success", "comparator": "eq", "expected": False, 'value': True} - ) - self.assertEqual( - diff_content_list[2], - {"check": "resp_headers_contenttype", "comparator": "eq", "expected": "html/text", 'value': "application/json"} + {"check": "status_code", "comparator": "eq", "expected": 205, 'value': 200} ) - def test_run_testset_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json') + def test_run_testset_hardcode(self): + for testcase_file_path in self.testcase_file_path_list: + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testset(testsets[0]) + self.assertEqual(len(results), 3) + self.assertEqual(results, [(True, [])] * 3) + + def test_run_testsets_hardcode(self): + for testcase_file_path in self.testcase_file_path_list: + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results, [[(True, [])] * 3]) + + def test_run_testset_template_variables(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_variables.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, []), (True, [])]) + self.assertEqual(len(results), 3) + self.assertEqual(results, [(True, [])] * 3) - def test_run_testsets_json_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json') + def test_run_testset_template_import_functions(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, []), (True, [])]) + results = self.test_runner.run_testset(testsets[0]) + self.assertEqual(len(results), 3) + self.assertEqual(results, [(True, [])] * 3) - def test_run_testset_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') + def test_run_testsets_template_import_functions(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, []), (True, [])]) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results, [[(True, [])] * 3]) - def test_run_testsets_yaml_success(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') + def test_run_testsets_template_lambda_functions(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_template_lambda_functions.yml') testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, []), (True, [])]) + self.assertEqual(results, [[(True, [])] * 3]) diff --git a/tests/test_runner_v2.py b/tests/test_runner_v2.py deleted file mode 100644 index b7af1f0d9..000000000 --- a/tests/test_runner_v2.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import requests -from ate import runner, exception, utils -from tests.base import ApiServerUnittest - -class TestRunnerV2(ApiServerUnittest): - - authentication = True - - def setUp(self): - self.test_runner = runner.Runner() - self.clear_users() - - def clear_users(self): - url = "http://127.0.0.1:5000/api/users" - return requests.delete(url, headers=self.prepare_headers()) - - def test_run_single_testcase_yaml(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_auth_hardcode.yml') - testcases = utils.load_testcases(testcase_file_path) - testcase = testcases[0]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) - - def test_run_single_testcase_json(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_auth_hardcode.json') - testcases = utils.load_testcases(testcase_file_path) - testcase = testcases[0]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) - - def test_run_testset_auth_yaml(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_auth_hardcode.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, []), (True, [])]) - - def test_run_testsets_auth_yaml(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_auth_hardcode.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, []), (True, [])]) - - def test_run_testset_auth_json(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_auth_hardcode.json') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, []), (True, [])]) - - def test_run_testsets_auth_json(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_auth_hardcode.json') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, []), (True, [])]) - - def test_run_testcase_template_yaml(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_template_separate.yml') - testcases = utils.load_testcases(testcase_file_path) - success, _ = self.test_runner.run_test(testcases[0]["test"]) - self.assertTrue(success) - success, _ = self.test_runner.run_test(testcases[1]["test"]) - self.assertTrue(success) - - def test_run_testset_template_yaml(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_template_sets.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, []), (True, [])]) - - def test_run_testsets_template_yaml(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_template_sets.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, []), (True, [])]) - - def test_run_testset_template_import_functions(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_import_functions.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 2) - self.assertEqual(results, [(True, []), (True, [])]) - - def test_run_testsets_template_import_functions(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_import_functions.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results[0], [(True, []), (True, [])]) diff --git a/tests/test_task.py b/tests/test_task.py index 624ebcd7c..ffecc6fce 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,30 +1,29 @@ import os -import random -import requests from tests.base import ApiServerUnittest from ate import task, utils -class TestMain(ApiServerUnittest): +class TestTask(ApiServerUnittest): def setUp(self): - self.clear_users() + self.reset_all() - def clear_users(self): - url = "http://127.0.0.1:5000/api/users" - return requests.delete(url) + def reset_all(self): + url = "%s/api/reset-all" % self.host + headers = self.get_authenticated_headers() + return self.api_client.get(url, headers=headers) def test_create_suite(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_testset_variables.yml') testsets = utils.load_testcases_by_path(testcase_file_path) suite = task.create_suite(testsets[0]) - self.assertEqual(suite.countTestCases(), 2) + self.assertEqual(suite.countTestCases(), 3) for testcase in suite: self.assertIsInstance(testcase, task.ApiTestCase) def test_create_task(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.yml') + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_testset_variables.yml') task_suite = task.create_task(testcase_file_path) - self.assertEqual(task_suite.countTestCases(), 2) + self.assertEqual(task_suite.countTestCases(), 3) for suite in task_suite: for testcase in suite: self.assertIsInstance(testcase, task.ApiTestCase) diff --git a/tests/test_utils.py b/tests/test_utils.py index f327fa069..1678edcb3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,9 +12,9 @@ def test_load_testcases_bad_filepath(self): def test_load_json_testcases(self): testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_no_auth.json') + os.getcwd(), 'tests/data/demo_testset_hardcode.json') testcases = utils.load_testcases(testcase_file_path) - self.assertEqual(len(testcases), 2) + self.assertEqual(len(testcases), 3) testcase = testcases[0]["test"] self.assertIn('name', testcase) self.assertIn('request', testcase) @@ -23,9 +23,9 @@ def test_load_json_testcases(self): def test_load_yaml_testcases(self): testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_no_auth.yml') + os.getcwd(), 'tests/data/demo_testset_hardcode.yml') testcases = utils.load_testcases(testcase_file_path) - self.assertEqual(len(testcases), 2) + self.assertEqual(len(testcases), 3) testcase = testcases[0]["test"] self.assertIn('name', testcase) self.assertIn('request', testcase) @@ -45,28 +45,28 @@ def test_load_testcases_by_path_files(self): # absolute file path path = os.path.join( - os.getcwd(), 'tests/data/simple_demo_no_auth.json') + os.getcwd(), 'tests/data/demo_testset_hardcode.json') testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) - self.assertEqual(len(testset_list[0]["testcases"]), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 3) testsets_list.extend(testset_list) # relative file path - path = 'tests/data/simple_demo_no_auth.yml' + path = 'tests/data/demo_testset_hardcode.yml' testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) - self.assertEqual(len(testset_list[0]["testcases"]), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 3) testsets_list.extend(testset_list) # list/set container with file(s) path = [ - os.path.join(os.getcwd(), 'tests/data/simple_demo_no_auth.json'), - 'tests/data/simple_demo_no_auth.yml' + os.path.join(os.getcwd(), 'tests/data/demo_testset_hardcode.json'), + 'tests/data/demo_testset_hardcode.yml' ] testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 2) - self.assertEqual(len(testset_list[0]["testcases"]), 2) - self.assertEqual(len(testset_list[1]["testcases"]), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 3) + self.assertEqual(len(testset_list[1]["testcases"]), 3) testsets_list.extend(testset_list) self.assertEqual(len(testsets_list), 4) @@ -81,7 +81,7 @@ def test_load_testcases_by_path_folder(self): # absolute folder path path = os.path.join(os.getcwd(), 'tests/data') testset_list_1 = utils.load_testcases_by_path(path) - self.assertGreater(len(testset_list_1), 6) + self.assertGreater(len(testset_list_1), 5) # relative folder path path = 'tests/data/' From 0674d6ab338f61aa2e025a1255e4383be7b121a3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 11:29:48 +0800 Subject: [PATCH 135/354] update demo testset: change position of variable binds --- README.md | 3 ++- tests/data/demo_testset_template_import_functions.yml | 3 ++- tests/data/demo_testset_template_lambda_functions.yml | 3 ++- tests/data/demo_testset_variables.yml | 10 ++++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d7a7f8fce..6eed4c411 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,6 @@ optional arguments: - device_sn: ${gen_random_string(15)} - os_platform: 'ios' - app_version: '2.8.6' - - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: base_url: http://127.0.0.1:5000 headers: @@ -77,6 +76,8 @@ optional arguments: - test: name: get token + variable_binds: + - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: url: /api/get-token method: POST diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index c781d2959..a64412c0d 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -7,7 +7,6 @@ - device_sn: ${gen_random_string(15)} - os_platform: 'ios' - app_version: '2.8.6' - - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: base_url: http://127.0.0.1:5000 headers: @@ -16,6 +15,8 @@ - test: name: get token + variable_binds: + - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: url: /api/get-token method: POST diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index ff581fd94..72fa56158 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -17,7 +17,6 @@ - device_sn: ${gen_random_string_lambda(15)} - os_platform: 'ios' - app_version: '2.8.6' - - sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} request: base_url: http://127.0.0.1:5000 headers: @@ -26,6 +25,8 @@ - test: name: get token + variable_binds: + - sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} request: url: /api/get-token method: POST diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index 505fe748e..fe77c4b12 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -1,10 +1,7 @@ - config: name: "create user testsets." variable_binds: - - user_agent: 'iOS/10.3' - device_sn: 'HZfFBh6tU59EdXJ' - - os_platform: 'ios' - - app_version: '2.8.6' request: base_url: http://127.0.0.1:5000 headers: @@ -13,6 +10,11 @@ - test: name: get token + variable_binds: + - user_agent: 'iOS/10.3' + - os_platform: 'ios' + - app_version: '2.8.6' + - sign: f1219719911caae89ccc301679857ebfda115ca2 request: url: /api/get-token method: POST @@ -23,7 +25,7 @@ os_platform: $os_platform app_version: $app_version json: - sign: f1219719911caae89ccc301679857ebfda115ca2 + sign: $sign extract_binds: - token: content.token validators: From 541037be80f2e940da4de985b1903a7d8bda897f Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 11:55:23 +0800 Subject: [PATCH 136/354] Convert README from Chinese to English. --- README.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6eed4c411..5a42bea31 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status](https://travis-ci.org/debugtalk/ApiTestEngine.svg?branch=master)](https://travis-ci.org/debugtalk/ApiTestEngine) [![Coverage Status](https://coveralls.io/repos/github/debugtalk/ApiTestEngine/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/ApiTestEngine?branch=master) -## 核心特性 +## Key Features - 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 - 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML/JSON` @@ -16,22 +16,22 @@ [《背景介绍》](docs/background.md) [《特性拆解介绍》](docs/features-intro.md) -## Install +## Installation ```bash $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine ``` -若安装出现问题,请查看[`FAQ`](docs/FAQ.md)。 +If there is a problem with the installation, you can check the [`FAQ`](docs/FAQ.md). -执行`ate -V`,若正常显示版本号,则说明安装成功。 +To ensure the installation is successful, you can excuting command `ate -V` to see if you can get the version number. ```text $ ate -V 0.1.0 ``` -执行`ate -h`,查看命令的帮助说明。 +Execute the command `ate -h` to view command help. ```text $ ate -h @@ -52,11 +52,11 @@ optional arguments: Specify report name, default is generated time. ``` -## 编写测试用例 +## Write testcases -推荐采用`YAML`格式编写测试用例。 +It is recommended to write testcases in `YAML` format. -如下是一个典型的接口测试用例示例。具体的编写方式请阅读详细文档。 +And here is testset example of typical scenario: get token at the beginning, and each subsequent requests should take the token in the headers. ```yaml - config: @@ -112,23 +112,25 @@ optional arguments: - {"check": "content.success", "comparator": "eq", "expected": true} ``` -## 运行测试用例 +For detailed regulations of writing testcases, you can read the specification. -`ApiTestEngine`可指定运行特定的测试用例集文件,或运行指定目录下的所有测试用例集文件。 +## Run testcases -执行单个测试用例集: +`ApiTestEngine` can run testcases in diverse ways. + +You can run single testset by specifying testset file path. ```text $ ate filepath/testcase.yml ``` -执行多个测试用例集: +You can also run several testsets by specifying multiple testset file paths. ```text $ ate filepath1/testcase1.yml filepath2/testcase2.yml ``` -执行指定目录下的所有测试用例集: +If you want to run testsets of a whole project, you can achieve this goal by specifying the project folder path. ```text $ ate testcases_folder_path @@ -138,7 +140,7 @@ $ ate testcases_folder_path Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. -## 阅读更多 +## To Learn more ... - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) - [《ApiTestEngine 演进之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) From 2d2b884cb1c75ed974475dffd6d789a331b679cf Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 12:15:22 +0800 Subject: [PATCH 137/354] README: add design philosopy --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 5a42bea31..3f3348885 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ [![Build Status](https://travis-ci.org/debugtalk/ApiTestEngine.svg?branch=master)](https://travis-ci.org/debugtalk/ApiTestEngine) [![Coverage Status](https://coveralls.io/repos/github/debugtalk/ApiTestEngine/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/ApiTestEngine?branch=master) +## Design Philosophy + +Take full reuse of Python's existing powerful libraries: [`requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achive the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. + ## Key Features - 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 @@ -148,3 +152,8 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. - [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) - [《ApiTestEngine 演进之路(3)测试用例中实现 Python 函数的定义》](http://debugtalk.com/post/ApiTestEngine-3-define-functions-in-yaml-testcases/) - [《ApiTestEngine 演进之路(4)测试用例中实现 Python 函数的调用》](http://debugtalk.com/post/ApiTestEngine-4-call-functions-in-yaml-testcases/) + + +[requests]: http://docs.python-requests.org/en/master/ +[unittest]: https://docs.python.org/3/library/unittest.html +[Locust]: http://locust.io/ From f3e989801b80e65ddeace45aca809a5cd00b3f96 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 12:28:36 +0800 Subject: [PATCH 138/354] change docs file name --- README.md | 2 +- docs/{background.md => background-CN.md} | 0 docs/{features-intro.md => feature-descriptions-CN.md} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename docs/{background.md => background-CN.md} (100%) rename docs/{features-intro.md => feature-descriptions-CN.md} (100%) diff --git a/README.md b/README.md index 3f3348885..8ee5424ab 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Take full reuse of Python's existing powerful libraries: [`requests`][requests], - 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) - 具有可扩展性,便于扩展实现Web平台化 -[《背景介绍》](docs/background.md) [《特性拆解介绍》](docs/features-intro.md) +[*`Background Introduction (CN)`*](docs/background-CN.md) | [*`Feature Descriptions (CN)`*](docs/feature-descriptions-CN.md) ## Installation diff --git a/docs/background.md b/docs/background-CN.md similarity index 100% rename from docs/background.md rename to docs/background-CN.md diff --git a/docs/features-intro.md b/docs/feature-descriptions-CN.md similarity index 100% rename from docs/features-intro.md rename to docs/feature-descriptions-CN.md From dcdc59e712dd7eb19742031e235dad6f10a09c2c Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 12:45:11 +0800 Subject: [PATCH 139/354] convert docs/FAQ to English --- docs/FAQ.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 475a3bbd4..accdcb909 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,19 +1,19 @@ -## 无法自动安装PyUnitReport依赖库 +## Unable to install PyUnitReport dependency library automatically -如果安装过程中出现如下报错: +If there is something goes wrong in installation like below. ```text Downloading/unpacking PyUnitReport (from ApiTestEngine) Could not find any downloads that satisfy the requirement PyUnitReport (from ApiTestEngine) ``` -那么需要先手动安装`PyUnitReport`,安装方式如下: +You could install `PyUnitReport` manully at first. ```bash $ pip install git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport ``` -然后再重新安装`ApiTestEngine`即可。 +And then everything will be OK when you reinstall `ApiTestEngine`. ```bash $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine From b8e7e4c0e8c3bfc3372c4217d721f99fcce254af Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 21:12:30 +0800 Subject: [PATCH 140/354] fix spelling error --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ee5424ab..fea1ded09 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Design Philosophy -Take full reuse of Python's existing powerful libraries: [`requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achive the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. +Take full reuse of Python's existing powerful libraries: [`requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. ## Key Features From 804c488e35eff6032c8127344f2ffeb643094d89 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 23 Jul 2017 21:50:44 +0800 Subject: [PATCH 141/354] update docs --- README.md | 25 ++++++++++++++----------- docs/feature-descriptions-CN.md | 11 +++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fea1ded09..d40b95e81 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,20 @@ ## Design Philosophy -Take full reuse of Python's existing powerful libraries: [`requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. +Take full reuse of Python's existing powerful libraries: [`Requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. ## Key Features -- 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 -- 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML/JSON` -- 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 -- 接口测试用例具有可复用性,便于创建复杂测试场景 -- 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 -- 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等 -- 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) -- 具有可扩展性,便于扩展实现Web平台化 +- Inherit all powerful features of [`Requests`][requests], just have fun to handle HTTP in human way. +- Define testcases in YAML or JSON format in concise and elegant manner. +- Supports `function`/`variable`/`extract`/`validate` mechanisms to create full test scenarios. +- Testcases can be run in diverse ways, with single testset, multiple testsets, or whole project folder. +- Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. +- Perfect combination with [Jenkins][Jenkins], running continuous test and production monitoring. +- With reuse of [`Locust`][Locust], you can run performance test without extra work. +- It is extensible to facilitate the implementation of web platform with [`Flask`][flask] framework. -[*`Background Introduction (CN)`*](docs/background-CN.md) | [*`Feature Descriptions (CN)`*](docs/feature-descriptions-CN.md) +[*`Background Introduction (中文版)`*](docs/background-CN.md) | [*`Feature Descriptions (中文版)`*](docs/feature-descriptions-CN.md) ## Installation @@ -144,7 +144,7 @@ $ ate testcases_folder_path Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. -## To Learn more ... +## To learn more ... - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) - [《ApiTestEngine 演进之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) @@ -157,3 +157,6 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. [requests]: http://docs.python-requests.org/en/master/ [unittest]: https://docs.python.org/3/library/unittest.html [Locust]: http://locust.io/ +[flask]: http://flask.pocoo.org/ +[PyUnitReport]: https://github.com/debugtalk/PyUnitReport +[Jenkins]: https://jenkins.io/index.html \ No newline at end of file diff --git a/docs/feature-descriptions-CN.md b/docs/feature-descriptions-CN.md index 11b96b4d6..c4dbdb707 100644 --- a/docs/feature-descriptions-CN.md +++ b/docs/feature-descriptions-CN.md @@ -1,3 +1,14 @@ +## 核心特性 + +- 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 +- 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML/JSON` +- 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 +- 接口测试用例具有可复用性,便于创建复杂测试场景 +- 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 +- 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等 +- 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) +- 具有可扩展性,便于扩展实现Web平台化 + ## 特性拆解介绍 > 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 From 7901a8e26a6194da1ea87b7a7dbf159417b945a3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 24 Jul 2017 18:16:07 +0800 Subject: [PATCH 142/354] update tag to 0.2.2 --- ate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/__init__.py b/ate/__init__.py index fb13a3556..9dd16a345 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.2.1' \ No newline at end of file +__version__ = '0.2.2' \ No newline at end of file From 137b38195711a1fb253b53b6f7e6f1b4b0d76cc5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 24 Jul 2017 22:16:15 +0800 Subject: [PATCH 143/354] Stop the test run on the first error or failure. --- ate/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ate/cli.py b/ate/cli.py index aafed92fa..cd89904a5 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -48,6 +48,7 @@ def main(): output_folder_name = os.path.basename(os.path.splitext(testset_path)[0]) kwargs = { "output": output_folder_name, - "report_name": report_name + "report_name": report_name, + "failfast": True } PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) From 0ce8a3f57356c150459faa779c92526e0ed4a24f Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 25 Jul 2017 10:44:29 +0800 Subject: [PATCH 144/354] update README: add Upgrade description --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d40b95e81..1b10d8b29 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,25 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], [*`Background Introduction (中文版)`*](docs/background-CN.md) | [*`Feature Descriptions (中文版)`*](docs/feature-descriptions-CN.md) -## Installation +## Installation/Upgrade ```bash $ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine ``` -If there is a problem with the installation, you can check the [`FAQ`](docs/FAQ.md). +To upgrade all specified packages to the newest available version, you should add the `-U` option. -To ensure the installation is successful, you can excuting command `ate -V` to see if you can get the version number. +```bash +$ pip install -U git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine +``` + +If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). + +To ensure the installation or upgrade is successful, you can excuting command `ate -V` to see if you can get the correct version number. ```text $ ate -V -0.1.0 +0.2.2 ``` Execute the command `ate -h` to view command help. From 4de4cfe3a39dc644f34bb99106f3cc1947227612 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 27 Jul 2017 14:44:32 +0800 Subject: [PATCH 145/354] specify run times in testcase --- ate/__init__.py | 2 +- ate/runner.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 9dd16a345..db9cf742d 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.2.2' \ No newline at end of file +__version__ = '0.2.3' \ No newline at end of file diff --git a/ate/runner.py b/ate/runner.py index 46b935ffe..cf11de8f1 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -61,6 +61,7 @@ def run_test(self, testcase): @param (dict) testcase { "name": "testcase description", + "times": 3, "requires": [], # optional, override "function_binds": {}, # optional, override "variable_binds": {}, # optional, override @@ -89,16 +90,19 @@ def run_test(self, testcase): except KeyError: raise exception.ParamsError("URL or METHOD missed!") - resp = self.http_client_session.request(url=url, method=method, **parsed_request) - resp_obj = response.ResponseObject(resp) - + run_times = int(testcase.get("times", 1)) extract_binds = testcase.get("extract_binds", {}) - extracted_variables_mapping_list = resp_obj.extract_response(extract_binds) - self.context.bind_variables(extracted_variables_mapping_list, level="testset") - validators = testcase.get("validators", []) - diff_content_list = resp_obj.validate( - validators, self.context.get_testcase_variables_mapping()) + + for _ in range(run_times): + resp = self.http_client_session.request(url=url, method=method, **parsed_request) + resp_obj = response.ResponseObject(resp) + + extracted_variables_mapping_list = resp_obj.extract_response(extract_binds) + self.context.bind_variables(extracted_variables_mapping_list, level="testset") + + diff_content_list = resp_obj.validate( + validators, self.context.get_testcase_variables_mapping()) return resp_obj.success, diff_content_list From fab235ffe096ac59fe82acb85eda2b1626db27c3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 27 Jul 2017 21:12:10 +0800 Subject: [PATCH 146/354] fix typo error --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b10d8b29..1632676a0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ $ pip install -U git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestE If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). -To ensure the installation or upgrade is successful, you can excuting command `ate -V` to see if you can get the correct version number. +To ensure the installation or upgrade is successful, you can execute command `ate -V` to see if you can get the correct version number. ```text $ ate -V From c3856f75993f791d0683b7d0fea5b7d56218bfd1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 30 Jul 2017 16:18:11 +0800 Subject: [PATCH 147/354] bugfix #9: handle string content with multiple variables. --- ate/context.py | 19 ++++----- ate/utils.py | 47 +++++++++++++++------- tests/test_utils.py | 96 +++++++++++++++++++++++++++++++++------------ 3 files changed, 112 insertions(+), 50 deletions(-) diff --git a/ate/context.py b/ate/context.py index e7efa9a95..c5f3bdede 100644 --- a/ate/context.py +++ b/ate/context.py @@ -6,7 +6,7 @@ import types from collections import OrderedDict -from ate import exception, testcase, utils +from ate import testcase, utils def is_function(tup): @@ -154,16 +154,8 @@ def get_eval_value(self, data): # data is in string format here data = "" if data is None else data.strip() - if utils.is_variable(data): - # variable marker: $var - variable_name = utils.parse_variable(data) - value = self.testcase_variables_mapping.get(variable_name) - if value is None: - raise exception.ParamsError( - "%s is not defined in bind variables!" % variable_name) - return value - - elif utils.is_functon(data): + + if utils.is_functon(data): # function marker: ${func(1, 2, a=3, b=4)} fuction_meta = utils.parse_function(data) func_name = fuction_meta['func_name'] @@ -172,5 +164,10 @@ def get_eval_value(self, data): args = self.get_eval_value(args) kwargs = self.get_eval_value(kwargs) return self.testcase_functions_config[func_name](*args, **kwargs) + + elif utils.get_contain_variables(data): + parsed_data = utils.parse_variables(data, self.testcase_variables_mapping) + return parsed_data + else: return data diff --git a/ate/utils.py b/ate/utils.py index 4e915bff6..f51e84be1 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -18,7 +18,7 @@ PYTHON_VERSION = 3 SECRET_KEY = "DebugTalk" -variable_regexp = re.compile(r"^\$([\w_]+)$") +variable_regexp = r"\$([\w_]+)" function_regexp = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") def gen_random_string(str_len): @@ -117,26 +117,45 @@ def load_testcases_by_path(path): else: return [] -def is_variable(content): - """ check if content is a variable, which is in format $variable +def get_contain_variables(content): + """ extract all variable names from content, which is in format $variable @param (str) content - @return (bool) True or False + @return (list) variable name list - e.g. $variable => True - abc => False + e.g. $variable => ["variable"] + /blog/$postid => ["postid"] + /$var1/$var2 => ["var1", "var2"] + abc => [] """ - matched = variable_regexp.match(content) - return True if matched else False + return re.findall(variable_regexp, content) -def parse_variable(content): - """ parse variable name from string content. +def parse_variables(content, variable_mapping): + """ replace all variables of string content with mapping value. @param (str) content - @return (str) variable name + @return (str) parsed content - e.g. $variable => variable + e.g. + variable_mapping = { + "var_1": "abc", + "var_2": "def" + } + $var_1 => "abc" + $var_1#XYZ => "abc#XYZ" + /$var_1/$var_2/var3 => "/abc/def/var3" + ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" """ - matched = variable_regexp.match(content) - return matched.group(1) + variable_name_list = get_contain_variables(content) + for variable_name in variable_name_list: + variable_value = variable_mapping.get(variable_name) + if variable_value is None: + raise ParamsError( + "%s is not defined in bind variables!" % variable_name) + + content = content.replace( + "${}".format(variable_name), + variable_value + ) + return content def is_functon(content): """ check if content is a function, which is in format ${func()} diff --git a/tests/test_utils.py b/tests/test_utils.py index 1678edcb3..7f609a90b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -115,31 +115,77 @@ def test_load_testcases_by_path_not_exist(self): testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_3, []) - def test_is_variable(self): - content = "$var" - self.assertTrue(utils.is_variable(content)) - content = "$var123" - self.assertTrue(utils.is_variable(content)) - content = "$var_name" - self.assertTrue(utils.is_variable(content)) - content = "var" - self.assertFalse(utils.is_variable(content)) - content = "a$var" - self.assertFalse(utils.is_variable(content)) - content = "$v ar" - self.assertFalse(utils.is_variable(content)) - content = " " - self.assertFalse(utils.is_variable(content)) - content = "$abc*" - self.assertFalse(utils.is_variable(content)) - - def test_parse_variable(self): - content = "$var" - self.assertEqual(utils.parse_variable(content), "var") - content = "$var123" - self.assertEqual(utils.parse_variable(content), "var123") - content = "$var_name" - self.assertEqual(utils.parse_variable(content), "var_name") + def test_get_contain_variables(self): + self.assertEqual( + utils.get_contain_variables("$var"), + ["var"] + ) + self.assertEqual( + utils.get_contain_variables("$var123"), + ["var123"] + ) + self.assertEqual( + utils.get_contain_variables("$var_name"), + ["var_name"] + ) + self.assertEqual( + utils.get_contain_variables("var"), + [] + ) + self.assertEqual( + utils.get_contain_variables("a$var"), + ["var"] + ) + self.assertEqual( + utils.get_contain_variables("$v ar"), + ["v"] + ) + self.assertEqual( + utils.get_contain_variables(" "), + [] + ) + self.assertEqual( + utils.get_contain_variables("$abc*"), + ["abc"] + ) + self.assertEqual( + utils.get_contain_variables("${func()}"), + [] + ) + self.assertEqual( + utils.get_contain_variables("${func(1,2)}"), + [] + ) + self.assertEqual( + utils.get_contain_variables("${gen_md5($TOKEN, $data, $random)}"), + ["TOKEN", "data", "random"] + ) + + def test_parse_variables(self): + variable_mapping = { + "var_1": "abc", + "var_2": "def" + } + self.assertEqual( + utils.parse_variables("$var_1", variable_mapping), + "abc" + ) + self.assertEqual( + utils.parse_variables("var_1", variable_mapping), + "var_1" + ) + self.assertEqual( + utils.parse_variables("$var_1#XYZ", variable_mapping), + "abc#XYZ" + ) + self.assertEqual( + utils.parse_variables("/$var_1/$var_2/var3", variable_mapping), + "/abc/def/var3" + ) + self.assertEqual( + utils.parse_variables("${func($var_1, $var_2, xyz)}", variable_mapping), + "${func(abc, def, xyz)}" + ) def test_is_functon(self): content = "${func()}" From 46fe02a6cd136e1561600b96dfa26eb510349262 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 16:01:14 +0800 Subject: [PATCH 148/354] #9: add unittest for string with multiple identical variables --- tests/test_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index 7f609a90b..6c00d1e1f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -182,6 +182,10 @@ def test_parse_variables(self): utils.parse_variables("/$var_1/$var_2/var3", variable_mapping), "/abc/def/var3" ) + self.assertEqual( + utils.parse_variables("/$var_1/$var_2/$var_1", variable_mapping), + "/abc/def/abc" + ) self.assertEqual( utils.parse_variables("${func($var_1, $var_2, xyz)}", variable_mapping), "${func(abc, def, xyz)}" From 233978007e88e9b844741ea7300663dfd119c163 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 21:04:19 +0800 Subject: [PATCH 149/354] bugfix: when the bind value of variable is not in string type, it should be converted to string first, or it will raise TypeError in replace function --- ate/utils.py | 14 ++++++++++---- tests/test_utils.py | 25 ++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index f51e84be1..437dc07ee 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -151,10 +151,16 @@ def parse_variables(content, variable_mapping): raise ParamsError( "%s is not defined in bind variables!" % variable_name) - content = content.replace( - "${}".format(variable_name), - variable_value - ) + if "${}".format(variable_name) == content: + # content is a variable + content = variable_value + else: + # content contains one or many variables + content = content.replace( + "${}".format(variable_name), + str(variable_value) + ) + return content def is_functon(content): diff --git a/tests/test_utils.py b/tests/test_utils.py index 6c00d1e1f..5556f6455 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -164,7 +164,10 @@ def test_get_contain_variables(self): def test_parse_variables(self): variable_mapping = { "var_1": "abc", - "var_2": "def" + "var_2": "def", + "var_3": 123, + "var_4": {"a": 1}, + "var_5": True } self.assertEqual( utils.parse_variables("$var_1", variable_mapping), @@ -190,6 +193,26 @@ def test_parse_variables(self): utils.parse_variables("${func($var_1, $var_2, xyz)}", variable_mapping), "${func(abc, def, xyz)}" ) + self.assertEqual( + utils.parse_variables("$var_3", variable_mapping), + 123 + ) + self.assertEqual( + utils.parse_variables("$var_4", variable_mapping), + {"a": 1} + ) + self.assertEqual( + utils.parse_variables("$var_5", variable_mapping), + True + ) + self.assertEqual( + utils.parse_variables("abc$var_5", variable_mapping), + "abcTrue" + ) + self.assertEqual( + utils.parse_variables("abc$var_4", variable_mapping), + "abc{'a': 1}" + ) def test_is_functon(self): content = "${func()}" From 533ff9198a00f93fd9222b3b06d5699add47e9c6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 21:46:11 +0800 Subject: [PATCH 150/354] bugfix #9: handle string content with multiple identical variables. --- ate/context.py | 47 ++--------- ate/testcase.py | 98 ++++++++++++----------- tests/test_context.py | 28 ------- tests/test_testcase.py | 175 +++++++++++++++++++---------------------- 4 files changed, 145 insertions(+), 203 deletions(-) diff --git a/ate/context.py b/ate/context.py index c5f3bdede..ebd212384 100644 --- a/ate/context.py +++ b/ate/context.py @@ -88,7 +88,11 @@ def bind_variables(self, variable_binds, level="testcase"): """ for variable_bind in variable_binds: for variable_name, value in variable_bind.items(): - variable_evale_value = self.get_eval_value(value) + variable_evale_value = testcase.parse_content_with_bindings( + value, + self.testcase_variables_mapping, + self.testcase_functions_config + ) if level == "testset": self.testset_shared_variables_mapping[variable_name] = variable_evale_value @@ -126,48 +130,13 @@ def __update_context_request_config(self, level, config_mapping): def get_parsed_request(self): """ get parsed request, with each variable replaced by bind value. """ - parsed_request = testcase.parse_template( + parsed_request = testcase.parse_content_with_bindings( self.testcase_request_config, - self.testcase_variables_mapping + self.testcase_variables_mapping, + self.testcase_functions_config ) return parsed_request def get_testcase_variables_mapping(self): return self.testcase_variables_mapping - - def get_eval_value(self, data): - """ evaluate data recursively, each variable in data will be evaluated. - """ - if isinstance(data, (list, tuple)): - return [self.get_eval_value(item) for item in data] - - if isinstance(data, dict): - evaluated_data = {} - for key, value in data.items(): - evaluated_data[key] = self.get_eval_value(value) - - return evaluated_data - - if isinstance(data, (int, float)): - return data - - # data is in string format here - data = "" if data is None else data.strip() - - if utils.is_functon(data): - # function marker: ${func(1, 2, a=3, b=4)} - fuction_meta = utils.parse_function(data) - func_name = fuction_meta['func_name'] - args = fuction_meta.get('args', []) - kwargs = fuction_meta.get('kwargs', {}) - args = self.get_eval_value(args) - kwargs = self.get_eval_value(kwargs) - return self.testcase_functions_config[func_name](*args, **kwargs) - - elif utils.get_contain_variables(data): - parsed_data = utils.parse_variables(data, self.testcase_variables_mapping) - return parsed_data - - else: - return data diff --git a/ate/testcase.py b/ate/testcase.py index c4c58769e..55f5f1075 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,77 +1,87 @@ -import re from ate.exception import ParamsError -from ate.utils import string_type +from ate import utils -def parse_content_with_variables(content, variables_binds): - """ replace variables with bind value - """ - # check if content includes $variable - matched = re.match(r"^(.*)\$(\w+)(.*)$", content) - if matched: - # this is a variable, and will replace with its bind value - variable_name = matched.group(2) - value = variables_binds.get(variable_name) - if value is None: - raise ParamsError( - "%s is not defined in bind variables!" % variable_name) - if matched.group(1) or matched.group(3): - # e.g. /api/users/$uid - return content.replace("$%s" % variable_name, value) - - return value - - return content +def parse_content_with_bindings(content, variables_binds, functions_binds): + """ evaluate content recursively, each variable in content will be + evaluated with bind variables and functions. -def parse_template(testcase_template, variables_binds): - """ parse testcase_template, replace all variables with bind value. variables marker: $variable. - @param (dict) testcase_template + @param (dict) content in any data structure { "url": "http://127.0.0.1:5000/api/users/$uid", "method": "POST", "headers": { "Content-Type": "application/json", "authorization": "$authorization", - "random": "$random" + "random": "$random", + "sum": "${add_two_nums(1, 2)}" }, "body": "$data" } - @param (dict) variables binds mapping + @param (dict) variables_binds, variables binds mapping { "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", "random": "A2dEx", - "data": '{"name": "user", "password": "123456"}' + "data": {"name": "user", "password": "123456"} } - @return (dict) parsed testcase with bind variable values + @param (dict) functions_binds, functions binds mapping + { + "add_two_nums": lambda a, b=1: a + b + } + @return (dict) parsed content with evaluated bind values { "url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", "headers": { "Content-Type": "application/json", "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx" + "random": "A2dEx", + "sum": 3 }, - "body": '{"name": "user", "password": "123456"}' + "body": {"name": "user", "password": "123456"} } """ - def substitute(content): - """ substitute content recursively, each variable will be replaced with bind value. - """ - if isinstance(content, string_type): - return parse_content_with_variables(content, variables_binds) - - if isinstance(content, list): - return [substitute(item) for item in content] + if isinstance(content, (list, tuple)): + return [ + parse_content_with_bindings(item, variables_binds, functions_binds) + for item in content + ] - if isinstance(content, dict): - parsed_content = {} - for key, value in content.items(): - parsed_content[key] = substitute(value) + if isinstance(content, dict): + evaluated_data = {} + for key, value in content.items(): + evaluated_data[key] = parse_content_with_bindings( + value, variables_binds, functions_binds) - return parsed_content + return evaluated_data + if isinstance(content, (int, float)): return content - return substitute(testcase_template) + # content is in string format here + content = "" if content is None else content.strip() + + if utils.is_functon(content): + # function marker: ${func(1, 2, a=3, b=4)} + fuction_meta = utils.parse_function(content) + func_name = fuction_meta['func_name'] + + func = functions_binds.get(func_name) + if func is None: + raise ParamsError( + "%s is not defined in bind functions!" % func_name) + + args = fuction_meta.get('args', []) + kwargs = fuction_meta.get('kwargs', {}) + args = parse_content_with_bindings(args, variables_binds, functions_binds) + kwargs = parse_content_with_bindings(kwargs, variables_binds, functions_binds) + return func(*args, **kwargs) + + elif utils.get_contain_variables(content): + parsed_data = utils.parse_variables(content, variables_binds) + return parsed_data + + else: + return content diff --git a/tests/test_context.py b/tests/test_context.py index d7ae5d96a..0205de02e 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -188,31 +188,3 @@ def test_get_parsed_request(self): self.assertEqual(len(parsed_request["headers"]["random"]), 5) self.assertIn("data", parsed_request) self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) - - def test_get_eval_value(self): - self.context.testcase_variables_mapping = { - "str_1": "str_value1", - "str_2": "str_value2" - } - self.assertEqual(self.context.get_eval_value("$str_1"), "str_value1") - self.assertEqual(self.context.get_eval_value("$str_2"), "str_value2") - self.assertEqual( - self.context.get_eval_value(["$str_1", "str3"]), - ["str_value1", "str3"] - ) - self.assertEqual( - self.context.get_eval_value({"key": "$str_1"}), - {"key": "str_value1"} - ) - - import random, string - self.context.testcase_functions_config["gen_random_string"] = \ - lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ - for _ in range(str_len)) - result = self.context.get_eval_value("${gen_random_string(5)}") - self.assertEqual(len(result), 5) - - add_two_nums = lambda a, b=1: a + b - self.context.testcase_functions_config["add_two_nums"] = add_two_nums - self.assertEqual(self.context.get_eval_value("${add_two_nums(1)}"), 2) - self.assertEqual(self.context.get_eval_value("${add_two_nums(1, 2)}"), 3) diff --git a/tests/test_testcase.py b/tests/test_testcase.py index d32c9a3e6..7f46fe19c 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -1,120 +1,111 @@ import unittest -from ate.testcase import parse_template, parse_content_with_variables -from ate import exception +from ate.exception import ParamsError +from ate.testcase import parse_content_with_bindings class TestcaseParserUnittest(unittest.TestCase): - def setUp(self): - self.variables_binds = { - "uid": "1000", - "random": "A2dEx", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "json": { - "name": "user1", - "password": "123456" - }, - "expected_status": 201, - "expected_success": True - } - - def test_parse_testcase_template(self): - testcase = { - "request": { - "url": "http://127.0.0.1:5000/api/users/$uid", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "authorization": "$authorization", - "random": "$random" - }, - "body": "$json" - }, - "response": { - "status_code": "$expected_status", - "headers": { - "Content-Type": "application/json" - }, - "body": { - "success": "$expected_success", - "msg": "user created successfully." - } - } + def test_parse_content_with_bindings_variables(self): + variables_binds = { + "str_1": "str_value1", + "str_2": "str_value2" } - parsed_testcase = parse_template(testcase, self.variables_binds) - self.assertEqual( - parsed_testcase["request"]["url"], - "http://127.0.0.1:5000/api/users/%s" % self.variables_binds["uid"] + parse_content_with_bindings("$str_1", variables_binds, {}), + "str_value1" ) self.assertEqual( - parsed_testcase["request"]["headers"]["authorization"], - self.variables_binds["authorization"] - ) - self.assertEqual( - parsed_testcase["request"]["headers"]["random"], - self.variables_binds["random"] + parse_content_with_bindings("123$str_1/456", variables_binds, {}), + "123str_value1/456" ) + + with self.assertRaises(ParamsError): + parse_content_with_bindings("$str_3", variables_binds, {}) + self.assertEqual( - parsed_testcase["request"]["body"], - self.variables_binds["json"] + parse_content_with_bindings(["$str_1", "str3"], variables_binds, {}), + ["str_value1", "str3"] ) self.assertEqual( - parsed_testcase["response"]["status_code"], - self.variables_binds["expected_status"] + parse_content_with_bindings({"key": "$str_1"}, variables_binds, {}), + {"key": "str_value1"} ) + + def test_parse_content_with_bindings_multiple_identical_variables(self): + variables_binds = { + "userid": 100, + "data": 1498 + } + content = "/users/$userid/training/$data?userId=$userid&data=$data" self.assertEqual( - parsed_testcase["response"]["body"]["success"], - self.variables_binds["expected_success"] + parse_content_with_bindings(content, variables_binds, {}), + "/users/100/training/1498?userId=100&data=1498" ) - def test_parse_testcase_template_miss_bind_variable(self): - testcase = { - "request": { - "url": "http://127.0.0.1:5000/api/users/$uid", - "method": "$method" - } + def test_parse_content_with_bindings_functions(self): + import random, string + functions_binds = { + "gen_random_string": lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ + for _ in range(str_len)) } - with self.assertRaises(exception.ParamsError): - parse_template(testcase, self.variables_binds) - def test_parse_testcase_with_new_variable_binds(self): - testcase = { - "request": { - "url": "http://127.0.0.1:5000/api/users/$uid", - "method": "$method" - } - } - new_variable_binds = { - "method": "GET" - } - self.variables_binds.update(new_variable_binds) - parsed_testcase = parse_template(testcase, self.variables_binds) + result = parse_content_with_bindings("${gen_random_string(5)}", {}, functions_binds) + self.assertEqual(len(result), 5) + add_two_nums = lambda a, b=1: a + b + functions_binds["add_two_nums"] = add_two_nums self.assertEqual( - parsed_testcase["request"]["method"], - new_variable_binds["method"] + parse_content_with_bindings("${add_two_nums(1)}", {}, functions_binds), + 2 + ) + self.assertEqual( + parse_content_with_bindings("${add_two_nums(1, 2)}", {}, functions_binds), + 3 ) - def test_parse_content_with_variables(self): - content = "$var" + def test_parse_content_with_bindings_testcase(self): variables_binds = { - "var": "abc" + "uid": "1000", + "random": "A2dEx", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "data": {"name": "user", "password": "123456"}, + "expected_status": 201, + "expected_success": True } - result = parse_content_with_variables(content, variables_binds) - self.assertEqual(result, "abc") - - content = "123$var/456" - variables_binds = { - "var": "abc" + functions_binds = { + "add_two_nums": lambda a, b=1: a + b } - result = parse_content_with_variables(content, variables_binds) - self.assertEqual(result, "123abc/456") - - content = "$var1" - variables_binds = { - "var2": "abc" + testcase = { + "url": "http://127.0.0.1:5000/api/users/$uid", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "$authorization", + "random": "$random", + "sum": "${add_two_nums(1, 2)}" + }, + "body": "$data" } - with self.assertRaises(exception.ParamsError): - parse_content_with_variables(content, variables_binds) + parsed_testcase = parse_content_with_bindings(testcase, variables_binds, functions_binds) + + self.assertEqual( + parsed_testcase["url"], + "http://127.0.0.1:5000/api/users/%s" % variables_binds["uid"] + ) + self.assertEqual( + parsed_testcase["headers"]["authorization"], + variables_binds["authorization"] + ) + self.assertEqual( + parsed_testcase["headers"]["random"], + variables_binds["random"] + ) + self.assertEqual( + parsed_testcase["body"], + variables_binds["data"] + ) + self.assertEqual( + parsed_testcase["headers"]["sum"], + 3 + ) From 5030a91a9d9168c7c3807deb81359f5c08aa1446 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 22:27:02 +0800 Subject: [PATCH 151/354] update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1632676a0..3e46e9a26 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], - Inherit all powerful features of [`Requests`][requests], just have fun to handle HTTP in human way. - Define testcases in YAML or JSON format in concise and elegant manner. - Supports `function`/`variable`/`extract`/`validate` mechanisms to create full test scenarios. -- Testcases can be run in diverse ways, with single testset, multiple testsets, or whole project folder. +- Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. - Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. -- Perfect combination with [Jenkins][Jenkins], running continuous test and production monitoring. +- Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. - With reuse of [`Locust`][Locust], you can run performance test without extra work. - It is extensible to facilitate the implementation of web platform with [`Flask`][flask] framework. From 5267d4f5e581c5e6c99424f4483a4034240ebc49 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 22:51:28 +0800 Subject: [PATCH 152/354] restructure code: move code related to testcase from ate/utils.py to ate/testcase.py --- ate/testcase.py | 124 +++++++++++++++++++++++++-- ate/utils.py | 111 ------------------------ tests/test_testcase.py | 190 ++++++++++++++++++++++++++++++++++++++--- tests/test_utils.py | 165 ----------------------------------- 4 files changed, 297 insertions(+), 293 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 55f5f1075..305da8404 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,6 +1,120 @@ -from ate.exception import ParamsError +import ast +import re + from ate import utils +from ate.exception import ParamsError + +variable_regexp = r"\$([\w_]+)" +function_regexp = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") + + +def get_contain_variables(content): + """ extract all variable names from content, which is in format $variable + @param (str) content + @return (list) variable name list + + e.g. $variable => ["variable"] + /blog/$postid => ["postid"] + /$var1/$var2 => ["var1", "var2"] + abc => [] + """ + return re.findall(variable_regexp, content) + +def parse_variables(content, variable_mapping): + """ replace all variables of string content with mapping value. + @param (str) content + @return (str) parsed content + + e.g. + variable_mapping = { + "var_1": "abc", + "var_2": "def" + } + $var_1 => "abc" + $var_1#XYZ => "abc#XYZ" + /$var_1/$var_2/var3 => "/abc/def/var3" + ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" + """ + variable_name_list = get_contain_variables(content) + for variable_name in variable_name_list: + variable_value = variable_mapping.get(variable_name) + if variable_value is None: + raise ParamsError( + "%s is not defined in bind variables!" % variable_name) + + if "${}".format(variable_name) == content: + # content is a variable + content = variable_value + else: + # content contains one or many variables + content = content.replace( + "${}".format(variable_name), + str(variable_value) + ) + + return content + +def is_functon(content): + """ check if content is a function, which is in format ${func()} + @param (str) content + @return (bool) True or False + + e.g. ${func()} => True + ${func(5)} => True + ${func(1, 2)} => True + ${func(a=1, b=2)} => True + $abc => False + abc => False + """ + matched = function_regexp.match(content) + return True if matched else False + +def parse_string_value(str_value): + """ parse string to number if possible + e.g. "123" => 123 + "12.2" => 12.3 + "abc" => "abc" + "$var" => "$var" + """ + try: + return ast.literal_eval(str_value) + except ValueError: + return str_value + except SyntaxError: + # e.g. $var, ${func} + return str_value + +def parse_function(content): + """ parse function name and args from string content. + @param (str) content + @return (dict) function name and args + + e.g. ${func()} => {'func_name': 'func', 'args': [], 'kwargs': {}} + ${func(5)} => {'func_name': 'func', 'args': [5], 'kwargs': {}} + ${func(1, 2)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} + ${func(a=1, b=2)} => {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + ${func(1, 2, a=3, b=4)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4}} + """ + function_meta = { + "args": [], + "kwargs": {} + } + matched = function_regexp.match(content) + function_meta["func_name"] = matched.group(1) + + args_str = matched.group(2).replace(" ", "") + if args_str == "": + return function_meta + + args_list = args_str.split(',') + for arg in args_list: + if '=' in arg: + key, value = arg.split('=') + function_meta["kwargs"][key] = parse_string_value(value) + else: + function_meta["args"].append(parse_string_value(arg)) + return function_meta def parse_content_with_bindings(content, variables_binds, functions_binds): """ evaluate content recursively, each variable in content will be @@ -63,9 +177,9 @@ def parse_content_with_bindings(content, variables_binds, functions_binds): # content is in string format here content = "" if content is None else content.strip() - if utils.is_functon(content): + if is_functon(content): # function marker: ${func(1, 2, a=3, b=4)} - fuction_meta = utils.parse_function(content) + fuction_meta = parse_function(content) func_name = fuction_meta['func_name'] func = functions_binds.get(func_name) @@ -79,8 +193,8 @@ def parse_content_with_bindings(content, variables_binds, functions_binds): kwargs = parse_content_with_bindings(kwargs, variables_binds, functions_binds) return func(*args, **kwargs) - elif utils.get_contain_variables(content): - parsed_data = utils.parse_variables(content, variables_binds) + elif get_contain_variables(content): + parsed_data = parse_variables(content, variables_binds) return parsed_data else: diff --git a/ate/utils.py b/ate/utils.py index 437dc07ee..32ea11664 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,4 +1,3 @@ -import ast import hashlib import hmac import json @@ -18,8 +17,6 @@ PYTHON_VERSION = 3 SECRET_KEY = "DebugTalk" -variable_regexp = r"\$([\w_]+)" -function_regexp = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") def gen_random_string(str_len): return ''.join( @@ -117,114 +114,6 @@ def load_testcases_by_path(path): else: return [] -def get_contain_variables(content): - """ extract all variable names from content, which is in format $variable - @param (str) content - @return (list) variable name list - - e.g. $variable => ["variable"] - /blog/$postid => ["postid"] - /$var1/$var2 => ["var1", "var2"] - abc => [] - """ - return re.findall(variable_regexp, content) - -def parse_variables(content, variable_mapping): - """ replace all variables of string content with mapping value. - @param (str) content - @return (str) parsed content - - e.g. - variable_mapping = { - "var_1": "abc", - "var_2": "def" - } - $var_1 => "abc" - $var_1#XYZ => "abc#XYZ" - /$var_1/$var_2/var3 => "/abc/def/var3" - ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" - """ - variable_name_list = get_contain_variables(content) - for variable_name in variable_name_list: - variable_value = variable_mapping.get(variable_name) - if variable_value is None: - raise ParamsError( - "%s is not defined in bind variables!" % variable_name) - - if "${}".format(variable_name) == content: - # content is a variable - content = variable_value - else: - # content contains one or many variables - content = content.replace( - "${}".format(variable_name), - str(variable_value) - ) - - return content - -def is_functon(content): - """ check if content is a function, which is in format ${func()} - @param (str) content - @return (bool) True or False - - e.g. ${func()} => True - ${func(5)} => True - ${func(1, 2)} => True - ${func(a=1, b=2)} => True - $abc => False - abc => False - """ - matched = function_regexp.match(content) - return True if matched else False - -def parse_string_value(str_value): - """ parse string to number if possible - e.g. "123" => 123 - "12.2" => 12.3 - "abc" => "abc" - "$var" => "$var" - """ - try: - return ast.literal_eval(str_value) - except ValueError: - return str_value - except SyntaxError: - # e.g. $var, ${func} - return str_value - -def parse_function(content): - """ parse function name and args from string content. - @param (str) content - @return (dict) function name and args - - e.g. ${func()} => {'func_name': 'func', 'args': [], 'kwargs': {}} - ${func(5)} => {'func_name': 'func', 'args': [5], 'kwargs': {}} - ${func(1, 2)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} - ${func(a=1, b=2)} => {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} - ${func(1, 2, a=3, b=4)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4}} - """ - function_meta = { - "args": [], - "kwargs": {} - } - matched = function_regexp.match(content) - function_meta["func_name"] = matched.group(1) - - args_str = matched.group(2).replace(" ", "") - if args_str == "": - return function_meta - - args_list = args_str.split(',') - for arg in args_list: - if '=' in arg: - key, value = arg.split('=') - function_meta["kwargs"][key] = parse_string_value(value) - else: - function_meta["args"].append(parse_string_value(arg)) - - return function_meta - def query_json(json_content, query, delimiter='.'): """ Do an xpath-like query with json_content. @param (json_content) json_content diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 7f46fe19c..6f201ac85 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -1,34 +1,199 @@ import unittest from ate.exception import ParamsError -from ate.testcase import parse_content_with_bindings +from ate import testcase class TestcaseParserUnittest(unittest.TestCase): + def test_get_contain_variables(self): + self.assertEqual( + testcase.get_contain_variables("$var"), + ["var"] + ) + self.assertEqual( + testcase.get_contain_variables("$var123"), + ["var123"] + ) + self.assertEqual( + testcase.get_contain_variables("$var_name"), + ["var_name"] + ) + self.assertEqual( + testcase.get_contain_variables("var"), + [] + ) + self.assertEqual( + testcase.get_contain_variables("a$var"), + ["var"] + ) + self.assertEqual( + testcase.get_contain_variables("$v ar"), + ["v"] + ) + self.assertEqual( + testcase.get_contain_variables(" "), + [] + ) + self.assertEqual( + testcase.get_contain_variables("$abc*"), + ["abc"] + ) + self.assertEqual( + testcase.get_contain_variables("${func()}"), + [] + ) + self.assertEqual( + testcase.get_contain_variables("${func(1,2)}"), + [] + ) + self.assertEqual( + testcase.get_contain_variables("${gen_md5($TOKEN, $data, $random)}"), + ["TOKEN", "data", "random"] + ) + + def test_parse_variables(self): + variable_mapping = { + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": {"a": 1}, + "var_5": True + } + self.assertEqual( + testcase.parse_variables("$var_1", variable_mapping), + "abc" + ) + self.assertEqual( + testcase.parse_variables("var_1", variable_mapping), + "var_1" + ) + self.assertEqual( + testcase.parse_variables("$var_1#XYZ", variable_mapping), + "abc#XYZ" + ) + self.assertEqual( + testcase.parse_variables("/$var_1/$var_2/var3", variable_mapping), + "/abc/def/var3" + ) + self.assertEqual( + testcase.parse_variables("/$var_1/$var_2/$var_1", variable_mapping), + "/abc/def/abc" + ) + self.assertEqual( + testcase.parse_variables("${func($var_1, $var_2, xyz)}", variable_mapping), + "${func(abc, def, xyz)}" + ) + self.assertEqual( + testcase.parse_variables("$var_3", variable_mapping), + 123 + ) + self.assertEqual( + testcase.parse_variables("$var_4", variable_mapping), + {"a": 1} + ) + self.assertEqual( + testcase.parse_variables("$var_5", variable_mapping), + True + ) + self.assertEqual( + testcase.parse_variables("abc$var_5", variable_mapping), + "abcTrue" + ) + self.assertEqual( + testcase.parse_variables("abc$var_4", variable_mapping), + "abc{'a': 1}" + ) + + def test_is_functon(self): + content = "${func()}" + self.assertTrue(testcase.is_functon(content)) + content = "${func(5)}" + self.assertTrue(testcase.is_functon(content)) + content = "${func(1, 2)}" + self.assertTrue(testcase.is_functon(content)) + content = "${func($a, $b)}" + self.assertTrue(testcase.is_functon(content)) + content = "${func(a=1, b=2)}" + self.assertTrue(testcase.is_functon(content)) + content = "${func(1, 2, a=3, b=4)}" + self.assertTrue(testcase.is_functon(content)) + content = "${func(1, $b, c=$x, d=4)}" + self.assertTrue(testcase.is_functon(content)) + content = "${func}" + self.assertFalse(testcase.is_functon(content)) + content = "$abc" + self.assertFalse(testcase.is_functon(content)) + content = "abc" + self.assertFalse(testcase.is_functon(content)) + + def test_parse_string_value(self): + str_value = "123" + self.assertEqual(testcase.parse_string_value(str_value), 123) + str_value = "12.3" + self.assertEqual(testcase.parse_string_value(str_value), 12.3) + str_value = "a123" + self.assertEqual(testcase.parse_string_value(str_value), "a123") + str_value = "$var" + self.assertEqual(testcase.parse_string_value(str_value), "$var") + str_value = "${func}" + self.assertEqual(testcase.parse_string_value(str_value), "${func}") + + def test_parse_functon(self): + content = "${func()}" + self.assertEqual( + testcase.parse_function(content), + {'func_name': 'func', 'args': [], 'kwargs': {}} + ) + content = "${func(5)}" + self.assertEqual( + testcase.parse_function(content), + {'func_name': 'func', 'args': [5], 'kwargs': {}} + ) + content = "${func(1, 2)}" + self.assertEqual( + testcase.parse_function(content), + {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} + ) + content = "${func(a=1, b=2)}" + self.assertEqual( + testcase.parse_function(content), + {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + ) + content = "${func(a= 1, b =2)}" + self.assertEqual( + testcase.parse_function(content), + {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + ) + content = "${func(1, 2, a=3, b=4)}" + self.assertEqual( + testcase.parse_function(content), + {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a': 3, 'b': 4}} + ) + def test_parse_content_with_bindings_variables(self): variables_binds = { "str_1": "str_value1", "str_2": "str_value2" } self.assertEqual( - parse_content_with_bindings("$str_1", variables_binds, {}), + testcase.parse_content_with_bindings("$str_1", variables_binds, {}), "str_value1" ) self.assertEqual( - parse_content_with_bindings("123$str_1/456", variables_binds, {}), + testcase.parse_content_with_bindings("123$str_1/456", variables_binds, {}), "123str_value1/456" ) with self.assertRaises(ParamsError): - parse_content_with_bindings("$str_3", variables_binds, {}) + testcase.parse_content_with_bindings("$str_3", variables_binds, {}) self.assertEqual( - parse_content_with_bindings(["$str_1", "str3"], variables_binds, {}), + testcase.parse_content_with_bindings(["$str_1", "str3"], variables_binds, {}), ["str_value1", "str3"] ) self.assertEqual( - parse_content_with_bindings({"key": "$str_1"}, variables_binds, {}), + testcase.parse_content_with_bindings({"key": "$str_1"}, variables_binds, {}), {"key": "str_value1"} ) @@ -39,7 +204,7 @@ def test_parse_content_with_bindings_multiple_identical_variables(self): } content = "/users/$userid/training/$data?userId=$userid&data=$data" self.assertEqual( - parse_content_with_bindings(content, variables_binds, {}), + testcase.parse_content_with_bindings(content, variables_binds, {}), "/users/100/training/1498?userId=100&data=1498" ) @@ -50,17 +215,17 @@ def test_parse_content_with_bindings_functions(self): for _ in range(str_len)) } - result = parse_content_with_bindings("${gen_random_string(5)}", {}, functions_binds) + result = testcase.parse_content_with_bindings("${gen_random_string(5)}", {}, functions_binds) self.assertEqual(len(result), 5) add_two_nums = lambda a, b=1: a + b functions_binds["add_two_nums"] = add_two_nums self.assertEqual( - parse_content_with_bindings("${add_two_nums(1)}", {}, functions_binds), + testcase.parse_content_with_bindings("${add_two_nums(1)}", {}, functions_binds), 2 ) self.assertEqual( - parse_content_with_bindings("${add_two_nums(1, 2)}", {}, functions_binds), + testcase.parse_content_with_bindings("${add_two_nums(1, 2)}", {}, functions_binds), 3 ) @@ -76,7 +241,7 @@ def test_parse_content_with_bindings_testcase(self): functions_binds = { "add_two_nums": lambda a, b=1: a + b } - testcase = { + testcase_template = { "url": "http://127.0.0.1:5000/api/users/$uid", "method": "POST", "headers": { @@ -87,7 +252,8 @@ def test_parse_content_with_bindings_testcase(self): }, "body": "$data" } - parsed_testcase = parse_content_with_bindings(testcase, variables_binds, functions_binds) + parsed_testcase = testcase.parse_content_with_bindings( + testcase_template, variables_binds, functions_binds) self.assertEqual( parsed_testcase["url"], diff --git a/tests/test_utils.py b/tests/test_utils.py index 5556f6455..c918a6670 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -115,171 +115,6 @@ def test_load_testcases_by_path_not_exist(self): testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_3, []) - def test_get_contain_variables(self): - self.assertEqual( - utils.get_contain_variables("$var"), - ["var"] - ) - self.assertEqual( - utils.get_contain_variables("$var123"), - ["var123"] - ) - self.assertEqual( - utils.get_contain_variables("$var_name"), - ["var_name"] - ) - self.assertEqual( - utils.get_contain_variables("var"), - [] - ) - self.assertEqual( - utils.get_contain_variables("a$var"), - ["var"] - ) - self.assertEqual( - utils.get_contain_variables("$v ar"), - ["v"] - ) - self.assertEqual( - utils.get_contain_variables(" "), - [] - ) - self.assertEqual( - utils.get_contain_variables("$abc*"), - ["abc"] - ) - self.assertEqual( - utils.get_contain_variables("${func()}"), - [] - ) - self.assertEqual( - utils.get_contain_variables("${func(1,2)}"), - [] - ) - self.assertEqual( - utils.get_contain_variables("${gen_md5($TOKEN, $data, $random)}"), - ["TOKEN", "data", "random"] - ) - - def test_parse_variables(self): - variable_mapping = { - "var_1": "abc", - "var_2": "def", - "var_3": 123, - "var_4": {"a": 1}, - "var_5": True - } - self.assertEqual( - utils.parse_variables("$var_1", variable_mapping), - "abc" - ) - self.assertEqual( - utils.parse_variables("var_1", variable_mapping), - "var_1" - ) - self.assertEqual( - utils.parse_variables("$var_1#XYZ", variable_mapping), - "abc#XYZ" - ) - self.assertEqual( - utils.parse_variables("/$var_1/$var_2/var3", variable_mapping), - "/abc/def/var3" - ) - self.assertEqual( - utils.parse_variables("/$var_1/$var_2/$var_1", variable_mapping), - "/abc/def/abc" - ) - self.assertEqual( - utils.parse_variables("${func($var_1, $var_2, xyz)}", variable_mapping), - "${func(abc, def, xyz)}" - ) - self.assertEqual( - utils.parse_variables("$var_3", variable_mapping), - 123 - ) - self.assertEqual( - utils.parse_variables("$var_4", variable_mapping), - {"a": 1} - ) - self.assertEqual( - utils.parse_variables("$var_5", variable_mapping), - True - ) - self.assertEqual( - utils.parse_variables("abc$var_5", variable_mapping), - "abcTrue" - ) - self.assertEqual( - utils.parse_variables("abc$var_4", variable_mapping), - "abc{'a': 1}" - ) - - def test_is_functon(self): - content = "${func()}" - self.assertTrue(utils.is_functon(content)) - content = "${func(5)}" - self.assertTrue(utils.is_functon(content)) - content = "${func(1, 2)}" - self.assertTrue(utils.is_functon(content)) - content = "${func($a, $b)}" - self.assertTrue(utils.is_functon(content)) - content = "${func(a=1, b=2)}" - self.assertTrue(utils.is_functon(content)) - content = "${func(1, 2, a=3, b=4)}" - self.assertTrue(utils.is_functon(content)) - content = "${func(1, $b, c=$x, d=4)}" - self.assertTrue(utils.is_functon(content)) - content = "${func}" - self.assertFalse(utils.is_functon(content)) - content = "$abc" - self.assertFalse(utils.is_functon(content)) - content = "abc" - self.assertFalse(utils.is_functon(content)) - - def test_parse_string_value(self): - str_value = "123" - self.assertEqual(utils.parse_string_value(str_value), 123) - str_value = "12.3" - self.assertEqual(utils.parse_string_value(str_value), 12.3) - str_value = "a123" - self.assertEqual(utils.parse_string_value(str_value), "a123") - str_value = "$var" - self.assertEqual(utils.parse_string_value(str_value), "$var") - str_value = "${func}" - self.assertEqual(utils.parse_string_value(str_value), "${func}") - - def test_parse_functon(self): - content = "${func()}" - self.assertEqual( - utils.parse_function(content), - {'func_name': 'func', 'args': [], 'kwargs': {}} - ) - content = "${func(5)}" - self.assertEqual( - utils.parse_function(content), - {'func_name': 'func', 'args': [5], 'kwargs': {}} - ) - content = "${func(1, 2)}" - self.assertEqual( - utils.parse_function(content), - {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} - ) - content = "${func(a=1, b=2)}" - self.assertEqual( - utils.parse_function(content), - {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} - ) - content = "${func(a= 1, b =2)}" - self.assertEqual( - utils.parse_function(content), - {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} - ) - content = "${func(1, 2, a=3, b=4)}" - self.assertEqual( - utils.parse_function(content), - {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a': 3, 'b': 4}} - ) - def test_query_json(self): json_content = { "ids": [1, 2, 3, 4], From 0c2f48a6fe48cd7b8446fef4fe1d01d89c257131 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 22:57:00 +0800 Subject: [PATCH 153/354] simplify tests --- tests/test_testcase.py | 64 +++++++++++++++--------------------------- 1 file changed, 22 insertions(+), 42 deletions(-) diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 6f201ac85..44ef26784 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -106,68 +106,48 @@ def test_parse_variables(self): ) def test_is_functon(self): - content = "${func()}" - self.assertTrue(testcase.is_functon(content)) - content = "${func(5)}" - self.assertTrue(testcase.is_functon(content)) - content = "${func(1, 2)}" - self.assertTrue(testcase.is_functon(content)) - content = "${func($a, $b)}" - self.assertTrue(testcase.is_functon(content)) - content = "${func(a=1, b=2)}" - self.assertTrue(testcase.is_functon(content)) - content = "${func(1, 2, a=3, b=4)}" - self.assertTrue(testcase.is_functon(content)) - content = "${func(1, $b, c=$x, d=4)}" - self.assertTrue(testcase.is_functon(content)) - content = "${func}" - self.assertFalse(testcase.is_functon(content)) - content = "$abc" - self.assertFalse(testcase.is_functon(content)) - content = "abc" - self.assertFalse(testcase.is_functon(content)) + self.assertTrue(testcase.is_functon("${func()}")) + self.assertTrue(testcase.is_functon("${func(5)}")) + self.assertTrue(testcase.is_functon("${func(1, 2)}")) + self.assertTrue(testcase.is_functon("${func($a, $b)}")) + self.assertTrue(testcase.is_functon("${func(a=1, b=2)}")) + self.assertTrue(testcase.is_functon("${func(1, 2, a=3, b=4)}")) + self.assertTrue(testcase.is_functon("${func(1, $b, c=$x, d=4)}")) + self.assertFalse(testcase.is_functon("${func}")) + self.assertFalse(testcase.is_functon("$abc")) + self.assertFalse(testcase.is_functon("abc")) + self.assertFalse(testcase.is_functon("${}")) def test_parse_string_value(self): - str_value = "123" - self.assertEqual(testcase.parse_string_value(str_value), 123) - str_value = "12.3" - self.assertEqual(testcase.parse_string_value(str_value), 12.3) - str_value = "a123" - self.assertEqual(testcase.parse_string_value(str_value), "a123") - str_value = "$var" - self.assertEqual(testcase.parse_string_value(str_value), "$var") - str_value = "${func}" - self.assertEqual(testcase.parse_string_value(str_value), "${func}") + self.assertEqual(testcase.parse_string_value("123"), 123) + self.assertEqual(testcase.parse_string_value("12.3"), 12.3) + self.assertEqual(testcase.parse_string_value("a123"), "a123") + self.assertEqual(testcase.parse_string_value("$var"), "$var") + self.assertEqual(testcase.parse_string_value("${func}"), "${func}") def test_parse_functon(self): - content = "${func()}" self.assertEqual( - testcase.parse_function(content), + testcase.parse_function("${func()}"), {'func_name': 'func', 'args': [], 'kwargs': {}} ) - content = "${func(5)}" self.assertEqual( - testcase.parse_function(content), + testcase.parse_function("${func(5)}"), {'func_name': 'func', 'args': [5], 'kwargs': {}} ) - content = "${func(1, 2)}" self.assertEqual( - testcase.parse_function(content), + testcase.parse_function("${func(1, 2)}"), {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} ) - content = "${func(a=1, b=2)}" self.assertEqual( - testcase.parse_function(content), + testcase.parse_function("${func(a=1, b=2)}"), {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} ) - content = "${func(a= 1, b =2)}" self.assertEqual( - testcase.parse_function(content), + testcase.parse_function("${func(a= 1, b =2)}"), {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} ) - content = "${func(1, 2, a=3, b=4)}" self.assertEqual( - testcase.parse_function(content), + testcase.parse_function("${func(1, 2, a=3, b=4)}"), {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a': 3, 'b': 4}} ) From 60ef698ee6d506907e49c27bddebd537749b755d Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 31 Jul 2017 22:58:12 +0800 Subject: [PATCH 154/354] update version to 0.3.0 --- README.md | 2 +- ate/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e46e9a26..99827c66b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -0.2.2 +0.3.0 ``` Execute the command `ate -h` to view command help. diff --git a/ate/__init__.py b/ate/__init__.py index db9cf742d..290d7c60d 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.2.3' \ No newline at end of file +__version__ = '0.3.0' \ No newline at end of file From 5829e8d21b55b01784bd63408ce6d1383424d3f8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 1 Aug 2017 11:33:31 +0800 Subject: [PATCH 155/354] call function anywhere in testcase --- tests/data/demo_testset_template_import_functions.yml | 4 +--- tests/data/demo_testset_template_lambda_functions.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index a64412c0d..44e7feb7e 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -15,8 +15,6 @@ - test: name: get token - variable_binds: - - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: url: /api/get-token method: POST @@ -26,7 +24,7 @@ os_platform: $os_platform app_version: $app_version json: - sign: $sign + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract_binds: - token: content.token validators: diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index 72fa56158..7ea5c42eb 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -25,8 +25,6 @@ - test: name: get token - variable_binds: - - sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} request: url: /api/get-token method: POST @@ -36,7 +34,7 @@ os_platform: $os_platform app_version: $app_version json: - sign: $sign + sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} extract_binds: - token: content.token validators: From f15cea51de7c4b818b2d0c606bbc44fddac374dd Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 1 Aug 2017 14:59:59 +0800 Subject: [PATCH 156/354] update testcase example --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 99827c66b..fcb4df0d8 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,6 @@ And here is testset example of typical scenario: get token at the beginning, and - test: name: get token - variable_binds: - - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} request: url: /api/get-token method: POST @@ -97,7 +95,7 @@ And here is testset example of typical scenario: get token at the beginning, and os_platform: $os_platform app_version: $app_version json: - sign: $sign + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract_binds: - token: content.token validators: @@ -106,17 +104,14 @@ And here is testset example of typical scenario: get token at the beginning, and - test: name: create user which does not exist - variable_binds: - - user_name: "user1" - - user_password: "123456" request: url: /api/users/1000 method: POST headers: token: $token json: - name: $user_name - password: $user_password + name: "user1" + password: "123456" validators: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} From a55672e61200221a1fe61c92843fc5f927117d9d Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 11:37:40 +0800 Subject: [PATCH 157/354] remove termcolor --- ate/exception.py | 11 +---------- requirements_dev.txt | 1 - setup.py | 1 - 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/ate/exception.py b/ate/exception.py index 6c7473774..d6f765b06 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -1,16 +1,7 @@ #coding: utf-8 -from termcolor import colored class MyBaseError(BaseException): - def __init__(self, msg): - self.msg = msg - self.color_msg = colored(msg, 'red', attrs=['bold']) - - def __repr__(self): - return self.msg - - def __str__(self): - return self.color_msg + pass class ParamsError(MyBaseError): pass diff --git a/requirements_dev.txt b/requirements_dev.txt index 1bda70be8..a6e5169ad 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,4 @@ requests -termcolor flask PyYAML coveralls diff --git a/setup.py b/setup.py index 589ef2ad1..721d1f1b8 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,6 @@ keywords='api test', install_requires=[ "requests", - "termcolor", "flask", "PyYAML", "coveralls", From cd27f325ebf7325e58cddf3d44718f06b4530978 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 13:33:07 +0800 Subject: [PATCH 158/354] optimize response validation --- ate/exception.py | 6 ++++++ ate/response.py | 33 +++++++++++++++++++++------------ tests/test_response.py | 2 +- tests/test_runner.py | 2 +- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/ate/exception.py b/ate/exception.py index d6f765b06..56768985e 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -5,3 +5,9 @@ class MyBaseError(BaseException): class ParamsError(MyBaseError): pass + +class ParseResponseError(MyBaseError): + pass + +class ValidationError(MyBaseError): + pass diff --git a/ate/response.py b/ate/response.py index f7ba8dc78..38570388b 100644 --- a/ate/response.py +++ b/ate/response.py @@ -50,7 +50,7 @@ def extract_field(self, field, delimiter='.'): return json_content except AttributeError: - raise exception.ParamsError("invalid extract_binds!") + raise exception.ParseResponseError("failed to extract bind variable in response!") def extract_response(self, extract_binds): """ extract content from requests.Response @@ -68,7 +68,7 @@ def extract_response(self, extract_binds): for extract_bind in extract_binds: for key, field in extract_bind.items(): if not isinstance(field, utils.string_type): - raise exception.ParamsError("invalid extract_binds!") + raise exception.ParamsError("invalid extract_binds in testcase extract_binds!") extracted_variables_mapping_list.append( {key: self.extract_field(field)} @@ -99,19 +99,28 @@ def validate(self, validators, variables_mapping): for validator_dict in validators: - if "expected" not in validator_dict or "check" not in validator_dict: - raise exception.ParamsError("expected not specified in validator") + check_item = validator_dict.get("check") + if not check_item: + raise exception.ParamsError("invalid check item in testcase validators!") - validator_key = validator_dict["check"] - try: - validator_dict["value"] = variables_mapping[validator_key] - except KeyError: - validator_dict["value"] = self.extract_field(validator_key) + if "expected" not in validator_dict: + raise exception.ParamsError("expected item missed in testcase validators!") + + expected = validator_dict.get("expected") + comparator = validator_dict.get("comparator", "eq") + + if check_item in variables_mapping: + validator_dict["actual_value"] = variables_mapping[check_item] + else: + try: + validator_dict["actual_value"] = self.extract_field(check_item) + except exception.ParseResponseError: + raise exception.ParseResponseError("failed to extract check item in response!") match_expected = utils.match_expected( - validator_dict["value"], - validator_dict["expected"], - validator_dict.get("comparator", "eq") + validator_dict["actual_value"], + expected, + comparator ) if not match_expected: diff --git a/tests/test_response.py b/tests/test_response.py index 28316c178..ee1e97e3a 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -170,7 +170,7 @@ def test_validate(self): [ { "check": "resp_status_code", - "comparator": "eq", "expected": 201, "value": 200 + "comparator": "eq", "expected": 201, "actual_value": 200 } ] ) diff --git a/tests/test_runner.py b/tests/test_runner.py index 4ef52015c..ace76de8b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -66,7 +66,7 @@ def test_run_single_testcase_fail(self): self.assertFalse(success) self.assertEqual( diff_content_list[0], - {"check": "status_code", "comparator": "eq", "expected": 205, 'value': 200} + {"check": "status_code", "comparator": "eq", "expected": 205, 'actual_value': 200} ) def test_run_testset_hardcode(self): From a18c7bc62f1c73f6cbebdfdb5f53552839eb508a Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 14:19:14 +0800 Subject: [PATCH 159/354] optimize exception type --- ate/utils.py | 13 +++++-------- tests/test_response.py | 4 ++-- tests/test_utils.py | 9 ++++----- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 32ea11664..4127dbaf7 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -7,7 +7,7 @@ import string import yaml -from ate.exception import ParamsError +from ate import exception try: string_type = basestring @@ -47,7 +47,7 @@ def load_testcases(testcase_file_path): return load_yaml_file(testcase_file_path) else: # '' or other suffix - raise ParamsError("Bad testcase file name!") + return [] def load_foler_files(folder_path): """ load folder path, return all files in list format. @@ -96,10 +96,7 @@ def load_testcases_by_path(path): "config": {}, "testcases": [] } - try: - testcases_list = load_testcases(path) - except ParamsError: - return [] + testcases_list = load_testcases(path) for item in testcases_list: for key in item: @@ -143,7 +140,7 @@ def query_json(json_content, query, delimiter='.'): key = int(key) json_content = json_content[key] except (KeyError, ValueError, IndexError): - raise ParamsError("invalid query string in extract_binds!") + raise exception.ParseResponseError("failed to query json when extracting response!") return json_content @@ -191,7 +188,7 @@ def match_expected(value, expected, comparator="eq"): elif comparator in ["startswith"]: assert str(value).startswith(str(expected)) else: - raise ParamsError("comparator not supported!") + raise exception.ParamsError("comparator not supported!") return True except AssertionError: diff --git a/tests/test_response.py b/tests/test_response.py index ee1e97e3a..439f84207 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -116,7 +116,7 @@ def test_extract_response_fail(self): ] resp_obj = response.ResponseObject(resp) - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ParseResponseError): resp_obj.extract_response(extract_binds_list) extract_binds_list = [ @@ -124,7 +124,7 @@ def test_extract_response_fail(self): ] resp_obj = response.ResponseObject(resp) - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ParseResponseError): resp_obj.extract_response(extract_binds_list) def test_extract_response_json_string(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index c918a6670..863db016e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,8 +7,7 @@ class TestUtils(ApiServerUnittest): def test_load_testcases_bad_filepath(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo') - with self.assertRaises(exception.ParamsError): - utils.load_testcases(testcase_file_path) + self.assertEqual(utils.load_testcases(testcase_file_path), []) def test_load_json_testcases(self): testcase_file_path = os.path.join( @@ -132,11 +131,11 @@ def test_query_json(self): self.assertEqual(result, 3) query = "ids.str_key" - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ParseResponseError): utils.query_json(json_content, query) query = "ids.5" - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ParseResponseError): utils.query_json(json_content, query) query = "person.age" @@ -144,7 +143,7 @@ def test_query_json(self): self.assertEqual(result, 29) query = "person.not_exist_key" - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ParseResponseError): utils.query_json(json_content, query) query = "person.cities.0" From 4d471d7cda30eeba66c97efa967f5ea1e7b12bce Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 15:10:38 +0800 Subject: [PATCH 160/354] optimize response validation: if validation failed, then raise ValidationError directly --- ate/response.py | 14 ++++---------- ate/runner.py | 5 ++--- ate/task.py | 3 +-- ate/utils.py | 13 ++++++++++--- tests/test_response.py | 21 +++++---------------- tests/test_runner.py | 29 +++++++++++------------------ tests/test_utils.py | 11 ++++++++--- 7 files changed, 41 insertions(+), 55 deletions(-) diff --git a/ate/response.py b/ate/response.py index 38570388b..34dc0f433 100644 --- a/ate/response.py +++ b/ate/response.py @@ -8,7 +8,6 @@ def __init__(self, resp_obj): @param (requests.Response instance) resp_obj """ self.resp_obj = resp_obj - self.success = True def parsed_body(self): try: @@ -95,8 +94,6 @@ def validate(self, validators, variables_mapping): } ] """ - diff_content_list = [] - for validator_dict in validators: check_item = validator_dict.get("check") @@ -117,14 +114,11 @@ def validate(self, validators, variables_mapping): except exception.ParseResponseError: raise exception.ParseResponseError("failed to extract check item in response!") - match_expected = utils.match_expected( + utils.match_expected( validator_dict["actual_value"], expected, - comparator + comparator, + check_item ) - if not match_expected: - diff_content_list.append(validator_dict) - - self.success = False if diff_content_list else True - return diff_content_list + return True diff --git a/ate/runner.py b/ate/runner.py index cf11de8f1..0d384a7ab 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -101,10 +101,9 @@ def run_test(self, testcase): extracted_variables_mapping_list = resp_obj.extract_response(extract_binds) self.context.bind_variables(extracted_variables_mapping_list, level="testset") - diff_content_list = resp_obj.validate( - validators, self.context.get_testcase_variables_mapping()) + resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) - return resp_obj.success, diff_content_list + return True def run_testset(self, testset): """ run single testset, including one or several testcases. diff --git a/ate/task.py b/ate/task.py index 83dfc6f89..a85f08b9a 100644 --- a/ate/task.py +++ b/ate/task.py @@ -14,8 +14,7 @@ def __init__(self, test_runner, testcase): def runTest(self): """ run testcase and check result. """ - result = self.test_runner.run_test(self.testcase) - self.assertEqual(result, (True, [])) + self.assertTrue(self.test_runner.run_test(self.testcase)) def create_suite(testset): """ create test suite with a testset, it may include one or several testcases. diff --git a/ate/utils.py b/ate/utils.py index 4127dbaf7..432b8a6ef 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -144,11 +144,12 @@ def query_json(json_content, query, delimiter='.'): return json_content -def match_expected(value, expected, comparator="eq"): +def match_expected(value, expected, comparator="eq", check_item=""): """ check if value matches expected value. - @param value: value that get from response. + @param value: actual value that get from response. @param expected: expected result described in testcase @param comparator: compare method + @param check_item: check item name """ try: if comparator in ["eq", "equals", "=="]: @@ -192,7 +193,13 @@ def match_expected(value, expected, comparator="eq"): return True except AssertionError: - return False + err_msg = "\n".join([ + "check item name: %s;" % check_item, + "check item value: %s (%s);" % (value, type(value).__name__), + "comparator: %s;" % comparator, + "expected value: %s (%s)." % (expected, type(expected).__name__) + ]) + raise exception.ValidationError(err_msg) def deep_update_dict(origin_dict, override_dict): """ update origin dict with override dict recursively diff --git a/tests/test_response.py b/tests/test_response.py index 439f84207..b290c9471 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -163,17 +163,8 @@ def test_validate(self): "resp_body_success": True } - diff_content_list = resp_obj.validate(validators, variables_mapping) - self.assertFalse(resp_obj.success) - self.assertEqual( - diff_content_list, - [ - { - "check": "resp_status_code", - "comparator": "eq", "expected": 201, "actual_value": 200 - } - ] - ) + with self.assertRaises(exception.ValidationError): + resp_obj.validate(validators, variables_mapping) validators = [ {"check": "resp_status_code", "comparator": "eq", "expected": 201}, @@ -184,9 +175,7 @@ def test_validate(self): "resp_body_success": True } - diff_content_list = resp_obj.validate(validators, variables_mapping) - self.assertTrue(resp_obj.success) - self.assertEqual(diff_content_list, []) + self.assertTrue(resp_obj.validate(validators, variables_mapping)) def test_validate_exception(self): url = "http://127.0.0.1:5000/" @@ -199,7 +188,7 @@ def test_validate_exception(self): {"check": "body_success", "comparator": "eq"} ] variables_mapping = {} - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ValidationError): resp_obj.validate(validators, variables_mapping) # expected value missed in variables mapping @@ -210,5 +199,5 @@ def test_validate_exception(self): variables_mapping = { "resp_status_code": 200 } - with self.assertRaises(exception.ParamsError): + with self.assertRaises(exception.ValidationError): resp_obj.validate(validators, variables_mapping) diff --git a/tests/test_runner.py b/tests/test_runner.py index ace76de8b..c28872c01 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -25,16 +25,13 @@ def test_run_single_testcase(self): for testcase_file_path in self.testcase_file_path_list: testcases = utils.load_testcases(testcase_file_path) testcase = testcases[0]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) + self.assertTrue(self.test_runner.run_test(testcase)) testcase = testcases[1]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) + self.assertTrue(self.test_runner.run_test(testcase)) testcase = testcases[2]["test"] - success, _ = self.test_runner.run_test(testcase) - self.assertTrue(success) + self.assertTrue(self.test_runner.run_test(testcase)) def test_run_single_testcase_fail(self): testcase = { @@ -62,26 +59,22 @@ def test_run_single_testcase_fail(self): ] } - success, diff_content_list = self.test_runner.run_test(testcase) - self.assertFalse(success) - self.assertEqual( - diff_content_list[0], - {"check": "status_code", "comparator": "eq", "expected": 205, 'actual_value': 200} - ) + with self.assertRaises(exception.ValidationError): + self.test_runner.run_test(testcase) def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 3) - self.assertEqual(results, [(True, [])] * 3) + self.assertEqual(results, [True] * 3) def test_run_testsets_hardcode(self): for testcase_file_path in self.testcase_file_path_list: testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results, [[(True, [])] * 3]) + self.assertEqual(results, [[True] * 3]) def test_run_testset_template_variables(self): testcase_file_path = os.path.join( @@ -89,7 +82,7 @@ def test_run_testset_template_variables(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 3) - self.assertEqual(results, [(True, [])] * 3) + self.assertEqual(results, [True] * 3) def test_run_testset_template_import_functions(self): testcase_file_path = os.path.join( @@ -97,7 +90,7 @@ def test_run_testset_template_import_functions(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 3) - self.assertEqual(results, [(True, [])] * 3) + self.assertEqual(results, [True] * 3) def test_run_testsets_template_import_functions(self): testcase_file_path = os.path.join( @@ -105,7 +98,7 @@ def test_run_testsets_template_import_functions(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results, [[(True, [])] * 3]) + self.assertEqual(results, [[True] * 3]) def test_run_testsets_template_lambda_functions(self): testcase_file_path = os.path.join( @@ -113,4 +106,4 @@ def test_run_testsets_template_lambda_functions(self): testsets = utils.load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) - self.assertEqual(results, [[(True, [])] * 3]) + self.assertEqual(results, [[True] * 3]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 863db016e..ba01636d2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -158,8 +158,12 @@ def test_match_expected(self): self.assertTrue(utils.match_expected(1, 1, "eq")) self.assertTrue(utils.match_expected("abc", "abc", "eq")) self.assertTrue(utils.match_expected("abc", "abc")) - self.assertFalse(utils.match_expected(123, "123", "eq")) - self.assertFalse(utils.match_expected(123, "123")) + + with self.assertRaises(exception.ValidationError): + utils.match_expected(123, "123", "eq") + + with self.assertRaises(exception.ValidationError): + utils.match_expected(123, "123") self.assertTrue(utils.match_expected("123", 3, "len_eq")) self.assertTrue(utils.match_expected(123, "123", "str_eq")) @@ -179,7 +183,8 @@ def test_match_expected(self): self.assertTrue(utils.match_expected("3ab", "123abc456", "contained_by")) self.assertTrue(utils.match_expected("123abc456", "^123.*456$", "regex")) - self.assertFalse(utils.match_expected("123abc456", "^12b.*456$", "regex")) + with self.assertRaises(exception.ValidationError): + utils.match_expected("123abc456", "^12b.*456$", "regex") with self.assertRaises(exception.ParamsError): utils.match_expected(1, 2, "not_supported_comparator") From 74d653f94d79edf0adcf5a6ba3af5e7c9a168ac5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 15:21:37 +0800 Subject: [PATCH 161/354] bugfix: TypeError in match_expected --- ate/utils.py | 3 ++- tests/test_utils.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index 432b8a6ef..07f522ca8 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -192,7 +192,8 @@ def match_expected(value, expected, comparator="eq", check_item=""): raise exception.ParamsError("comparator not supported!") return True - except AssertionError: + + except (AssertionError, TypeError): err_msg = "\n".join([ "check item name: %s;" % check_item, "check item value: %s (%s);" % (value, type(value).__name__), diff --git a/tests/test_utils.py b/tests/test_utils.py index ba01636d2..3c6584f90 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -196,6 +196,12 @@ def test_match_expected(self): self.assertTrue(utils.match_expected("123abc", 12, "startswith")) self.assertTrue(utils.match_expected(12345, 123, "startswith")) + with self.assertRaises(exception.ValidationError): + utils.match_expected(None, 3, "len_eq") + + with self.assertRaises(exception.ValidationError): + utils.match_expected("abc", None, "gt") + def test_deep_update_dict(self): origin_dict = {'a': 1, 'b': {'c': 3, 'd': 4}, 'f': 6} override_dict = {'a': 2, 'b': {'c': 33, 'e': 5}, 'g': 7} From dd488bd48e1ff9fbeca4284ad6f854bfe938184e Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 15:32:50 +0800 Subject: [PATCH 162/354] add parser argument failfast --- README.md | 6 ++++-- ate/cli.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fcb4df0d8..909104772 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ Execute the command `ate -h` to view command help. ```text $ ate -h -usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] - [testset_paths [testset_paths ...]] +usage: main.py [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] + [--failfast] + [testset_paths [testset_paths ...]] Api Test Engine. @@ -60,6 +61,7 @@ optional arguments: Specify logging level, default is INFO. --report-name REPORT_NAME Specify report name, default is generated time. + --failfast Stop the test run on the first error or failure. ``` ## Write testcases diff --git a/ate/cli.py b/ate/cli.py index cd89904a5..1eab971de 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -24,6 +24,9 @@ def main(): parser.add_argument( '--report-name', help="Specify report name, default is generated time.") + parser.add_argument( + '--failfast', action='store_true', default=False, + help="Stop the test run on the first error or failure.") args = parser.parse_args() @@ -49,6 +52,6 @@ def main(): kwargs = { "output": output_folder_name, "report_name": report_name, - "failfast": True + "failfast": args.failfast } PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) From 393fea8d849b2125b91f80964344ffc13457e512 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 16:44:51 +0800 Subject: [PATCH 163/354] bugfix: comparison with None should raise exception in Python 2.7 --- ate/utils.py | 5 +++++ tests/test_utils.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index 07f522ca8..320412f64 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -152,6 +152,11 @@ def match_expected(value, expected, comparator="eq", check_item=""): @param check_item: check item name """ try: + if value is None or expected is None: + assert comparator in ["is", "eq", "equals", "=="] + assert value is None + assert expected is None + if comparator in ["eq", "equals", "=="]: assert value == expected elif comparator in ["str_eq", "string_equals"]: diff --git a/tests/test_utils.py b/tests/test_utils.py index 3c6584f90..9af1a1cb2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -196,9 +196,9 @@ def test_match_expected(self): self.assertTrue(utils.match_expected("123abc", 12, "startswith")) self.assertTrue(utils.match_expected(12345, 123, "startswith")) + self.assertTrue(utils.match_expected(None, None, "eq")) with self.assertRaises(exception.ValidationError): utils.match_expected(None, 3, "len_eq") - with self.assertRaises(exception.ValidationError): utils.match_expected("abc", None, "gt") From a7bb4c450be4a43701f13e5666f140e903ad489a Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 17:36:03 +0800 Subject: [PATCH 164/354] collect test result --- ate/cli.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index 1eab971de..56d73e70e 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -43,7 +43,9 @@ def main(): logging.warning("More than one testset paths specified, \ report name is ignored, use generated time instead.") - for testset_path in args.testset_paths: + results = {} + + for testset_path in set(args.testset_paths): testset_path = testset_path.strip('/') task_suite = create_task(testset_path) @@ -54,4 +56,13 @@ def main(): "report_name": report_name, "failfast": args.failfast } - PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) + result = PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) + results[testset_path] = { + "total": result.testsRun, + "successes": len(result.successes), + "failures": len(result.failures), + "errors": len(result.errors), + "skipped": len(result.skipped) + } + + return results From bb9440814493ef158b231f59f05c4ad46094f7b6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 23:30:54 +0800 Subject: [PATCH 165/354] add test result flag --- ate/cli.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index 56d73e70e..a06a5999a 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -1,12 +1,12 @@ -import os import argparse import logging +import os import PyUnitReport - from ate import __version__ from ate.task import create_task + def main(): """ parse command line options and run commands. """ @@ -44,6 +44,7 @@ def main(): report name is ignored, use generated time instead.") results = {} + flag = "SUCCESS" for testset_path in set(args.testset_paths): @@ -65,4 +66,7 @@ def main(): "skipped": len(result.skipped) } - return results + if len(result.successes) != result.testsRun: + flag = "FAILED" + + return flag, results From 8e3bf499a0789388b31f91b77c43fa517cc8e2c9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 2 Aug 2017 23:37:23 +0800 Subject: [PATCH 166/354] add jenkins-mail-py to support mail test result --- README.md | 42 ++++++++++++++++++++++++++++++++++++------ ate/__init__.py | 2 +- ate/cli.py | 9 +++++++++ requirements_dev.txt | 1 + setup.py | 8 +++++--- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 909104772..e01d9b2a0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], - Supports `function`/`variable`/`extract`/`validate` mechanisms to create full test scenarios. - Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. - Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. -- Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. +- Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. Send mail notification with [`jenkins-mail-py`][jenkins-mail-py]. - With reuse of [`Locust`][Locust], you can run performance test without extra work. - It is extensible to facilitate the implementation of web platform with [`Flask`][flask] framework. @@ -38,16 +38,21 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -0.3.0 +0.3.1 ``` Execute the command `ate -h` to view command help. ```text $ ate -h -usage: main.py [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] - [--failfast] - [testset_paths [testset_paths ...]] +usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] + [--failfast] [--mailgun-api-id MAILGUN_API_ID] + [--mailgun-api-key MAILGUN_API_KEY] [--email-sender EMAIL_SENDER] + [--email-recepients EMAIL_RECEPIENTS] [--mail-subject MAIL_SUBJECT] + [--mail-content MAIL_CONTENT] [--jenkins-job-name JENKINS_JOB_NAME] + [--jenkins-job-url JENKINS_JOB_URL] + [--jenkins-build-number JENKINS_BUILD_NUMBER] + [testset_paths [testset_paths ...]] Api Test Engine. @@ -62,6 +67,24 @@ optional arguments: --report-name REPORT_NAME Specify report name, default is generated time. --failfast Stop the test run on the first error or failure. + --mailgun-api-id MAILGUN_API_ID + Specify mailgun api id. + --mailgun-api-key MAILGUN_API_KEY + Specify mailgun api key. + --email-sender EMAIL_SENDER + Specify email sender. + --email-recepients EMAIL_RECEPIENTS + Specify email recepients. + --mail-subject MAIL_SUBJECT + Specify email subject. + --mail-content MAIL_CONTENT + Specify email content. + --jenkins-job-name JENKINS_JOB_NAME + Specify jenkins job name. + --jenkins-job-url JENKINS_JOB_URL + Specify jenkins job url. + --jenkins-build-number JENKINS_BUILD_NUMBER + Specify jenkins build number. ``` ## Write testcases @@ -143,6 +166,12 @@ If you want to run testsets of a whole project, you can achieve this goal by spe $ ate testcases_folder_path ``` +When you do continuous integration test or production environment monitoring with `Jenkins`, you may need to send test result notification. For instance, you can send email with mailgun service as below. + +```text +$ ate filepath/testcase.yml --mailgun-api-id samples.mailgun.org --mailgun-api-key key-3ax6xnjp29jd6fds4gc373sgvjxteol0 --email-sender excited@samples.mailgun.org --email-recepients test@email.com --jenkins-job-name demo-smoketest --jenkins-job-url http://test.debugtalk.com/job/demo-smoketest/ --jenkins-build-number 69 +``` + ## Supported Python Versions Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. @@ -162,4 +191,5 @@ Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. [Locust]: http://locust.io/ [flask]: http://flask.pocoo.org/ [PyUnitReport]: https://github.com/debugtalk/PyUnitReport -[Jenkins]: https://jenkins.io/index.html \ No newline at end of file +[Jenkins]: https://jenkins.io/index.html +[jenkins-mail-py]: https://github.com/debugtalk/jenkins-mail-py.git \ No newline at end of file diff --git a/ate/__init__.py b/ate/__init__.py index 290d7c60d..9c5adf70b 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.3.0' \ No newline at end of file +__version__ = '0.3.1' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index a06a5999a..0609e2bee 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -28,6 +28,12 @@ def main(): '--failfast', action='store_true', default=False, help="Stop the test run on the first error or failure.") + try: + from jenkins_mail_py import MailgunHelper + mailer = MailgunHelper(parser) + except ImportError: + mailer = None + args = parser.parse_args() if args.version: @@ -69,4 +75,7 @@ def main(): if len(result.successes) != result.testsRun: flag = "FAILED" + if mailer and mailer.config_ready: + mailer.send_mail(flag, content=results) + return flag, results diff --git a/requirements_dev.txt b/requirements_dev.txt index a6e5169ad..0b2c72d8c 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,3 +4,4 @@ PyYAML coveralls coverage -e git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport +-e git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py diff --git a/setup.py b/setup.py index 721d1f1b8..885f8d048 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( name='ApiTestEngine', version=version, - description='An API test engine.', + description='API test engine.', long_description=long_description, author='Leo Lee', author_email='mail@debugtalk.com', @@ -27,10 +27,12 @@ "PyYAML", "coveralls", "coverage", - "PyUnitReport" + "PyUnitReport", + "jenkins-mail-py" ], dependency_links=[ - "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport" + "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport", + "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py" ], classifiers=[ "Development Status :: 3 - Alpha", From 728c379f091edfeb0b30354d2753e7a0c6ad85ce Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 3 Aug 2017 10:53:33 +0800 Subject: [PATCH 167/354] bugfix: remove return value, otherwise jenkins job will consider it as failure --- ate/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index 0609e2bee..a7cf51724 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -77,5 +77,3 @@ def main(): if mailer and mailer.config_ready: mailer.send_mail(flag, content=results) - - return flag, results From 249cf0dc9b160468e197b4499d680f9070bf8d4d Mon Sep 17 00:00:00 2001 From: diaojunxian Date: Wed, 2 Aug 2017 14:19:24 +0800 Subject: [PATCH 168/354] fix bug|>update parse_variables and add testcase test_parse_variables_multiple_identical_variables --- ate/testcase.py | 2 +- tests/test_testcase.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ate/testcase.py b/ate/testcase.py index 305da8404..5ebd33d43 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -49,7 +49,7 @@ def parse_variables(content, variable_mapping): # content contains one or many variables content = content.replace( "${}".format(variable_name), - str(variable_value) + str(variable_value), 1 ) return content diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 44ef26784..371dffdb3 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -188,6 +188,19 @@ def test_parse_content_with_bindings_multiple_identical_variables(self): "/users/100/training/1498?userId=100&data=1498" ) + def test_parse_variables_multiple_identical_variables(self): + variables_binds = { + "user": 100, + "userid": 1000, + "data": 1498 + } + content = "/users/$user/$userid/$data?userId=$userid&data=$data" + self.assertEqual( + testcase.parse_content_with_bindings(content, variables_binds, {}), + "/users/100/1000/1498?userId=1000&data=1498" + ) + + def test_parse_content_with_bindings_functions(self): import random, string functions_binds = { From bfc18b38a3f1ff7f6e75d20a529507a946f22f43 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 3 Aug 2017 12:02:37 +0800 Subject: [PATCH 169/354] bugfix: when a variable binds to None, it should not raise exception --- ate/testcase.py | 4 ++-- tests/test_testcase.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 305da8404..fc8078911 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -37,11 +37,11 @@ def parse_variables(content, variable_mapping): """ variable_name_list = get_contain_variables(content) for variable_name in variable_name_list: - variable_value = variable_mapping.get(variable_name) - if variable_value is None: + if variable_name not in variable_mapping: raise ParamsError( "%s is not defined in bind variables!" % variable_name) + variable_value = variable_mapping.get(variable_name) if "${}".format(variable_name) == content: # content is a variable content = variable_value diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 44ef26784..137d25278 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -58,7 +58,8 @@ def test_parse_variables(self): "var_2": "def", "var_3": 123, "var_4": {"a": 1}, - "var_5": True + "var_5": True, + "var_6": None } self.assertEqual( testcase.parse_variables("$var_1", variable_mapping), @@ -104,6 +105,10 @@ def test_parse_variables(self): testcase.parse_variables("abc$var_4", variable_mapping), "abc{'a': 1}" ) + self.assertEqual( + testcase.parse_variables("$var_6", variable_mapping), + None + ) def test_is_functon(self): self.assertTrue(testcase.is_functon("${func()}")) From dd6b849845fad7044f46167785b4e9c0c3105efa Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 3 Aug 2017 12:05:57 +0800 Subject: [PATCH 170/354] update README --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e01d9b2a0..048560c63 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,14 @@ $ ate testcases_folder_path When you do continuous integration test or production environment monitoring with `Jenkins`, you may need to send test result notification. For instance, you can send email with mailgun service as below. ```text -$ ate filepath/testcase.yml --mailgun-api-id samples.mailgun.org --mailgun-api-key key-3ax6xnjp29jd6fds4gc373sgvjxteol0 --email-sender excited@samples.mailgun.org --email-recepients test@email.com --jenkins-job-name demo-smoketest --jenkins-job-url http://test.debugtalk.com/job/demo-smoketest/ --jenkins-build-number 69 +$ ate filepath/testcase.yml --report-name ${BUILD_NUMBER} \ + --mailgun-api-id samples.mailgun.org \ + --mailgun-api-key key-3ax6xnjp29jd6fds4gc373sgvjxteol0 \ + --email-sender excited@samples.mailgun.org \ + --email-recepients ${MAIL_RECEPIENTS} \ + --jenkins-job-name ${JOB_NAME} \ + --jenkins-job-url ${JOB_URL} \ + --jenkins-build-number ${BUILD_NUMBER} ``` ## Supported Python Versions From 4dbae83199d7e25326df590ae1cc3c23547bd1af Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 3 Aug 2017 14:20:57 +0800 Subject: [PATCH 171/354] add return value in cli.main to adapt jenkins: 1, if all testcases passed, then jenkins job pass; 2, if any testcase failed, then jenkins job fail. --- ate/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ate/cli.py b/ate/cli.py index a7cf51724..7761667ab 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -77,3 +77,5 @@ def main(): if mailer and mailer.config_ready: mailer.send_mail(flag, content=results) + + return 0 if flag == "SUCCESS" else 1 From dfba8143ee3880e2fbba373a3a34aee8336d18ca Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 3 Aug 2017 23:56:05 +0800 Subject: [PATCH 172/354] make comparator classified --- ate/utils.py | 37 ++++++++++++++++++++++++------------- tests/test_utils.py | 22 +++++++++------------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 320412f64..9d6d61517 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -159,40 +159,51 @@ def match_expected(value, expected, comparator="eq", check_item=""): if comparator in ["eq", "equals", "=="]: assert value == expected - elif comparator in ["str_eq", "string_equals"]: - assert str(value) == str(expected) + elif comparator in ["lt", "less_than"]: + assert value < expected + elif comparator in ["le", "less_than_or_equals"]: + assert value <= expected + elif comparator in ["gt", "greater_than"]: + assert value > expected + elif comparator in ["ge", "greater_than_or_equals"]: + assert value >= expected elif comparator in ["ne", "not_equals"]: assert value != expected - elif comparator in ["len_eq", "length_equal", "count_eq"]: + elif comparator in ["str_eq", "string_equals"]: + assert str(value) == str(expected) + elif comparator in ["len_eq", "length_equals", "count_eq"]: + assert isinstance(expected, int) assert len(value) == expected elif comparator in ["len_gt", "count_gt", "length_greater_than", "count_greater_than"]: + assert isinstance(expected, int) assert len(value) > expected elif comparator in ["len_ge", "count_ge", "length_greater_than_or_equals", \ "count_greater_than_or_equals"]: + assert isinstance(expected, int) assert len(value) >= expected elif comparator in ["len_lt", "count_lt", "length_less_than", "count_less_than"]: + assert isinstance(expected, int) assert len(value) < expected elif comparator in ["len_le", "count_le", "length_less_than_or_equals", \ "count_less_than_or_equals"]: + assert isinstance(expected, int) assert len(value) <= expected - elif comparator in ["lt", "less_than"]: - assert value < expected - elif comparator in ["le", "less_than_or_equals"]: - assert value <= expected - elif comparator in ["gt", "greater_than"]: - assert value > expected - elif comparator in ["ge", "greater_than_or_equals"]: - assert value >= expected elif comparator in ["contains"]: + assert isinstance(value, (list,tuple,dict,string_type)) assert expected in value elif comparator in ["contained_by"]: + assert isinstance(expected, (list,tuple,dict,string_type)) assert value in expected + elif comparator in ["type"]: + assert isinstance(value, expected) elif comparator in ["regex"]: + assert isinstance(expected, string_type) + assert isinstance(value, string_type) assert re.match(expected, value) - elif comparator in ["str_len", "string_length"]: - assert len(value) == int(expected) elif comparator in ["startswith"]: assert str(value).startswith(str(expected)) + elif comparator in ["endswith"]: + assert str(expected).startswith(str(value)) else: raise exception.ParamsError("comparator not supported!") diff --git a/tests/test_utils.py b/tests/test_utils.py index 9af1a1cb2..5d7f9b756 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -156,42 +156,38 @@ def test_query_json(self): def test_match_expected(self): self.assertTrue(utils.match_expected(1, 1, "eq")) - self.assertTrue(utils.match_expected("abc", "abc", "eq")) + self.assertTrue(utils.match_expected("abc", "abc", "==")) self.assertTrue(utils.match_expected("abc", "abc")) with self.assertRaises(exception.ValidationError): utils.match_expected(123, "123", "eq") - with self.assertRaises(exception.ValidationError): utils.match_expected(123, "123") - self.assertTrue(utils.match_expected("123", 3, "len_eq")) - self.assertTrue(utils.match_expected(123, "123", "str_eq")) + self.assertTrue(utils.match_expected(1, 2, "lt")) + self.assertTrue(utils.match_expected(1, 1, "le")) + self.assertTrue(utils.match_expected(2, 1, "gt")) + self.assertTrue(utils.match_expected(1, 1, "ge")) self.assertTrue(utils.match_expected(123, "123", "ne")) + self.assertTrue(utils.match_expected("123", 3, "len_eq")) self.assertTrue(utils.match_expected("123", 2, "len_gt")) self.assertTrue(utils.match_expected("123", 3, "len_ge")) self.assertTrue(utils.match_expected("123", 4, "len_lt")) self.assertTrue(utils.match_expected("123", 3, "len_le")) - self.assertTrue(utils.match_expected(1, 2, "lt")) - self.assertTrue(utils.match_expected(1, 1, "le")) - self.assertTrue(utils.match_expected(2, 1, "gt")) - self.assertTrue(utils.match_expected(1, 1, "ge")) - self.assertTrue(utils.match_expected("123abc456", "3ab", "contains")) + self.assertTrue(utils.match_expected(['1', '2'], "1", "contains")) + self.assertTrue(utils.match_expected({'a':1, 'b':2}, "a", "contains")) self.assertTrue(utils.match_expected("3ab", "123abc456", "contained_by")) - self.assertTrue(utils.match_expected("123abc456", "^123.*456$", "regex")) + self.assertTrue(utils.match_expected("123abc456", "^123\w+456$", "regex")) with self.assertRaises(exception.ValidationError): utils.match_expected("123abc456", "^12b.*456$", "regex") with self.assertRaises(exception.ParamsError): utils.match_expected(1, 2, "not_supported_comparator") - self.assertTrue(utils.match_expected("2017-06-29 17:29:58", 19, "str_len")) - self.assertTrue(utils.match_expected("2017-06-29 17:29:58", "19", "str_len")) - self.assertTrue(utils.match_expected("abc123", "ab", "startswith")) self.assertTrue(utils.match_expected("123abc", 12, "startswith")) self.assertTrue(utils.match_expected(12345, 123, "startswith")) From 61657454ba1d0e7bfc35064086e811bb4ebcaa68 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 3 Aug 2017 23:58:06 +0800 Subject: [PATCH 173/354] add doc of comparator --- docs/comparator.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/comparator.md diff --git a/docs/comparator.md b/docs/comparator.md new file mode 100644 index 000000000..74a7673fb --- /dev/null +++ b/docs/comparator.md @@ -0,0 +1,22 @@ +# Comparator + +| comparator | Description | A(check), B(expected) | examples | +|:------------:|-----------|------------------------------------|-----------| +| `eq`, `==` | value is equal | A == B | 9 eq 9 | +| `lt` | less than | A < B | 7 lt 8 | +| `le` | less than or equals | A <= B | 7 le 8, 8 le 8 | +| `gt` | greater than | A > B | 8 gt 7 | +| `ge` | greater than or equals | A >= B | 8 ge 7, 8 ge 8 | +| `ne` | not equals | A != B | 6 ne 9 | +| `str_eq` | string equals | str(A) == str(B) | 123 str_eq '123' | +| `len_eq`, `count_eq` | length or count equals | len(A) == B | 'abc' len_eq 3
[1,2] len_eq 2 | +| `len_gt`, `count_gt` | length greater than | len(A) > B | 'abc' len_gt 2
[1,2,3] len_gt 2 | +| `len_ge`, `count_ge` | length greater than or equals | len(A) >= B | 'abc' len_ge 3
[1,2,3] len_gt 3 | +| `len_lt`, `count_lt` | length less than | len(A) < B | 'abc' len_lt 4
[1,2,3] len_lt 4 | +| `len_le`, `count_le` | length less than or equals | len(A) <= B | 'abc' len_le 3
[1,2,3] len_le 3 | +| `contains` | contains | B in A | [1, 2] contains 1
'abc' contains 'a' | +| `contained_by` | contained by | A in B | 1 contained_by [1,2]
'a' contained_by 'abc' | +| `type` | type of A is instance of B | isinstance(A, B) | 123 type 'int' | +| `regex` | regex matches | re.match(B, A) | 'abcdef' regex 'a\w+d' | +| `startswith` | starts with | A.startswith(B) is True | 'abc' startswith 'ab' | +| `endswith` | ends with | A.endswith(B) is True | 'abc' endswith 'bc' | From d5da5941d725cfc46a112684d17730e9744bbc87 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 5 Aug 2017 10:57:00 +0800 Subject: [PATCH 174/354] add Python version 3.7-dev --- .travis.yml | 1 + README.md | 2 +- setup.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index b0ec3a619..3ab6479cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - 3.4 - 3.5 - 3.6 + - 3.7-dev install: - pip install -r requirements_dev.txt script: diff --git a/README.md b/README.md index 909104772..b8e2957af 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ $ ate testcases_folder_path ## Supported Python Versions -Python `2.7`, `3.3`, `3.4`, `3.5`, and `3.6`. +Python `2.7`, `3.3`, `3.4`, `3.5`, `3.6` and `3.7-dev`. ## To learn more ... diff --git a/setup.py b/setup.py index 721d1f1b8..bf7a4aea8 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,8 @@ 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6' + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7-dev' ], entry_points={ 'console_scripts': [ From ce94af9e572a0f4968992ce1e3baae571d412130 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 5 Aug 2017 14:04:54 +0800 Subject: [PATCH 175/354] add version number for dependent PyUnitReport --- README.md | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8e2957af..4f83dbdc4 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], ## Installation/Upgrade ```bash -$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine +$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine --process-dependency-links ``` To upgrade all specified packages to the newest available version, you should add the `-U` option. ```bash -$ pip install -U git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine +$ pip install -U git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine --process-dependency-links ``` If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). diff --git a/setup.py b/setup.py index bf7a4aea8..3516fe610 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ "PyUnitReport" ], dependency_links=[ - "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport" + "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0" ], classifiers=[ "Development Status :: 3 - Alpha", From 09d1037bb11faee04a58e6cf45883f4a58baf0ab Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 14 Aug 2017 11:34:48 +0800 Subject: [PATCH 176/354] fix dependency_links for jenkins-mail-py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 661b1484c..70beb0595 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ ], dependency_links=[ "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0", - "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py" + "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py-0" ], classifiers=[ "Development Status :: 3 - Alpha", From 0d3fd40602c08a6a707a2fc8c4058f476bc6ef71 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 14 Aug 2017 15:09:43 +0800 Subject: [PATCH 177/354] update with jenkins-mail-py 0.2.2 --- ate/cli.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index 7761667ab..b2799bd11 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -50,7 +50,7 @@ def main(): report name is ignored, use generated time instead.") results = {} - flag = "SUCCESS" + subject = "SUCCESS" for testset_path in set(args.testset_paths): @@ -73,9 +73,10 @@ def main(): } if len(result.successes) != result.testsRun: - flag = "FAILED" + subject = "FAILED" + flag_code = 0 if subject == "SUCCESS" else 1 if mailer and mailer.config_ready: - mailer.send_mail(flag, content=results) + mailer.send_mail(subject, results, flag_code) - return 0 if flag == "SUCCESS" else 1 + return flag_code From 78bd34bcbc567b72756e5e6215909209a79503b8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 14 Aug 2017 22:46:37 +0800 Subject: [PATCH 178/354] make jenkins-mail-py as extras_require --- README.md | 35 ++++++++++++++++++++++++++++++++++- ate/__init__.py | 2 +- setup.py | 8 ++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5c5485a24..3171dc961 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,44 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -0.3.1 +0.3.2 ``` Execute the command `ate -h` to view command help. +```text +ate -h +usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] + [--failfast] + [testset_paths [testset_paths ...]] + +Api Test Engine. + +positional arguments: + testset_paths testset file path + +optional arguments: + -h, --help show this help message and exit + -V, --version show version + --log-level LOG_LEVEL + Specify logging level, default is INFO. + --report-name REPORT_NAME + Specify report name, default is generated time. + --failfast Stop the test run on the first error or failure. +``` + +### use jenkins-mail-py addon + +If you want to use `ApiTestEngine` with Jenkins, you may need to send mail notification, and[`jenkins-mail-py`][jenkins-mail-py] will be of great help. + +To install mail helper, run this command in your terminal: + +```text +$ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py +``` + +With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. + ```text $ ate -h usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] diff --git a/ate/__init__.py b/ate/__init__.py index 9c5adf70b..f37c5faff 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.3.1' \ No newline at end of file +__version__ = '0.3.2' \ No newline at end of file diff --git a/setup.py b/setup.py index 70beb0595..247bc4c3a 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,13 @@ "PyYAML", "coveralls", "coverage", - "PyUnitReport", - "jenkins-mail-py" + "PyUnitReport" ], + extras_require={ + 'mail': [ + "jenkins-mail-py" + ] + }, dependency_links=[ "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0", "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py-0" From 802cdec95284ec12c64a2feefc5a69538130cc28 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 14 Aug 2017 22:46:37 +0800 Subject: [PATCH 179/354] make jenkins-mail-py as extras_require --- README.md | 35 ++++++++++++++++++++++++++++++++++- ate/__init__.py | 2 +- setup.py | 8 ++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5c5485a24..607d2c347 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,44 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -0.3.1 +0.3.2 ``` Execute the command `ate -h` to view command help. +```text +$ ate -h +usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] + [--failfast] + [testset_paths [testset_paths ...]] + +Api Test Engine. + +positional arguments: + testset_paths testset file path + +optional arguments: + -h, --help show this help message and exit + -V, --version show version + --log-level LOG_LEVEL + Specify logging level, default is INFO. + --report-name REPORT_NAME + Specify report name, default is generated time. + --failfast Stop the test run on the first error or failure. +``` + +### use jenkins-mail-py plugin + +If you want to use `ApiTestEngine` with Jenkins, you may need to send mail notification, and[`jenkins-mail-py`][jenkins-mail-py] will be of great help. + +To install mail helper, run this command in your terminal: + +```text +$ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py +``` + +With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. + ```text $ ate -h usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] diff --git a/ate/__init__.py b/ate/__init__.py index 9c5adf70b..f37c5faff 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.3.1' \ No newline at end of file +__version__ = '0.3.2' \ No newline at end of file diff --git a/setup.py b/setup.py index 70beb0595..247bc4c3a 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,13 @@ "PyYAML", "coveralls", "coverage", - "PyUnitReport", - "jenkins-mail-py" + "PyUnitReport" ], + extras_require={ + 'mail': [ + "jenkins-mail-py" + ] + }, dependency_links=[ "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0", "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py-0" From 862a2928f40f15450ab13a7e60b70144abe2840e Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 15 Aug 2017 19:34:50 +0800 Subject: [PATCH 180/354] update docstring of run_test --- ate/runner.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 0d384a7ab..9b2a33023 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -78,8 +78,7 @@ def run_test(self, testcase): "extract_binds": {}, # optional "validators": [] # optional } - @return (tuple) test result of single testcase - (success, diff_content_list) + @return True or raise exception during test """ self.init_config(testcase, level="testcase") parsed_request = self.context.get_parsed_request() From c61a7070f1b478107cedd9d218accc100713ace7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 16 Aug 2017 18:04:44 +0800 Subject: [PATCH 181/354] fix print version --- ate/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/cli.py b/ate/cli.py index b2799bd11..49dfa3961 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -37,7 +37,7 @@ def main(): args = parser.parse_args() if args.version: - print(__version__) + print("ApiTestEngine version: {}".format(__version__)) exit(0) log_level = getattr(logging, args.log_level.upper()) From 256d7de90f23837c66516011229771ea29d76ff8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 16 Aug 2017 20:33:05 +0800 Subject: [PATCH 182/354] make result displayed in order --- README.md | 2 +- ate/__init__.py | 2 +- ate/cli.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 53be1ae75..d2c37a261 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -0.3.2 +0.3.3 ``` Execute the command `ate -h` to view command help. diff --git a/ate/__init__.py b/ate/__init__.py index f37c5faff..03174f4a3 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.3.2' \ No newline at end of file +__version__ = '0.3.3' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index 49dfa3961..5283a7638 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -1,6 +1,7 @@ import argparse import logging import os +from collections import OrderedDict import PyUnitReport from ate import __version__ @@ -64,13 +65,13 @@ def main(): "failfast": args.failfast } result = PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) - results[testset_path] = { + results[testset_path] = OrderedDict({ "total": result.testsRun, "successes": len(result.successes), "failures": len(result.failures), "errors": len(result.errors), "skipped": len(result.skipped) - } + }) if len(result.successes) != result.testsRun: subject = "FAILED" From ebcc274e248f1ef315ce9b40376e1fe69927e02d Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 16 Aug 2017 21:34:13 +0800 Subject: [PATCH 183/354] update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d2c37a261..c621c4123 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -0.3.3 +jenkins-mail-py version: 0.2.4 +ApiTestEngine version: 0.3.3 ``` Execute the command `ate -h` to view command help. From 7f6a4e0159c21f4aa0d737ed1198d5f4c1d53021 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 17 Aug 2017 16:48:58 +0800 Subject: [PATCH 184/354] remove long description from README.md --- setup.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 247bc4c3a..744401a96 100644 --- a/setup.py +++ b/setup.py @@ -7,14 +7,11 @@ with open(os.path.join(os.path.dirname(__file__), 'ate', '__init__.py')) as f: version = re.compile(r"__version__\s+=\s+'(.*)'", re.I).match(f.read()).group(1) -with open('README.md') as f: - long_description = f.read() - setup( name='ApiTestEngine', version=version, description='API test engine.', - long_description=long_description, + long_description="Best practice of API test, including automation test and performance test.", author='Leo Lee', author_email='mail@debugtalk.com', url='https://github.com/debugtalk/ApiTestEngine', From 78ac4049c1a57dde09778fb6c3c9bf7a5e28322a Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 17 Aug 2017 17:15:41 +0800 Subject: [PATCH 185/354] bugfix: open file with utf-8 encoding --- ate/__init__.py | 2 +- ate/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 03174f4a3..a8a1bf6e4 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.3.3' \ No newline at end of file +__version__ = '0.3.4' \ No newline at end of file diff --git a/ate/utils.py b/ate/utils.py index 9d6d61517..8ca075f28 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -32,11 +32,11 @@ def get_sign(*args): return sign def load_yaml_file(yaml_file): - with open(yaml_file, 'r+') as stream: + with open(yaml_file, 'r+', encoding='utf-8') as stream: return yaml.load(stream) def load_json_file(json_file): - with open(json_file) as data_file: + with open(json_file, encoding='utf-8') as data_file: return json.load(data_file) def load_testcases(testcase_file_path): From 73dd8128faffcf57175da00df7e6e9ec7fadcab7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 17 Aug 2017 21:56:43 +0800 Subject: [PATCH 186/354] bugfix: Python2 does not support encoding parameter in open function; use codecs.open instead. --- ate/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 8ca075f28..093df3a8a 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,3 +1,4 @@ +import codecs import hashlib import hmac import json @@ -5,8 +6,8 @@ import random import re import string - import yaml + from ate import exception try: @@ -32,11 +33,11 @@ def get_sign(*args): return sign def load_yaml_file(yaml_file): - with open(yaml_file, 'r+', encoding='utf-8') as stream: + with codecs.open(yaml_file, 'r+', encoding='utf-8') as stream: return yaml.load(stream) def load_json_file(json_file): - with open(json_file, encoding='utf-8') as data_file: + with codecs.open(json_file, encoding='utf-8') as data_file: return json.load(data_file) def load_testcases(testcase_file_path): From b334e168cf60ca21e482ac8e917a841459b17150 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 18 Aug 2017 17:23:47 +0800 Subject: [PATCH 187/354] new feature: ate-locust --- .gitignore | 3 +- README.md | 39 +++++++++++++++++++++-- ate/__init__.py | 2 +- ate/cli.py | 70 +++++++++++++++++++++++++++++++++++++++-- ate/locustfile_template | 26 +++++++++++++++ main-ate.py | 5 +++ main-locust.py | 5 +++ main.py | 2 -- requirements_dev.txt | 1 + setup.py | 12 +++++-- 10 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 ate/locustfile_template create mode 100644 main-ate.py create mode 100644 main-locust.py delete mode 100644 main.py diff --git a/.gitignore b/.gitignore index 5a1d27b40..5dc7a5ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ dist/* *.egg-info .python-version logs/% -.coverage \ No newline at end of file +.coverage +locustfile.py \ No newline at end of file diff --git a/README.md b/README.md index c621c4123..c7d67baa5 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -jenkins-mail-py version: 0.2.4 -ApiTestEngine version: 0.3.3 +jenkins-mail-py version: 0.2.5 +ApiTestEngine version: 0.4.0 ``` Execute the command `ate -h` to view command help. @@ -213,10 +213,45 @@ $ ate filepath/testcase.yml --report-name ${BUILD_NUMBER} \ --jenkins-build-number ${BUILD_NUMBER} ``` +## Performance test + +With reuse of [`Locust`][Locust], you can run performance test without extra work. + +```bash +$ ate-locust -V +Locust 0.8a2 +``` + +For full usage, you can run `ate-locust -h` to see help, and you will find that it is the same with `locust -h`. + +The only difference is the `-f` argument. If you specify `-f` with a Python locustfile, it will be the same as `locust`, while if you specify `-f` with a `YAML/JSON` testcase file, it will convert to Python locustfile first and then pass to `locust`. + +```bash +$ ate-locust -f examples/first-testcase.yml +[2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 +[2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 +``` + +In this case, you can reuse all features of [`Locust`][Locust]. + +Enjoy! + ## Supported Python Versions Python `2.7`, `3.3`, `3.4`, `3.5`, `3.6` and `3.7-dev`. +`ApiTestEngine` has been tested on `macOS`, `Linux` and `Windows` platforms. + +## Development + +To develop or debug `ApiTestEngine`, you can install relevant requirements and use `main-ate.py` or `main-locust.py` as entrances. + +```bash +$ pip install -r requirements_dev.txt +$ python main-ate -h +$ python main-locust -h +``` + ## To learn more ... - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) diff --git a/ate/__init__.py b/ate/__init__.py index a8a1bf6e4..222c11cfd 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.3.4' \ No newline at end of file +__version__ = '0.4.0' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index 5283a7638..3453b26cc 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -1,15 +1,17 @@ import argparse +import codecs import logging import os +import sys from collections import OrderedDict - import PyUnitReport + from ate import __version__ from ate.task import create_task -def main(): - """ parse command line options and run commands. +def main_ate(): + """ API test: parse command line options and run commands. """ parser = argparse.ArgumentParser( description='Api Test Engine.') @@ -81,3 +83,65 @@ def main(): mailer.send_mail(subject, results, flag_code) return flag_code + +def main_locust(): + """ Performance test with locust: parse command line options and run commands. + """ + try: + from locust.main import main + except ImportError: + print("Locust is not installed, exit.") + exit(1) + + sys.argv[0] = 'locust' + if len(sys.argv) == 1: + sys.argv.extend(["-h"]) + + if sys.argv[1] in ["-h", "--help", "-V", "--version"]: + main() + sys.exit(0) + + try: + testcase_index = sys.argv.index('-f') + 1 + assert testcase_index < len(sys.argv) + except (ValueError, AssertionError): + print("Testcase file is not specified, exit.") + sys.exit(1) + + testcase_file_path = sys.argv[testcase_index] + sys.argv[testcase_index] = parse_locustfile(testcase_file_path) + main() + +def parse_locustfile(file_path): + """ parse testcase file and return locustfile path. + if file_path is a Python file, assume it is a locustfile + if file_path is a YAML/JSON file, convert it to locustfile + """ + if not os.path.isfile(file_path): + print("file path invalid, exit.") + sys.exit(1) + + file_suffix = os.path.splitext(file_path)[1] + if file_suffix == ".py": + locustfile_path = file_path + elif file_suffix in ['.yaml', '.yml', '.json']: + locustfile_path = gen_locustfile(file_path) + else: + # '' or other suffix + print("file type should be YAML/JSON/Python, exit.") + sys.exit(1) + + return locustfile_path + +def gen_locustfile(testcase_file_path): + """ generate locustfile from template. + """ + locustfile_path = 'locustfile.py' + with codecs.open('ate/locustfile_template', encoding='utf-8') as template: + with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: + template_content = template.read() + template_content = template_content.replace("$HOST", "https://skypixel.com") + template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) + locustfile.write(template_content) + + return locustfile_path diff --git a/ate/locustfile_template b/ate/locustfile_template new file mode 100644 index 000000000..11a7c478c --- /dev/null +++ b/ate/locustfile_template @@ -0,0 +1,26 @@ +#coding: utf-8 +import zmq +import os +from locust import HttpLocust, TaskSet, task +from ate import utils, runner, exception + +class WebPageTasks(TaskSet): + def on_start(self): + self.test_runner = runner.Runner(self.client) + self.testset = self.locust.testset + + @task + def test_specified_scenario(self): + try: + self.test_runner.run_testset(self.testset) + except exception.ValidationError: + pass + +class WebPageUser(HttpLocust): + host = "$HOST" + task_set = WebPageTasks + min_wait = 1000 + max_wait = 5000 + + testsets = utils.load_testcases_by_path("$TESTCASE_FILE") + testset = testsets[0] diff --git a/main-ate.py b/main-ate.py new file mode 100644 index 000000000..4146b3179 --- /dev/null +++ b/main-ate.py @@ -0,0 +1,5 @@ +""" used for debugging +""" + +from ate.cli import main_ate +main_ate() diff --git a/main-locust.py b/main-locust.py new file mode 100644 index 000000000..e85d44102 --- /dev/null +++ b/main-locust.py @@ -0,0 +1,5 @@ +""" used for debugging +""" + +from ate.cli import main_locust +main_locust() diff --git a/main.py b/main.py deleted file mode 100644 index 18fdc38c8..000000000 --- a/main.py +++ /dev/null @@ -1,2 +0,0 @@ -from ate.cli import main -main() \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 0b2c72d8c..d9c5fdd79 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -5,3 +5,4 @@ coveralls coverage -e git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport -e git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py +-e git+https://github.com/locustio/locust.git#egg=locustio \ No newline at end of file diff --git a/setup.py b/setup.py index 744401a96..c68fcc18c 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,9 @@ url='https://github.com/debugtalk/ApiTestEngine', license='MIT', packages=find_packages(exclude=['test.*', 'test']), + package_data={ + 'ate': ['locustfile_template'], + }, keywords='api test', install_requires=[ "requests", @@ -29,11 +32,15 @@ extras_require={ 'mail': [ "jenkins-mail-py" + ], + 'locust': [ + "locustio" ] }, dependency_links=[ "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0", - "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py-0" + "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py-0", + "git+https://github.com/locustio/locust.git#egg=locust-0" ], classifiers=[ "Development Status :: 3 - Alpha", @@ -46,7 +53,8 @@ ], entry_points={ 'console_scripts': [ - 'ate=ate.cli:main' + 'ate=ate.cli:main_ate', + 'ate-locust=ate.cli:main_locust' ] } ) From 09dc36d69da4dc9900582258dfc078c46026cc43 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 21 Aug 2017 00:14:38 +0800 Subject: [PATCH 188/354] add quickstart docs --- README.md | 6 +- docs/ate-quickstart-demo-report.jpg | Bin 0 -> 40427 bytes docs/ate-quickstart-http-1.jpg | Bin 0 -> 114965 bytes docs/ate-quickstart-http-2.jpg | Bin 0 -> 108744 bytes docs/quickstart.md | 339 ++++++++++++++++++++++++++++ examples/__init__.py | 0 examples/quickstart-demo-rev-0.yml | 30 +++ examples/quickstart-demo-rev-1.yml | 32 +++ examples/quickstart-demo-rev-2.yml | 39 ++++ examples/quickstart-demo-rev-3.yml | 45 ++++ examples/utils.py | 22 ++ 11 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 docs/ate-quickstart-demo-report.jpg create mode 100644 docs/ate-quickstart-http-1.jpg create mode 100644 docs/ate-quickstart-http-2.jpg create mode 100644 docs/quickstart.md create mode 100644 examples/__init__.py create mode 100644 examples/quickstart-demo-rev-0.yml create mode 100644 examples/quickstart-demo-rev-1.yml create mode 100644 examples/quickstart-demo-rev-2.yml create mode 100644 examples/quickstart-demo-rev-3.yml create mode 100644 examples/utils.py diff --git a/README.md b/README.md index c7d67baa5..aa15f9b45 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ And here is testset example of typical scenario: get token at the beginning, and - {"check": "content.success", "comparator": "eq", "expected": true} ``` -For detailed regulations of writing testcases, you can read the specification. +For detailed regulations of writing testcases, you can read the [`QuickStart`][quickstart] documents. ## Run testcases @@ -255,6 +255,7 @@ $ python main-locust -h ## To learn more ... - [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) +- [`ApiTestEngine QuickStart`][quickstart] - [《ApiTestEngine 演进之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) - [《ApiTestEngine 演进之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) - [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) @@ -268,4 +269,5 @@ $ python main-locust -h [flask]: http://flask.pocoo.org/ [PyUnitReport]: https://github.com/debugtalk/PyUnitReport [Jenkins]: https://jenkins.io/index.html -[jenkins-mail-py]: https://github.com/debugtalk/jenkins-mail-py.git \ No newline at end of file +[jenkins-mail-py]: https://github.com/debugtalk/jenkins-mail-py.git +[quickstart]: docs/quickstart.md \ No newline at end of file diff --git a/docs/ate-quickstart-demo-report.jpg b/docs/ate-quickstart-demo-report.jpg new file mode 100644 index 0000000000000000000000000000000000000000..caa06be7b9ecaa9275306b3fa45ffea34d353429 GIT binary patch literal 40427 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 literal 0 HcmV?d00001 diff --git a/docs/ate-quickstart-http-1.jpg b/docs/ate-quickstart-http-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae89002e96a1f851e733bbd63d94cb85d3e9c00f GIT binary patch literal 114965 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

  • =acGLNU)*I{Q`9@uu2{``rD2Fio+N!|`ZJTb9 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

    a(Mk ze3dOKeZH%nBblb3rDRr1yxyJ~^5#^4Q*wKk)24hf?b&tq zRbeCj(x+VBxD*+bsxpMmSV0oJ)2J8s;@_8Y!QPP*{4#%Lb-ny0-kbU@@L^-?;+-_Z zZxc?RZ{$}rN8k9(Jep)8lEyYUfTC+`as)J1U`v!k&tP*wnnMbe@sNc;^xKgE8;0kQ>rzvy>t zx_xg^m8B--B0h?_1Yfd^D6G?a1FE0qE&>$?QNWJpxq{56CzNhXhE8|1(RhajNo(>A zxCvl#W!0N;zk}pY&s}WQM(7`CzX_o)W2!O#hi6gZQB7A`3uR;7G${71>YL?GPI#4+ zJn}P1@>0t_J7K$Y=N!mx!hv1ibd0PIONy{dWk81|+KEcS9KlJBH)fkV+Gaqj6d?d9 zLYotn_v!i`YTjpm5R?*g<gkeL1*8_$148Fa zNbwGlJ5ujMlt5Am)YSm=0UdvCuEh?*^uYiQq5sAnD~2SZbzUo;QXBld{<5liprAos zz7Fw>(4W35?Kn5@P)4*{FSI6{8C41@d+;AvF_5hy@mXwjICB&KWOs4rOO&tufTsSF zz5Tb(`sO$e;PEV}3`F4dU`_f()ii5;&MUlXd7A43|K577RcoU{zCPbm_~e|HW>GI} zGOYZ0kIPW*g_I(ZOxZ0F8to1I)nt$gcogvWGaC@5fjT|__lIb|-^L~rA#83Dd;LP= zz%q0PFWkqy-{I>@DRYXV5bND{9>%>@g62mSp(l-RbkZ9D>NP2?lEowU8Jrfedb#-&|yqdnSl$W#` zEsq+^j+y4dvB3GR;X}K*!8Tz@>Gn27d41%@p}EW+fcB)kAV47h{UjjlTsUrNg258V z2GImbKKA@DY||<9Vg7asO3Q>511uLAkE(OO_Aai)@XgWcWE~ble|5mX?9?Qqsx~1Q z|040whQ`5`ibc@mssjC*_(j*r&Aw|vR2Eg9J*rzEpNoSFLCF%cF*{Np4bOj^4L)ED7B5_B1tgh7=| z75P#k&|a|F)Pn8bPTIZ#iyKoLLQ4Th%qrEOMDp5CF~{m?&1Nu+foDi&#ee$doOI&alD7kI8@F})vTdeZDi4+ zlw;$|XX`4{U=8W%D_I7j6~nTcQs(D$s2TK{{a?`PqWLzyGNuh$)dPCmHZh=zXvNes zz39;J;x4ROPN018ewuZjX&C3+>e?{ZIaD^pH;jE8UyIrQQ>@dRLlfS`W0y~p{oKb6 z^g-!++1&aU!n}DLHS8AUPJQ_GrAO&uppC1(>cNBcA#!+nlovMQTvwOPdaa-1k`3+? zEh&6uXe?n5nOVa|GN5fjd+sqDeLC(wNvau{qN2iyi{CYJQhfM!k_5s+OA8vMdd)l4 z)b~>oI=Mb}?@s9P8m1nX%>xF0)3H90hUhDfZJ)Xah35tca|LJgaWTgN&5y7zv4uG! zxGEpiv`0-&tMwc2*;8ZI3U<{tJGt_OrxG+rrn`jyGNjWCmQWmh0@&6ZyvVZ`*oU4r z+WL0hE%5b5FhNb}TbGQj+vjU=wA&t6WY!QPQ+xD?uD;g1-7zSn11a;VWUAEN=IFt8yWW_MwlnNe@IS zpcr+0A(>&O#PX`?H?_D^IN}kc6eVoyPI(lms`uleDSP)5_mg4YjRG)&i2bcOB~b;GJ~%pm|-nslJPsR^X20|GN1 zfHoUHka2E9qN6sYfo>6QiDkDXWt_fNZ?3jEu9b7aCfQEeIv7c$!V$_(?@mbS1axEi z0i3u6jB*Td5BM@$kgU-BpB(DOS4_D8er-yv&lgeWYH$WklS-maA92r4%dr0B_IPb! zU9>n`BodE`k}D%m_E377aMc7RaF;gDaqMFOi1csgdGSx7Xb!!FSs(i5vcp~=oNYOB z87(>k6KT5@H+=KVDb@Jh=nhJE8>D}BEnOsoOCl{Rv!_K{gGp;}D}g#x0sF&*-XoA5 z#V!t26j@k0rHxYAy0*gydkTNxpZ@Jk0AT%PUGU2g+g_ON-#-8QdcTVI-*)?q`{#qN z{%v_wZb|)@rvp$21J?XCyamTQTnA!wWnxiA7U<5uU5&h7migy@Kj8m3-~Zl0&;ORe z|F9o_za&2gMQu>JI4=@ZAFCw%6#MHKmQ(k)57L=5W-ph;{qZ}LcT%JR?ql35?D#f_ zAUO4x_asA+RuBvn%mqNH3XZ~@+mMv!`*@OU65T)gsKEJ+Eq#k7b0yi8?_8I?dB??` zO&#fQ#GZ|HZj&}PPpZ!CaZ33Smg$e)rf(@KR8oi(+1+R!e(kq6p z`_}ap)F&Pi6i?YamG8TAOC?-G`k{@iXSVn5;qcV~bDt$wk}W#!!cA`$LeNNt;_B)` zzX~IOfrZdn!p#xtA~8^6-Tvd(59T->?KJiUrq!8#wC2H_an@E;5G)~LH9@8&0++PX zsXwaay`g1Q!(Vy6ufyN;)wJWgvAxmyS2q`Yeo~I3`wL`!C;XWTjMU$GUgOYtexCQp zoR3soTf}#YsE*rN^mu@!-&KOT7Z!|!M#70>7T?O_w5#^V&&jC~1mc>6UJF`P@7#+? zc|0aZID^rgnJn*^g9`QnXBGS7bT5?shm^?3>X-MhROk-q_=^Z!oA%u>S7XRuc>Qj# zHtbe3jKSJn__Wlf2*wMo-ob@kBh;mgS$QR_=x1(x{6_YS)&>=gyJYBic_yeZEk)}U zp}Z`}Xd`yVdG-pietQi=9qdn@L)!Y6p~$EDRAfaRZ1 zBrrh0L$sX(ZHZro)8u*mFg89(-KHp@`q1sJkJqmUk7mn_)IC@YYuYg1z7%~!kIR~G z;naJALLZ|KxLq|QNsI9r)2MCap-ji~{izj--@UAD^g|x@Q%i4VG)a)ep4Z3{Zb{Gnt=aVwV8vTqvig4M*wX+VM^^My;BR z@qanDu5(kGH3oZ^OHTMkpTFAPqxYeJoZMuDFqL`uCm#y#26;)2v-TOMOf)!Ql7u3- z{S@o#e0@j=UgPeHKH`?otiS{^Gg!e>D}kvL_Y5ElRtK@JahweZaeWfl;s+qR=V%*% zt6(#}+9dSn-uOy9{KID-`^(c(UcKjl)3u@C?}M*)Lb+frn zwK2tL3Uc`hv39Y3xfk<#6~aS7=D|fiV}AjF5c4d6k{@GRK7atD{rdh0ylBfZNpv2c z)1b{)7VPEd@NN|b;p7ak8v)%+VkDom0R+HH^bNTo-ABWciq9Y1R%JiQG(~7IoltxW2`u-ZDr0a5IM~8juPq*%5|k6dw7qcRLz8W6d4n9R53#p= zL8~mGCe0?WtdI4%ZRE`(cO+8nRe~qoT5HE$pSEJKbluw_WjrShdleMu?uHD6N(j{* z(qVnRg!fW#-&=R&CaWDsJ|3)kM%BIaX#c9BsenFz6|I*6K^#Ug=8o+ksd4t)k=GLe-oxm z)@UAm9dKq=RJ^4Y2YVhZR-yeo6H;|j5-&*Lnww|rL`i=;NR&ANtF@ErlcN<6Zt$3j$n{^JD+cZAENN6hA+mB)JOVT9aL~P?|CbG{p}SD;)QEp za$4Zn0?kaJCzLQ9=`T@V;z=WSO|N;7zW$@x+}m&($eXnAwW%Z^Mofb#0@9;hDIMH^ zA^jZtJtOX@Olubk{M0ZrRrpZJ+tR#5M0>T|{R=IN(?E))fEliJTt!H3P5yhSQ& z%5?#Dpx%|bE~*cOx`03UCsZKli{mnR74+1tnEqOvy!lR)Z!=Vm8V-1vU1;$>+U&(ZqBzfy4o!f{sPP^Zq0MWf zBwBBp-@PL&DH`#ij-8CsymvD;VO*zYchftBn)MagL;UX7^#bUco;60-rt5o~KYW?| zQ%udyo^89f482PF=jU!EW05_HK7aHRkD?$QQ1R*~(14x|b7L}Jt6@^^K{q=umk<&K z1&QwEo&)D9ygm}(2R6HlCRDd^)*}ean(%j0DIxvlgXfG=jj71obV0U&aBzK?CDiUH z!o*wWD+axV<3{gjm1qA^8QLRj;Y6kI46QEWKH`N6iP$JLC6Zu(jg7>v+*@s74j`siO7 zyY}i`Xu4&h)hT%9lD=GJKaF4oJocpZuo2KUXni*Xk7LIOKuqMN6tKk~c0qZgkY7JQ zAK)tr{Dt|VgQya2dOz{RjHhJbBR0Q1%UCO$VmtE=?v^rkNyTZZexGNAnG6_kOy$Z zzym@NimjEiDYFd?WVlFeRE<8a1%9plWk}jWFKNF^FkKDES3sRn9 zojve7G(vovUH|ktR3LkLer&I>M05em-tBe65wQr;apMKNCGB(~1y>IHRR;bh6d0>O zDBC61t&Ow`4H>6N2#mf~Be$TQpze&I0VNUYDYQ)uQun zcI!W?w5*TcutEl>I(Y7J+7!G5WH!(R*mM7g->>jSV2+ z*93DjAcODUBaZ#*1>~9Z5M=qYp>&+7G6+c`+(aT^l-mO~EOt!b=4Tt2A=zJUH%j^mIyJ*J9+4J#u6-Ut}_#ZvogFGW7-;B4! zz4Ly+pxfS#_Y$>Eibvbux?MT9T36M%heojv;qnjKDNai)T~BR@Ipb{Pq7CB?}`+}tNOo>QWkAz`$p%81$=AobL;qCkEkw- z;lPSD2m|@8o<0a}mS}UJ`R^Q3B1RGgxa9gEC)v~SrUv1%;mysci4>I98SWBwq*Ube zkx&2l80jP#4&4G+2U9l)_Hjl@tq4flb9Ih=;uZEYbE&k6R^z(^SDz1Q46{Bjb{)kA zL>8;Ri~8)h*u2RlSq$Hg{inTzTNzT*BSuL%by%z(sU$KT8=h5=#?OW?1iA|z_LHti zMH}08Fl|E%yGtn-F+<#{VbtABRE6KBzL7hfg~bGo;L7e$&6b3X^##|T|3R6)CX5km z3S>y+IycdFVfLX7zcyGP3z`_33N7n(!<0G6JZc+gM_PCoRk-E(E=Mh;z5vrnhs|>Q z1K`T7jkDGzf=H{25Rsl^7h9s~n1$-noaeJ+%>|=l?x)_|O|w2WE@qsa4Nn`X^H6O( zP`T_zB^I2^FNkcUVvXP=Z-o(z32IKXh0lkg{~W>wHe%foLH#bmH_Y2z2#l8p-8q2b ztoq3IGI8wmwmuu#o1graVvK+PeF;~IR(utx@;f^&7A-~pXp4^%>@M;O4AXh$cc~`% z%7Z-!#mzCtH_X7U{BrUDA^h8OpHVn6=@rQHp@q`M&eWiLqOiMRtV(|Blm8*-p*$%6 zw5W%+OH-sHI6Ziq7Pn4Fs7{p)T8$_u7|Vid4j`0A%8qSpEeEYYL#|C9KYrYc1D8Ui zxYR^jmLJ5jzC#7qQhz;HF_8t9ZiNOt2Cq&sZ~{5t*m2(Q;Z3AHq(GJgFZ@HMQpbFr zc&qj<^x;}BZ3$kEH=DUkxsO9aj=eZM*`Yn0YB)LYq{lQ{bnkEX4G1lo9WLBd1LLm& z)UQ|+4#&E&zORhf9iAnxP`mFydO%whRP@ap(pQaR+7FKk5EH3eZK4eCGRk~g2vtGZ z@L4Bo&8CC_Rr{x^;i;dKYj=jUZ@QtP%vojn(;J<~%`1>>s1u1Od`=O8cH?SqYf<0& z;~xEP)akPDVz8~hT%p#OZ(5H*ry?;Zae(=n&#pg8lK7_aWg77S{V$|W=u!6 z{oNY4O%ipE(2v%BeZa>XMzx3Qr+vJtcVy$cu#{cj-CG20UIo}wuz%7LeU9f$8=>QB zYig_e8ew`gkpwme8wt%$1=&5o0u)t*mhHa{xm)+(pp0J(3ppOW<>@V+kDUVDWM1W|)ps1} zRC`B#clyTSYhqck%uoO!L^ca@)r1wYfHsCzrF1mgry6t1-oUf* z%~;vdpzQ9?gm7IDycm^S{M&-8TdXccDHS$Ar7|)6hp(xPHYuKdrRnL^1yxBhLNLysyh(WatmQ5niPx;Mp;j+S%f|Cr6{_<+GFg|GuKF9l#WZ2J2cu0mZkC+zHrO5>Amrio z-=g!ovF?K`gkM7@Omajb$^KATV%ts~i9PWLG%75+A9(c4<9=B*_(oQGWch?sH00^++k^>CpU9-d|MP-=9FNQZ~Pkk1Ts; zSzbFgb%z+Jnp=QsRnZrwn#=PRpeZ46rG-W@;fv7Z4W2{E4cyr)xRzJc`4U9kAlLGz zn4kS|>nT0I+RCfGE69JBkiTi2_@;x|Gsxy4{(HBPfGHMs&Uq2e$eQfXGzaJtYkvSiD+Wk1VQclW%^a9r(;(o$-^%-W}p7aI~M8xHfu(faJ^M9qef zH)tCUAw_V7sNW}uy!Co6JhRH?R`HChvwO4Z;{280M_=V8oyoaz>fY6pl_X!%ui)TU zDS!1$)*z9|(Q&d`GDrRCGCjyB!>Egtq2#*|eG-7+Et++`b7LNNu1O-U{%Paa9R1dfT zu;Kn@X!RksRt1*f4X2Y^VX;|#K`KfdxBJsxQ8xBzep`$WSnuhz-t_sBYOnQfjn^G= zlPWue_lR=5YpbB{eGm=U8I;i${iaC&3FhfmO80j?+H6EdY{L+k*z>m5z3uUiFLSax zRK5NX!@L&)eDlA2s%H?bQPz?zv|D|u@4X$O#Hf#UFq<%{zFHrQG2?Y`cku!l$-X`u zpQjU`&!2ao!9&G1scgG-MU>Y45I_#rzeB-X~1J z$O)7<$h!^jm#F$SpZ#RPrq`%+7ebC_ee`6xmZ|gisP_xr2;Vd($+{;gE0=5(w9adqC!>cD?l0v7%(V+ipGW%?nh_ zxCA?zAOD;BjbNQ6Ul4u;-nG9$weZz#rZLt1t3HilSvHxn|cwHam zcm~r?LCfDH+k*DN0jGxP0IgU5hI4;a+fJ`C$KWh9ZQaS9?DULUBfyQm66ixA`ai|K z!Rgh+jl3DU_8#;}4z?BW4E4J12n1c&b~Se-W()hiN+TqztPsjI4PpGGBk`NBsS@Y= z8!qtW0(|xlRzqY1aS);^O1mTM`JTAu2td=GY&W)BceV(S$J>FU(U5o*V_ z*Sli(lP+QZNfVSjewK|~@5Ogk^9vUa5xq$z!=ezMOjoF~z?r{|C(~}ex3}DYl|+ON zVjzLxb*=^KG5DIxQH8eQc}F!6htn3etZYu&B|YsPJRx&@q$RUw6iU&Y6Jd?lvHzeb zPvn*@2}qq$t&1hJ$+e$iIsa+adWEmXNYhLBA`ssD#mo8i*5&I>N&Sb;f&6d&Te1JL zeEs!*@L(^b{sY_omwdSYXBYF6EULneRye<$f&f{X)KgF1l6UbqFGl1ZiN#8mc zql6awIJfnl)~H}1OTW>-6{8VjGbA`opF`L?4I-;U{9eLJoqY7XTFK@Y;~!k!37MT^ z6g8)cp|7*OX)Qeo6mfy-m=vku?SNX<(llW-q+skMHbCUp@aUXrNqs>6hZ|)C_#aN6 zkZ5ZjVqEHhj~FMenUwfks$YO*Pj{@NDXulIX5W^z6-*X1KTbRBd*edJW8ul~m;Qoh z$Et5#EK&QM+c-pVf(7Z;mh1Y|hGpPo!%oW%np@ujf#`GyV%w0Jpu zLxBbWO*chkEZa^Q&UNPWBoHA4K(QX>Ovfb)^xp7NU52VzI|AOK!d(Yb2$6pj+_??< z7%c#aQrYYBj)3X36HiLY&5sD5VYZJ7W7Cw+aUL! zi<1p#@$%~?yl|UAQ#ivL*_IxtbS7W%sD4#>kkViM2tP{65zjSD(3{fO443Wx<6zE% zJy)71&9QIB$rJaWj|^T9g~hp%jdAyu%}%#wPZiO7wvUaLCK!B4e(5u7q-6J0$GYH5 zq*uV_!w2$xJS*g==MWd}O&;v)_jwq+uyrV+q;Uj!ut2fRa-?Q5!s*^lI~qDIBcDnn zE_%WP3JVks(VBd9OcSgs{2mB>6+vnh?(3I8lrEx>|4KS4&WW~xz6E6z-huFzL`?FC ze0tt3Vq_+Cy5_Ou{)-8YiC6KT`9#os6rI8==dvycL9upVUE`NHg*H}_yzz8AQZsn0 zV;)db8kCU}wZCTvq&}%lzZe)6<|cMkKitAy?n==Da=Sngbdp08VLOQiC~V6CdtMGF zy60TK!0OGNrS}blUU~ZU9j%Mi(wpTME^Tvs383hSD}R`#w?nHW<3Q>VF7=zJ&s>wU zErJ-sCez0n#}V2sJH2Ff<+PHsZ?7lMrz_Uh@)25N!}}L+%H^wlMAYm`_;j=A<9z>A zJvCb+TeG0V>9McE_my6~t+IKLpjnM2EpMaAG>5PVkG2!m@Pg`kV0lb13Sgmw81h>W zv=>=B6e=lj{A%jVHy51bwT(x)LhfU<*7jLn6-}5dR!Gc1{k0sQV z_D^?~5$A17ZOz{od7msfg}a`_H@?DwE<8MI7}Ysp&)aV_?+a(=Ot{60Ht=j5c)lDP zU^cgb$)+SgC^fjk#J0+hDfcxv24pUJdIYYnH`Klje5-$~!SKe>kY>3|h<4<{ic}1i zeOBt5JzYE+v_EUu-*Z#hm&L%f4Doy!TWV}0FAn^U3TEt69?Qs28nv5TRyLgcDTYGa zuC4v^45;|KApa$3AlJf2!{Xxrvb@agoaouW#r#1bRm(3_J6s~6#ku5E=_}BRGpUMa z<1Yulp;*3gwEMD(FiedW>5BSr(r88gNuCtLsztTxD_VmO3Qu__$BVCPsnDx+qBz)g zC5y}gg<*?<$nt8C#p=yDQJ8jGoEpAk{2dk#IzM2PIc_x@3|QdPvBUTe5wJF>3Z|#% zgP};V|MJ@`hx*#o*7pdXMVnnngx}zF|NN>Cb>?d~Lan`N2l}IAqypgg$N>pAtp+K+ zkHCo69e|T>kl}ly>S(Zm>(+H|;S!SSc74^2JHJq7ceg~!u3sRgB99uq?UC)e^)zph z`bR-86x8>=ZI5N+89hzkFgs{LV=}y%$bwm-M*Se#qI}l4{bliw*zLKmbFZ+Rb1uX= zRR&B1Xz#5{L46KkhD;WYJ-hmB=1_~(=joF0i%dI*;Ppl8Zf`T>)jn&yg4E`=ChCQS z#`(4VFBR4BZGM`FQG6xlEDE$(&hXFkBDuGDQF%W^I$tsBqOZ`MM8h6-6(8`6dr!!e z{V;T;$P)KHUBTYJ*#(JLxVYm3yCV>R)hzMql;hyKmJaxI(UHtiumKjlr3un-4j0*f zo3;h>1tud>!?zwBM8hUnVsI{u5&^q+l|4`wjK56J5qjAU=&u<_ApGdhzbsDPb3XbY z?O3whPci%N;GVdPT2>*ORv>r7aa%)`ty+}uiLW>V>x1O@m0^L?m};~^w5FG6XQ07B z&emCJuUFI#W7>V2kndXiK0*HE0oA_ortE(iCnbPxxkDg-90}@JUXU4mHFs7t zE!WqrIOj8O&KW6?6~b?I6_3sC4CHnjj4s)m`~K#A^3v^TA2O~m+so$lNmzfbHHB$y zGiGn0Ecf1*<|Pwol#*)RajMYx^9fKp+nf56!pPbnl^8r(wH4fvVmLt%kPX z<8ix5SEVTaTwXnxk+hem-7MPAn}{q$`8QDx;tlTSLmRmL2<)3yZ8=J$oKk|aoQ*4AOcRV+^n z%RCKiV*GP8wp)7-PyLxZ7bng-YS1M#*WOwfOV>1N zNxF9MSl2~p$L_-mH}54c35gYc`}$uy8SPYm3L5k-UARUxK;ydtrEIzPke)~YQ%M= z&#n<)iVJ6md*dwMDR^Et=H~j|LHd@Tz8tbd?Rb6KxcuDsOATxO$$y>0Pq8a_N-Co) zs~!3PRGTh<`MVSHImnVGztY+4Yfte_uSO)w43)!eD;fxI$@|{ETVqZP?8}u=JLZs4 zQt-!%yzWmUF2U*OWTdANJUd@B;Qgwcsa{ZjA!zH3$Hhx(T7l|Z^7|ks(f1QD4cdjM zVV)PK{S%g=4GMGRO}Op68jegW7`X6~bQ!iiP>TJAWjBOh{%Cq+-7R_3f>ssP*XOhv z%O(e;D;_xO%KSzpJYi^7N~IAeoGIa;)U0We_mhAyI zE__-DX9o~|pRT>ZhF|jUTNo!*BX!5l7N}eptad_dldEN3`siVuWLKbQiL0Y&rcq1@4gKNh z!QE47HLn47oW}F2`v&M%HLX;2u2J;T7}Gpur-eQ!yW&Z{V@` zM|g4EAht>s9iQ;kE}d6zKY&=l8rk$u=$I;L&gph;H8H()?zac&le!fL8~EHn@TVwI z*AVEz0~u6*dP^Y2aKKc8ZPWtYZlEm@m^Eg`2^}$XGcHy7+K?_Ym-e)~hdu~gHGLY>OW*YV!mE!3yQ;W1SUa@aa%Y6EJX0O-# zzjQ2PR|S*tyBeT%`Gr}T!i5}Sw>Pt>y9&4S8RJ!LonTPJhs2R8ar1I~BT(1B)t~Q+ z@;_I>Dd+%6(rZF-dLe1NOVX;tM`J*9UYqr5$Y;t~Zj4~;eTV6{;cV6^mn%;-mdsfR zKgBN5;>@)=4Bh;k<}wtF zFo4yLS|f$eGQ&1>Ufp@%RQwCT%wn#>(_!~FgIJfEAmFR1?osoD=*yLT%b@mV7K2_u zYA#=RXCtd)TDI}jruruvWCLKBixnGofr182upzO~-}=X4AI{j{H&(xYTOxUPGxLD` z>x0>>J<(fs_dFZ9cvSb@P5kj|8+La%CP!rK^SBaNxOQOx862p^sxIr&AVnBGsJ`~i z-EuJS%TRG1!l$#XT~ty3IJ~;FSI{T=`crH>aX&g(_z)V1qcT|BSYFIHNrIO&k-Shr z$B74IHw-BuRtj?%QM;RsEhaxd)E%+Ddb@i+{N3&4N1CeH!i9s@S2uSF4}u9@;-NjM z*exsg*leSA$WHY3T8=?ma>TYXhcy%nt5Hg$$1EizTs=ao3XyNWYc`TIx9u5&h4StAhNKeR!6 zCsMI)Bi{^mJV+tn@uEh!7CN{O12KZi3H&8ZoS~Md~S6k ztVgNGzv%d6c}w36JW)@?*Q;4*?k|l_JXH9bg{;H>jURC^`tlugyo5rFH#OpKM_ZB6 zhm}{z@yjG>p01aPw=rwpNa3cGY{0i_uNYP99iSZ4q1{Rv@h|}z<_X4MO*7^A=iop^ zK))HYCZO}h3<&VBF}v7=Hc98JB8|QkuP7fIgob^H3}q(#X3^8F_XEDaKP97nc~dqi z5OV~5h%MgUvuRXd%FBI$ZMGff^SdNZc|px1qsU6$-xav2Q(m32d7tWQ6VHoprG~xr zJ6ebbnOd{~2j7akPm)JF((ert_oDa4xe(Y)ez&eL&FtEtaUx7j)&1ZSBSChp_q~I; zZGIYh_OZq-JC{i!nfQFpt-qY&U4(EPw5#5km{w;GYgww`CG+(}Gzh^QUyN-s#ps}K z4-%C?6-BB*PJW1Mzl~?qVr;RHRf;+_xvW8xo_W+=GBQhk2;Y&Taj=GzH7_3KkHYU7nbOZMx&z_UcfW(meA(WjUrnH?jVP@2^8YtZH&hx2=lE z+kM8zsE<+mE}&qrT4?#n%PSpWG(2`Y4PpwVn1;cFxEQIE7;)<53)f| zEN)9MGdMI=%A037Ch6}pR8w!Oagr1onX6${GiV^ug;21|ePl!p?4`ChA0H_HHgRD& zgcBPu5PN*F-Qk&2CjC z0&_5YigHp7k3W2Gv9=_V@Mh<&e`4Dfhm{m&d%^vxXBzx@3)s$gb&AL`Gxn=QosTk6Bb91!$5Qu$y zvcGYVe>I%e%wNs?;|}v)s_!ky?;AT2NA)DLc3ASzqTX|3q5~?=M)-r&NVdvHzL}8U6vk4GyceCEOD!p!gjy8Pa$@ ztj4wYmA61M$)2oTdL1K=y46y4-9YjVz4EyU+*?k~Bmce8v5)Ho%unuuSr-e?=&a4( zE!e`t^X&y2gwKd_EsRldZnh9_>R=i&LE1UvqsKA8b0N{DI%@fp!Dablb6bjriN(5< zfT5Xfn_t-stGj6KxlcNY(FEe6j=juErtLw8^Uv~FAI5ZIChS|O@ZDzNp6u0DDMeHy zGi?1T=0d(UGH;G6AALy1M0($akuTpB!hKf=_euUe_spev9TVCV@l)(aF%#mc4Q2%> zd*`9)`nvazmXqZc{3>fwPE4ms1WaTu-r3W%2Lb2{lPZhlGC%2GwxIMqc$Ql`#;NMJ zY2RMZe+$iQyplH>|IF{iyRz|D_`wx~H$L>-#mwHD9kzbh9v1J&xr?ZNz=jZw#eA+C zBD~?<8t-6QbIPeL<|lHjiSPOg8~gQ{QeHzZ`<~BI&XymDaUu3=jz3X--Pcif^wGXe z(DA3W)@(8Z3`mUOUSi`Ckt?)l5CUq8l+fN3NJ;*_o)nN-H+_E@agAkje`@kOZoYPP zwZCD9$=kbE7r)-ynm2=2#5S3euHseD+Po4rwiPRp{~j0&hGw*Vgp?R>`dKgVjD*5Fxk^uo4k$^{>}E6t-GiypiIVG*x-Ch8fO@)Iz`( zZ`!AQGF^4!qWiCfKD!h3ZM48IN}+D{U8CX6sp!nKNw*>NL#y%f8b7mXQ2U+$V>`p~ z@y}JiViDTgMc?4#RFm2vyr{?TB?bLwTvG!5av=zlUq%>DpS+f4b1QgTdN((B?2;9v z2FAXCj{z+#B>pZzAi`uKJc%g9!_rBy=#z|OJ8}~t&Zd6o3od`mCeY9P<#ugEX&T!i z#MskXW@K}#mdNI8>Hr+_|D$RjVZl+ri@UbQ1?Z(u31NZDnF2y+z zv-p-vtvYv%A4hv=dBPn-c2Fw^8E4nqU*9R;)*qymfCcH1$SZwZirfqA~NG3 z_po$3sbNQLeIe3eFuT9icEYI7t|>%QkeO{k%;MfmGZ^g_T(8=B_38r!d^_SmJ53RU zSa2IL`tG0`_DT)Uk#CP`;kt6dlQ9rfR|{0O;Uq`?@FY7oIVR|UbM4T9=pm|ubud%= z)w_Pi`)Kb<%PL>iPqFiOz9QyxKimmUCP`?ncomD3Qt!!}j0_FG0#2i%rRqBdHU{CfsOT42BnEQTv}#G9I^$G9SQ@nSqUuy1IS z!;%M6i^|_)Uu)qC&t5=N;g^-pNpHLxz1L^n+rYJUe`P=AuvWbfNvpW6eK!BZGtbJ4 zB{>=A&>pv9kDyS4$-vm)kh zKd4~mY?lq0bfpK#Eol&Q7}J51G4*2{3N-^|Hy07Gi|pj`gwR4%p+)2Kx!_l7gTWN9 z_{45U(TboVz-#fj7ZvdV3bw1;Y2&l z{9QQPTro->su{O6jWsCIDG9`Q zaSTc4CK8|!$IAv0&8mg~Q-1)Qk{Wl7JzuM`KZ{9F_|0Go&aK|M{EDB9>YE2!J5*@2 zH#{${2UveF<|Cw{DB1KhM`~QVr9>#xZp=-Vy1%b+D74$_B0}$E?bC(5y~_oI+QI$j z`d&TlD$7l4lRq_i_6&8^7Lo9#0FI4(<=QZ*nnAGEPmd~1vp$fRVX(Pf+UrosO2(^n zWRw@l`$EoY4j3)?KKVb#K>go-1!JW|@4;!jdA>KjnN(qvYHFE1FH(xL0RNac_yB^pFR}tU4+(X$A?PliME1#?8c$K>h3EcA(1Zxj&;VT zd>R`RaQ=PE-N~l=1jX`)_iV~MOYPKNeCR&CtiT9w0iLB2Mg`SHhqhtGzoGx+AvhBD zIp>C!y`AZl@B3@c^)_x)_?RWIaKn`Z{I$CDs)G1w}cfvZP@R7ALa4MIS8RSSie|< z)F65_-X^AWnm=)~wlIjonqab+Wi>-{oK^a&K(2@Tk#}JnPf6;))+{8^bM+Y7%~;Sg z$jgH?F|C?J``A7;zSvjQY6$(Zpv4L2UbO$Kz3<%d1~!V!$@}(du@xR1$N>b1e>wvh zd5Sts4{wcfZiPr)7r0?VA<+lfJ;`V@$!(;=F5^1 z(3U^8cLOz2TXEWon(T*@+Uuk~Ae>v|eUyjFO%J~gB<@S3n_A<&;m6#~<0OZY%Ex|uJu*VG zI2z*Nb0~Sty#?zfw^$0TP@k_CpP%4pbI(DxzRKcacx_sZ(1hy4cw_;5t-(G#1pB5- zZDQ1bmp!$x;BhAD244EeKAksvJ(I1ERFuwBfG4XSR^9K{)ve$cR#1>F_hRN#mbHgb zy^ZVl+SSKvesZ^~t5&^n6GhAdKFB6Doodi|30@p9Cq$oBba~d}Fpv&o+e*A^fEvZ3 zkuQRjhnl?LMBfYk5SyD(UUR1EA-mT9>~r3RrYtLq6* zs{T|7cgYo7fl#YNn^Q|f-yjM^?OCpIQH!z!oz&qiVZk0MD4C5gtWZzXpVKrv#QgqH znB__6Z8-h9MfPNMvXy65_T2f0IL+@4@+3=hRj@2gfNzsXa(TQSj>NdI{r!Z^%L3&q zn`_EEd(M)ter=XCM8GX;h%sk}{41q9zIR0h>K#ha$*p+d&8|5dX>4a*nBCZiwAkJC zCdfW#BtqlIe7d@9`qYDn!gZ>br%B=a&{c8+vvMj-Faxm2G#~BFt7?YqK|3?B_roET zq5{6d9Hgq76{CqZdwrTARUx%w)>s8sqE$+1&1{}k>>IF!b0L}x57nI|ZjFp3`8)`K6NH!1SD?gC$ zjh}Yv)=+J=T9-rcoiN6-Aqh#^hxR&{zuppL(oKG1J#aNp z^u?XJZC?7$rM=^VlRzy_2vbcRqxffGaOgJeNf>3q;VtmFDW@6AH^;gFlvWvLE@XaD zK4;>kQA+t!E&J0U)m+~TPxBoUzc-biyAf)Tos^nh_hN;`YObmuJlv+?*;^XvTz0~# z4`-z7I#_UoVgd~x$yF=pzYz%PrU6kT7~|sKMjCeG+75}1p=IBGHGn2?$28cO7+)mg zOzv`rh{oBTRdD_wUoZa$(RPAyX5`}yDrC3R6s;9RD@j~|@Ntb4puOm5H^b6eO36T{^8gw2_!o!oxEPhUzD9)zrJ(Glcpq z$D!TM?+7(j*{D*@O^aS_M{^o#X?xR;+Xf8F3wyNZ6nlQxSLzt zYc9isxa6E94ylpLN)JSLxzyKXl3P7bU7P{;`(YCQbYQjSXV-CY2UjZ&bhqHLkU;VS z-y|-Qfb#9o5MB~{QnXXl^ZYp5vVy_dHaDg`pjBhP(~c+&My$T2z*@?@nqD;zl@^XB zmn|hEjGYscnlFlmjz7Uwj>YjeqqhNj5``kSxPekMJ+>|wad4PUYV0)-AJq5QIjiYv z(!GD`(_-HWKBJsK{c_LbQJTiLQ2aM67sgQlDkuQE5ss6DxUk90i(Du4pZC3;opgg( zMc*iawew3+RXun6zO;|&4zZ+f=8RN7SQy1LXBbyHX7mO3tYvBL8tV!6w{r@qX*E+qoSrh8FJ)H+PHi}!ZJ*754AKFKF zTUVRv_|C9W;>3{0R@G>ITh=;^0~c*c0s}^F@H*5xUpvP@+wWS%VuAt{q}lHD{WAroV!6tYeTMa)z7Jc8}dp`fb`~5wib3W(&!*$H@!<=ib>#^J)xBG1wcc`aI-sMHC zd%!b$zYMkQQ8UU%x~8W)a2Q|P6ujqmPrNBjyjJD;9jCZ8?KU^vy9g>wVUOf$C_lRW zO8jZ-OZ92#Z+oVlN#zNsaBkypa8*J*Wali%+OS!={k__p@qvFTTs>;%#ebU|A#gjc zoyg8Pqm_@Z4zD#*_YVt2!2Myj6nk^onRi6^6{pXEL~c-zf{(|HoDH#JrxBmxj$)p5 z3}Y^TQB=M?8e&wUi-I{4Z*eZp{cw;h*XOo<(a;ZltaQgi3o&x5E`5z=;jS6ZQcGz2 zWHGmoW)`+ymB6=tMDg+X>Y_c0G&)eM{$y%R6*po(d9Z7TUJ|RU& z{qgEW?)X`iq<0LXR%PWzopVN~n06_sRdz%#JUvToG;OS@tW)RRtK4PQMOB{~N^^z! zu%F{(zyet2sNoI@K~^IB<|+*Yy?282T}6c}Z0hnLXSG9LXuvsG!W?+vwrd=vMJ9@4 zZn_>6Ghy_#!D*FgrGB-G166GS4l5f`C3V>zQY)7)<_Y(6z=v~CP=xm-$_xA4L;((R z*44-b$SdUgf&JfiFp9*K$~`S3`4Jf0?tZi!SA!(dWz84qS3{USuJJ z`xSUQkdT8y2v!gO%qg1S&hB;?Ax~*I4;fokE##;OzmBEBZ-4l}0aLg4)IU}$E{+7g zPxo*WUwD4^xJ&`vh=lvPc~HQB8wPl>l^KP}261_SDieAHAhgnm&qK64+6)XGvKXv; zYjzPQj1raY$;~OPD4)nN#EFmhny%1|9$PczX z3N+o}z+?Un560bRHtfn;*$h84iasOmW{N4pppQM?I1+F`qTF+rPp00{%}y9_TAhEW zzFlcApu@54ri$nyzKwtUicwPZ=rp9p^LH|clD-BeD$j0>XjYYn#P^?`^>^Dj*jeyO z?&YFkbQZ?J=&R?pkBc)^IPjJtorUefvw$tze71?N(!vgn1ow!Mre`06Fz^of>8GW? z9&Pd{<0!w`sjZM1MTE)lCo%K)tUZy*=@?G_%K~}WQ(YIf? z*;5-fCjZ%HtrXk&aLtkYd6JxO)2{)o4QMp#+M02m;PtCj^`-!XOkF}OzJ3+t5BouZ z({hQ%q8y_(A_*1);)`=6PyefbEhZ9qM>*vhwyD8mn8^#T zxn|N|>&KaSKEu9Aq^V7E!6ry#6cEECCX%2Ed|$y^p@%@{N|AY2DS!H+pr8|SWJbk}*6#Qq~on|y|oA49ZsZr%$1ISF<=VzC4 zv_S?@Ae-6wpf0btyTD(lXqu*6+dQP)Y^^r?F}3%li-mu{ak%@@7yD(_azM7`t`tGm zI(n}opg~vm7fRJ}#1r{)48#$Cit3nNmE7XiSn6zqg&xZ2s(ey?>9m{k9n~Lam!rJS zm?df-hid4l#}pYD>QHOn-ZHE|^kX<}B_|o%zjVqu&1pQ@ro5#1meUt#7V?ztyFV!0 zLMKwDN@w~jzfzD#^{^JDYr<+bm#5e~2DJm91kM$7yp#Gf(rS!BBE#B3nqC)AR|r2G zf|qtx2v3XCI<><5kn`K>(_DrFwb4rRQ~o~cyDFN&>^zn8lde|5F0%l77`Bmim=xr# zQS!{b9>vNkwd|v%n~&1#?^klgBSc7IA={+elKMqep1R^(0C0cVl`V*&7=Zfr8J04W zx)akSo(PjhmEYe0jre9HjJ@cqP?J9d zU12FF4b2rG-)LV+x~oR@#?QuWZ6=WM-xS)XrGTf!$V`yY9F?QRK|kM^67PW5EN;a_ zt(24F2cZ8%*f5Z>dY*$?UXfoEFXg+39P&Ha_Fjf11Sd`8Jz*DKf}#L2?hv8uB?opi zfoDWsV4IZC(UMDHvJH9K`JGiBbz{K-vzc4Jq5J={?OL=|M$+|DUaAsRUTmsxzksL0 zmyenC#2++~F^EwYyv=gSXv;VSo(+uQCaSKDEmPkKm;sQEa|C*E^v{RapRPY`&mKLb z>Y(dyPi+2KHagYxYjq)bUqXw2K!WIXP+=Rb-7$l%4PDDLP^~j7X){ zgnk?l1w25of3|8Lt~bYsGu|lsM$lAJJB#T#4Oqq03_VK-XowpoHyZNmNK6!_Hd0#X1= z#>;oYK>e{{g^yD{gM_x((r{fZBTrIT1)eD&0QB#r|6<>A?|+6sdh7jFnvSI)1?FW- zK#(4ld8w7-q%r=h_SM7mo4;rL6|hYT=U+bjJ?+-4&-r&Q=byc5)T{wOZIgdUp%Y&N zTgHQXsvLU&)J1_U3g>QEP3Thwu<#B63WctYHETUCt0Nb_A@^TU9@iC^tS&pD<5*S7 zan~wnCzn?$l9kNiqJn?;zJNG8j~mS%kKrqF2yGr+IdbgoSY5pA#J8TnK(xfDuIQ%_ zF%!St&0qg4pdatL`9~TZeff!nLY2%Htc4&Q<^vuFOknSUY>p82|C6I>WQF%y1a-8Vy$1O--6NDh{YnPhjjW(2lh5sx11^cOY^ep>_8qY$itxoP$@vd%MV&p^L{U?WqIyVd3_M?q-s!Mxfx zHQHD@mH?*hW{;y|GNGwG#U<~hQ5Q*kmG`uo&dbjfSdVB*Z1DdABZf3 zC>0HEqL6Oeqr!ejZ9sM&V9tF66maZJju#d%p%6eE@m`J=`kdGvw9q(8h*~9E8{Hs9 zTpov~>|cfkyfQuiSl0Va`l$f^SwS%jZA%Mc{9N80>cJUrh3!M@5+VHSfFDQNa(G?L z{N@nMRBx*( zX)6jlv<)(`X2Z!(S!fKXt)yX+cpXaG5voKN6ScZg_c1DI$HJO^*ZXbk&U|PHUorxqRh})p zt6e-+?HA~Q%7Ci{lNWB-%_Wpxgr~2*nYQVNMhh7(pkrcan(}?Pa#4?!c_qbd&2DtY zxix62*SLz=^pFFi6>*#DI58gylLWe3JUfUn-X0O&NQEII~X^>&`nY`~(AauI{t=Z|F|gHr#QnrhvGCmRwLe%^B}N z5{~+9Rj?AD!v{^Z)3KMZ9EI4sjyX4~&)@dtcg{Q$NC2y?N^-!PS;ArL1r8-cP(M2W z>P?)qt2{kZpd?4P4z{h_8)@^?kCTnGe@)%-IelrKa)hnUm#XKU214nCI5*L+d<7<0 z0z;1QRl{e|l72X4uy9WZ;W@5bU%IWW#fWzpU%lC%edy!|vZPJ?%aoJqCO)H!CHGX1 zIDUESAxHCbu%^+}Stm$npI~l;M49P;7|SEvV7*8I@(`nn3oZU-C=cqSA% z#q$yNJR*|RsJxp~*o(lDyE{*R5ki7K)8rq?pTS_h^%o2rl`~Xbt`AEu;`U)@oF2nJ zz5m4X*4lxMtf!n3jv&~#Y$u@ffPyluUsA4u-^6y{0&J| zuiPF%UV&Aa|GC9RnaG*$AjJ^_htysU>0s=$YBa( z8ms9!qLMRDLx?Pj@0{|hb?VcYv^~H=%WkHty^5NoBG16iU<`!&CBe#NlMR24Q^%Z` zoK?n@ff$LLZahNVzr9m_y0+@OY5pCXdIzV7sIj7A*D1e84hGjUWZ&QE+i~e!d2&>U zgVIu!RVH*|3KPs*&p^k9aZQBS%?ha^rV;GPa{_ig}SX|=>O{~1J zD~VQD5IFy9)1lVoTKage_P=LOp4I;e6X6Fn0}Jd;F4zl-eA3BtBSsU+dz1LEBHU;0 zneX3zWo+3HLXZ*6DF=n{HzX=6tAwSeq1Keln80!5l8(g5c*jxyC4#6(>lxTR$N|1I z*hq!dw-#|ipP0b6`FXjwX_XSlf%8kpnTNV8Y6hxagiTT=H-*awz zCGsPlZ2|cvO&`tZutM#f?aD2l=DRhrM>pfh7ttNl$41FoZcD9OG2EQki*p;ovY=4i zI)iY_3mH%;bF>-{!;B3sh%HjQ|0j(+)UR!=_09oAHsKnD%VSygbM>0(gpf7srYXdm zi1ZYK*}zl20uLjckp|k^5DLw=8N_HfzJ>*&9OmV?0Xd3TEnAo?_d@I?M>a3lZ>z?i zRn~*z78hCB>6N_yJ*}E@>2%Y+@mLOMumUz-ltbwQ;{B?U zMK{PufUzk$zmq9Rzp$}uj5&V1F2lYdbAP4D=iq_U`&4%6)JWm|SzrrrB6GOy5pHii z;9U(kwKJ^vZqehc%^%Qs&k^Dsl=52B-NcIX5lB9+v^yN<@UUJc-1ps&(qT5)Fq(|qA)H%&p27H&7ReafnRE3x}U z#ro^!d&cS}|7Ly<`=LLy7^-tguX`ft&D^D3Pof`Pwp|5dgr5>cDSM$Ew*hy0J$n|Y zW@P>=Ys$D4@E%)~l8~w<;HT?JlLJJ>s@Dm(zLfabbcKsCYORK<|3n~L}=P>aO zfO{U+s1fKd525X8;%$jUhV+rLP=Mf1BNxzEt$}Wcz)48LU;3s36WuPJ1gZ6DE4yfQ8z>($*+U(gW3wo^q87QB;KlCF z(60#O<^#(NxohcXHD!HkKPekHaO&HM@4>NpmKTI|1Q5K7!U23W`DZH_Y};xgE_iLM z%t7>w8tat#b6-yT!)Y6NpgL!%ibj%^RycfTKzd{V}36(nJb!(}POJhOR4SO$ZAEu?*X^+z<0J8V%ke@GsmDEgKPs;c1+IwzI*QrOvBE_A}|f-PQ>Id(hKT3LEV+}Xf=n_8Rpvko-YCd^KKyMQ)sWk-nu)Kqh ze&~|%kx&Kwo?`*!UD<|<{-*~sNDjhVf0%nw=pxMq^jLorvi*5+i9vsvOL~NGzeR98 zEowvPg)GDC6Ssq1=6qP49C+hdTsgsOnCHO$fshg`M39D41Q@fX?BPy`B%Xd^B&5mt z_5Jm%c*4PfT{zj=iGoH-E~Ylaul8v8-IqBt%&Mm*27E6PIG?+O-(W}e6nAvn4}o7- z^GhgAVY393COQ%!jq$lLg18>Ng^+M@a&DG76@SZf_|T$M=YiMtzi*4L;FIvP$@uqs zJ0J(az~LM++lgoWl_G*MoTN_^WaAmuc*}5t2#wi07-CUQKCd=L>TK<&6mFgjwMgdm z7(^(R3cC@t-nFx|}`-r%c@_V7w1?KE-LpkoF&{gbtyeT5PF1f+fUb!5sBgre9};~=NOh_g33 zcp%12YgE~sbHewfTU`aSQ=g*nb;H?fGzICrJDQ_%Ny_S6KkQZZbT#ju@H?@3^{?7+ z_YPnZ{1ZP75#$@2=JB!hfcq@!^gx?aJ0-Ok8J3hY0C55js1OKbb44woT%6w~%d29CNC6_Gw!4NQKve}v;dhrAb(cbSJJZ0mm zJ$Wm}CgWzkm8fsx_jH!-%kDYL0flq*c^HCle=F|@rV&qjWGls2LU|8!yK`u34Atjb zUFBo)X{^k)W0dGVaKrQ*4mm9B->JnV%8)^6S&%j`m~lFFzU*{WY03=F?`G;cRrP94 z<P9X+0F|0Lo2_z(3q+X=i-WIr1s6tUyMg)|rhhRkKyeml3+9)$4A*@rChs)-&Y zBVW|MzERKhys7*Ank0f1d1j>%yPJ9!CyhM-xUw(cPE@svi&10tb`lQFfcDxmk3&Ma z6(05{11k5@3bT5o{vdg;A6@?NNAe*u7EB1)Gld(W*221B6YbWV0lo%@llM;pyD+z` zbAMwBtemQ@{q$&?sTSwUTw$(8;lU0%3EnDB>$U@q z(8aTqB&?A7l9)$aHoV<9CK^}04+O~ne!l;TCM%s=)!ZKHg}eb4IRbS)`D z-S(?)BJ}bQD!tEg5cQ5kj@l!8KKI6pf5T1}?;s(mk`A)mTenM!)DLa=8N6#611)hT zf}LtpGK?%ka7G%F$PLiaEi@twT*X}qbN@GtYnIZfY?=J2lC9spE%Nqzg}q-{cOQgH z_*-r}`7ZANw+jp&s)WtHr%E*kvIn>d9M7EHz_Vg3S~v7Q&hA8F?@e1-+%&AddztgE@UOj26W^ZQ$8e*-y!wNR1`{$9#C7g5%iCQU+tRK;YM}#vi_*Lyq!M2cIu%Y z#?mCQluzhO>pS?CqE^z=;yE%c@*t*~TgrXw(#*(#WYsOou;f;%d@E2FV9I>+zwH7+ zEm*C^Lud*e2(ngp`)U$bK6H7aSon4NKsDPfIf6c4lVz22J|Ng?LVH8Ymi5w`JKRGt zjW(Y=J^OVXFAY?6 z$Ok-CP2>`*T>FB`zd34`DDvfG;gT2IOf_vz7`fmt36K2^5cEOIB9k#Ooe0tLX6s^e zws4K^VP0%;v6DoRp<+}ThP}xt-gP;rGHF_?n%1X8>v2OL`JJ;=K*g3Xz0|5C3S`Xg zGuds;go^PU2T@pqGJKnB6%wc1Xsy&1(pWpZT&0IHu_Y$>-x3Q z_B`4!H#sYjQH5cBZdfEMEHVUH`EN((`eir9iAfR}wR5SPmE`H^IhE-JSePC{!==I+ z8aliIH7ZDV+m=A8^YZFP1fmSF`+GC4R{ZhV`LKH%S@UskwO{HV437m`sd z_Uh4i;OB~M)Eq6FsXQ=h-6uBTt$r0qMDhUwdG5GjL~YD6lTL_%h*4mm;*bQY0Ja-+ zSX-G>n(8_rVl1phMCqculRrEA@@7wM%9q~5l@%vUG5j&S<{OyRed=r~{(V$co47ny zx&g2%U2>BJ;u(t2&dm9;V$d$vKuM)}OxN6e7z*jIEmg!=7d`f`i-KnKTq5r%Ikx#X zd2;qGteV<+QJtao25el{EE>QLVNDAFs{QEkF+F62!^P06!~YK5*VE84Q~0$?np}!7 znLfXZYkzsgKqtdbMeDg!eCqTH3v1v)x?Nd?^6PJM&Rl4^*m#e@)J8#+6T)br#Uo^p z&@Mk^;;dVlP3Y^Z0!~6%bWOLzf-Ft!C3crME&i&YNJT(2zQla?@1hj&Xr-6gCg-L` z9<5jpERQeNrYEw}!_7vN3^GqidS3e>2KhEqlfl}_3&uLJ5ph_BigtEiH{=M+8+^oY zq8LsESteWX&M!kKR~xM`HWHjAsaBxr)I>u#K_`3~^ci;0Rm-S>A)+{JmPoBJXqyq? zA7kSqDN<_Lh1dfe5S)=%-#-sV3d9dHkY&g+qeQ2z-^ilIQFMUTs*hvAi0TJz7agx; zk##vxp$}c|DOuE69LI}glSM&sGS)Nw+Qvn*i^4N?^>Jfbrjf2uMt7rid8Z&==X;)~ zSo_($yBT1)`Q!I!gsY{4lggh!sUL~2-ZEu}YNO8&gh}4Io%wTW6O(j1BFrXo-tPlX zpQUx{Kil-cm(_rPfTu1m)J~9C!@D{K9yFnyWr*hp#f&?6G2rso=|x&h8XYRLU8eb1 zLgHt6xUs3k<9)~XRpsFH@U7RWD3~b5gR8=ZMK#0e)}ahM3r~kgVjY?|=r)jxnWF-f z6!>K-zH0RM+p;0%{|bG-DA$+mbf{t62XoBCUWRO*bAEhl7m&=hqQ`Xhqv-}OKqBW| zO#B_-2*Z>U8dYc@Cn1P;a5ie%9$0t8!-C@%5eUgRp8_R5>hCS5%40w|n+9(;lCqyY z^F+(!;G)UiuY5_yMRcbLB&&_0I=-&KlHcrjcqGJky>H6sR$bFDiT7v!W74FfN43`4 zzMb354=TONW8ETlns^0#PmZ^>osY}NfBfie#jktF;o@T2X2J!f4EWD7qY8&!OMT&5 zIy;WYgeHL7EuL++D!XJN0U&4b6TdfSe|Dp{^P$6hKL%CIppAMM^8;zf%`I7ykEjFr z)Dt?+19UO%1zocoqkcOzB5LRe=B3)Jhz;HI_< zRB}g#{cYU>D0&|l(1W5!h|+yx#GeK85TeMiftaD6?W;O27Ws?#38$zxW4a4IDHfjf zBe<^#zk55RH4FIq5a0xKIf_9g;7Rx{NTQM?LJruA^e=&;Di-$}a$bv>=s6oOydiQs}2#{Cgytoxomp`qU z)h-v)<}m28uu%L)eQZ#E@L06L@A-_EHDzj|2jFXg94ny+fp-n}6}Alou!+whQX3WO z4A3Ttc7=NfC}LKLFBuxN6J!C1^?O5o;@)H>LOJG5G8R!d<>c>Z@O5IM@toPbszn-- zcen+VJ(ycIeO)a-(KxtTK^sECZ`{MUTIza*-9KrWJbV;B4+9I(Lp}JfeG+c@-Bbl?d41wnPA2i~U;3Os z9`uC7AHki()cym2E@1nxlJU&YcGz~l;~10FWPNjSpfhL3*V!Smb9{Pp`jmGqXZH#b zsTsMC!IRGZcWe8-z`0(uAXS1)gHI7x#E8u1%B!=ZlCXcKmm&7hU9c}| z^o>MPZ`#qfZ^@w!6XDGzdi4`;Jx)?edJpz?AG)oZd$>$hF^t!{^=B=}!pil6yM{h# zfqD;?fS1FW2>bB+gs1sf;C3L}o(X-i{FBguJDNkAA6VY33)OLIPIjK&-4r>!)FUTh z{=j%7&J_OVsosX`~WF5Xqry83JZ4EeJ&J+$q=`PvSKc>T|L;s4Be!QOwBn+z@(ah-ycs8Dy9?JnG1sLg$he*n?v5ok4`kQ2?p;q=v zZt?f&g-j>SBoA_R=02uJ!?wGJBUD3qI{ZD&oWf+L^XkqkAxtrikD++63a|9Vxfs5( z+|9|JpnG?9UVECS>=V8I$@}om&)Z)><#s#hL^|Ajp10;t)t-FVaKi>6ee1{MX67sR zMZ*V0V}Wm-BZe^H0)Coc{O^%rI2DAnVXO__L+pOQ7Tlla0nkF}YCRCesprN4?hzbm zXCMj$Vx!#DILm_zjh=?akLvL3#9xi0(|M{Lzx@Xz>K#8&e(NZldFlz43s%mZ9YkV~pd&f5vqzYBA$AInb9G{LxIYP5W`HXc)d3Pz0XPwz~GO42Q`vUDzexs(r zS*yT9_4?Hx><^XyC|Vc;OXyu0f+)050;>od-`j30@ykt2KvZcm+1os9?7;}P4!RmG z*cGau9`?hjZRxDv+SL+;JA&HfTq(UfPm<112H4|VaX|{@U~8!pD0J5Gm44L7tsAIDp0NPKAS$}hFd|J7u0N-wyg-2Wkh ze=g0a;?^IRf=A1rDY_+>CCXNv&br>Fy*(-%pN!0D>g>6!@5cGF!M0+eS+1N-E>n<+ zJLQW|Shp3y^-~YyeZ+JaUgPa=bVVo2vg?N(k9Z_4VS&6=SM{4=67)d!z|7ssm77w(6YD0F6ex6Im-tPhpDJ3I5N%0%D1IbxA{ zC<}SKBd+(GNwx=XAFj^{o=EksdOiDM3D2-Q!;6-}TB^PU>l~HD`+V;f z+cGVsvFf6lTPy;er0Asz?oJ`x;HEyNMmKHKEb}Z@UX|KB7-LPJ4Rn||KXuW@Sl7R} z=q5ZXK^kd_8BXx57=Kf^nui@jUisrMNFl)man#w@hD4CIHakWv#SAbA?Nu^MGwhrJ z#;OD+ro(LYqdX*W6-CBWpjJbyUL2>^e!c%WeYo7TZ}JImld}46Gf2Jy&#pC;W0)3a zt_KxNGs9{F-8>tEkI?8X(1~h&a>@*}UnGk8KU@m?{BHP09kNzD_P+LUQtW~KnvYON zk{tpc5KG*Q1#k2qyKz0>2e3=*am`qRUAoqh?0@60Y~(c!v9xt{uUm8G((ROR_hRR% z@1*&a1?H9D>hj8lJ*vN#n0PsStAg+VCWdvd0`nNG|%DIPfDz8`KTj0Q{wF|D{rNN-`-i5KKJ62+L#(#Wt zG|Td*_rYw#z)j&PGcs*#AjPfEEbYfn)v~bDfi{CP4BL1rC{Ul&m`%@MR87Qml+afd zaHC4Hz#5tzZTrGRgl$G!Q}wL+ zI`9p~jTcZs=md5Udtp?l&4+x&%F&6Un5>S>y-9imiC>MI&%kJcHX3aVE$&Y6D$tIXm9a3G!rrSuq+ofbSp_12}@S;uBLXrQ&Uh^j)GcXW3M zV0DC880oKUguLtSWo^pT{Jw2ZAj+E|Ggwvd>0xo7W_GUPBo?V+IGLW@CF;&9azFgi zi1#Sjs;O?o*h=b3ncGZ%;A729GK(MhZ~9L=WbDI#%T8?@F8qTI?SJR2TBRwSo)z93 z&@-{-T5%#b=iJa-a9a7)M!11}W&L1#NRc*y@$(Nlb_AmL~bZvLfDU!0H(_N85KM2IeYXX?yb6zJ`AIn@$eyj*^wXb zV=03gq_N`X`+oXanexPWR=74qErb=s5Z{TZWfCGoK;}N=AZw*uS72^7u7}z)x6b3n zuB`J)g0q&?TZRK@`!X55KYDgW9jZABZ*8pNOVo2Oa`tvvLp$UtF~#7`Zg1Yt4!R7$ z=#c%shTx1KAq56DLsev}veyM;ntHFHRm*z&(7Dm!RwXL%|F_#7pOR-6#R}DEVXZjK z;nfzx8;edhm7#xi70M62dbg5ysCkB*KUYX=y#)`;Umqo2#GV3Q8z>I|0l0wd(2uf+ zn(-GD8R3^kcr46&bsry1$>@?-%Vqeu+B-IlvDAl;>C@^`A>Y(e+D%zZ4GfuYcD)@~d6GTaV=hvb&R z?2vGGMsW{%UTm-jAQPu`X`#Aqon92`zB&rc)U%~xi? zz3GM)S~u-lMyevxPd~ryQ(eJ2+4-E}k`0DQ=Cv@NYri=q?Jg9b*+M=a)h+WiB`sk^+}e(jk615ZnBMa?=8H> zLN3)UyOu7(Z^pXKF{RC3LZ*m;|9z;k{d)IDBHJ4BjYuDVj#I>lUINb0ik^XDMMQ_v zKF}Rq0%=HT+FkdQ@zlSuG82BA+Pqd?Db>4g=tzA*^EKsq$>UzeKmV&0;-hbKhF5Z&#ndL3hdzCMEp@lNluE^uVawv|ZV-`o=j?66 zDZK`>-(#4Fs7>mg3O<zv$%bJ)+61<9VLxQ5kfOvY6Q{{$|v>V#Z;42=^z` z#S|A& zb(|{o;PIA2850O`KwKzd%5wsRK!I%J?95XW4gUFYP_RTuf*sWs9_nO@AzIy(*-Rjy ztGj(%36wY>*!oqWJ|#{_q7NxWH2mtQ(wOgD6B zE*`eDhqe=I9ZKg759UH+0+uU{kB2aju1+)N@}C+bR}N24_>fSSn2+Bj%U(_TXHZDM zw^!irfts-r?13i08efX>fO9Tm7)(bB=U{yZBbsycMn$vJ2aYB!9(WpZ7&Uq~j*foT zy-gxl5}P)S1s8@$obc0(C{~00tQ9;)U(0;-hON`3!@u%XJ1@C?xRR|m9@md)kBZUK+!x%5ep z(@aq%b}6a8v;}|F{n3@X^>yh#V&`6&Hi}-e?|ZJ|;eC0>9pa60P$PP2V=hL&=R$FT ze#)S_2DvWLx9-ILBBSLw{pNQ+zB@E7D5z7)LO;-G0aopqfdQ1DwHQSM)qbB5l1)%e=F(KRt2x#_|ng`vJ7)IMElQ zoTC3q?c+jrJ6nTc&a&m*6@sgx9RE-)!@M&wsC5Q{_x)tNo2}D|Qyk^1(pzpS?1$0S zIgnW0C95Xl!Ar$pW=H>M>ddvDwl&Ru(5r#vX8Kuk!hb17*nNNn?>he^C#F?A3Aeuv zh+;Q&zf_UW$$0p?1JawU7I$s_DdR(Z&i*_L_naeAck)d!Q?8e)5{;F?O(G5)KoJ;& zHsVn}#E-4b?05ttb-gL{bGz8gfxHNrLf;(A8}!PIFhnL^u^U;ba^QAZ^wc%Sc{V&( zIh}HP@}u%X+i1dZ`%L~9-bKa;9J=`^YaWjKJv?Pr^T5x1XzTvStA_eblEuR&ErZQ$ zsu4~c+z%~4$|CWg8(`of$ip6ZFB}ja2FRfGFDA8HcpM04K%zJ03!gHbkxinaA4sl` z=EJiMndY2pHRR^B{7f3sC@sxN{{XU${}?=jD060F=KuQG?cN8(u#H-A1`RF1CC(bd zw%vkR-&{L-mmLuMI2Ye$6cCWfd1qg7$3krSUzUGItg>Xe_c;i6xE;a1j{a8Ej@Usl z6n^oc>&?Vb0;_VTVr1?WSQ#Yz^I8>M z4qErS?RYk8oTJWO?NU^lY|o_fRWet|6;qx0cfQo?JK>0rix&+HGOe={hqOBL4Y6ZO z=yT=OtYn!ea5jM>!&sN_#6fNhUrp?C4dOm%;Uo?tFzxV>RH1Z)PX}DG`=~xTq7=wM zS=j};@6grf{F#$7I9<9;rn$^)?a>TMHg2(JOGPlZYPn#!^@+(vb%|`1AtDf`t-*rn zi*U)M8c2oP5k7$;Q}7=#+^>Rkp}teqlf5w%3EXEB8FoQ>lR;9m-E5Tvrs>j1$m4*O zlvV3-JzH^?Khq+^6+GU9JenGh7d3Wg*G&E| z`zI`>s%>yl>F9{A)@9ypj&@?X?=)moZ&!>%yhD9`P3FE0(U{uTcRs6mUrqM73M#uJ zfEnR_H17gGtYrx6#k&K7@7M^SUXZRUE`f<0x1+Mll~jg~H)hU{+l=d0?AWv&){9qg z$R5>No&Asy^HOF~GYTv>;BA69}l#IvyRBitQVS}`_rX21GK|FG?-Lwruc7yV*GxUx=o7mJNtwN!KAC| znu9H>s*F-GQy3ej{?1r@kC{zhuyvR4c^z#KDy5LJ{V2F0@0|Kh)rXlHONBY-8 zO>MMzKb0vt?JI$ZJ`ir`oneSSGxh){t25BTfza`t)Hs+Ne|O#l<{1|Y&fouRbMpno z%FbdMSL+)l!d1eu3(}tjd6adA6fYu6{^>8C=#7QYZl`Y-SOCyj+qQuisx(dudyh?s zCRzdUoB_~kQ!1X@+?b7qRkQjl(vM~OX|edP4;O722d*yivNHb|FzfkJWgx@0zy!KD zqd+-}Rs6t##`9eTZ@{zi#aD)76<+T!sl3A_!_;~b()n9(1#;Q>&=u~IAI0<*>Qian zo1urVpxUnTD1T;ncpn|xJ6W{1%p(6AdpOQ^Qe&Cbm{t+ITBI>LQKC^0GKzEKKN7s8 zh+^Ra&=WY#&4g3;hN%VZz}Ka%kW2a3-&b>palw<;m*uTPadwXBso3@MzyD% zU+dYS-IZR)Yf2lxRKdH)9`}ruJi5b0N2+TLZkTG{uBs3eUSH!kb+QQ>llEbHJaJXQ zLha$~sgrA(SLOrG5)5dKp%2F^hQg*S$}IGP@hXM~8#sThfkCm@E*C*Me>?pS70ibQ zo5`>aTcsI4p(=+`LT8COwS3*Mx^~}ZY$L)@(cEq%m}i7C^J9>rF9-)8a95bU35{Fz z%XFnIo+-fO%780WFyEeo?jXeAcVT5JudztlPAM``U0*ghDTW)x|#v5hW0%``!&J5@i*lw8jYl`eNU_~b^PpW6Jg|_0z z#;&w-;PD$z3$bXjA6~Y4Z}y=f(=&3ORtkqA`kp9{Q)e^4e)Pc>-bKEN;0AV(U9LaZ&@-G=qv^Cd^EtI*MfZgc>wr=DyxkWzsGDqj7 zFjRC=gMy_1Kb@hX6BVdN)M~1}^}XZ7(?pC- z@RHi9!M;u_t0p6w%{b|1!tq86%bW49S|+(K=byhq`IYz$o-9~dLiSHT91b@%ah_qz zqLc8X7{2s4_lW@XOI5@P zxGMSPjiK(-z8G9NdnttX`95L{9i0Ioud*N&LK^SNk_iy zh_OWp<_pv8!wBdD0OWPxj*b3Mf{dWZD8pT&dMKw&yXY^IH%ZZ;gY<*+)f1^I_m8wk zyrHjxsF9fQDUKDxrz?AB`-yhY4qZb_!-k_)^#9qRr@gmTo%22MTXx0$wZ~v)HROFPX?#Vq|;kdRoWU0M`(tmz$n&?MX$FVH=G~z(a zatSXOQ^Zvj5U8^`(`SJGs!PSf9yfi^)DBEUcOIQyS#zrAz@qxO4MoZ`{hIx{1~j!d zrJZ%R{hSzwFE{vn3tvrukh=LYsqHgj_^KBE% zDjFe@Y&)TQfoc<-qL|}s0(UivAIKOGwG0k_{G&0cKksWui~gP$E|x>tgDL%y9V{13 zqYTZ6+IehGJucRwsYM~^Rf8+{`MqIEoKGKFueSj;Sc&Y^fY#*6gdo?fNuy+#_6GDh ztge8_8UQy;eGr{H!jYyAbfRhEv+8Y$8rK1zqD9=H5eCnAe9R#1x$GGmtgW&BdpB#; zoWIHKoTcvHDxYCKx6s+t0LbJL!H4%k`5s0UI>ITGdi-R zzO?G0C0m3=9$I4rq!#7X_*cB1oM7PoAjz>;Ut*G>q_8;vs z)fRb$?w2d~_%xf7SyHc6ImMVnHFa4YG-%Eo-)(1~c3~u7#Ws`HB^)$sTvk z&aU@BFUndg#I0JyZn#?AY#K=)w#ktR55L;r9Nb6Hj8&Q0B%7WN zkbYZ*^UOfmN64k+J=&<(OZC3}-TT$eL8D5l_bFvP9qLm1SvU_3X}VioS5K9%*fBl|JTk?$jE8 z+)lpn@E1YPns_IX06qe{3M+8fP$!79pEse=*GfeC!#%;Jh zjBq=zxx9RgqXPYRVX*sM@9_1_jJ2KTb|1dnzcs0%vdEpSn)^NcKR?10D|!aKw4 zEfAR0+o%a9f~7xyR{|=XWJIIFR8Oz3BiCRc%dP$~Gw{}3zG?bhl7zR>ojpHqY|Muj zU{8Qc(G{EzTM9c&QNt=>wn82uz_N@|p%C*+PkcUGX|P!V%5ziIOG6o1Q^|3aOqStH z6-=UEt~oDDOS#r(`*$DCaQH$&DB3B z_9IFm>@B@mTdw-Xjt1_k&U|FqyMzXPiGz$9N~;Z_c2afa0yK?5U!*H z;?#)2vB>$U&^L(2B)v3BUDRzYe3HU}Iu5Zj{t(qM+1lt^kHsw8z~y@H_kvwTW1^)8 z{aMl{2OM6{_*DnYmI_AO4%^MR-I`dU1PfM0A4OtA0&Bjb;2{L8V?O5rT2K#668p*h zSRFc^i#eCr-%2~DbiU+xyILEIxe!uVJ>(!4Gvy|im+|ara)~@lM%123?*v2Qpj{Y- z6j6cP5Xof!0l|1tTzcX@3bYW+Tp+r`V$w2~n@O;U#bubJ!O!YcbaDHh=1Rms=ZkyE zV;kd-Kr2=i92&wn=Lbo&<(Um(e3_EA9ZIFv`lEk@+B&~nLKm6>XFS<{b)&Q_ zkLwIM$5<$wK@iBe@^(W;T!nK`4riK?T~e@Mx;b04(&}QG!#wSKg+q^I8|+Urj(e$a zritn~@T=JQZt69n&Mm-GOA7XCcig^7?(Jvq%W;hio1>I~oLzn6nCr#na|6^X4px_L z9~>=l&#V9TB6FPaFeTy_PRg)Yix~Ri)iN#{Rw*vnsUX_At{A!^+S0%`6MX_@C`TqK z9N147r!c%sG#Dt^%R~Nd>+MidcEc!t>7ohmQM>u0eYA6}^BI(lassAa#1sz2>59{8SqA^nl@bz^^)E@<+yDYx)rQCEb?2ZRv@QW53u z*bgZXKxpwe`RO$uDPwyBqBnk{;i}dj6f150dZ~21__lSVv&5;dbNUm*DdVXZmy80T zWOrfRAWDa%8-MP*b+@^JLg=G~UW9J1rt*27k5Nb^r_B~>$gFXGWOGu^FpRnWK_FkfQt0c#X8`& zN1T@)bMM$)gZI#Aztx-JzL|G%r)X8+BLqdIJAg1L%g}5ig%PBim8GXB;V6V&_<;dE zk{_Vu4t_qvLk9a1ZEb6-srT+A(pNNcYVEnn_&Q6u4oF$Fg~(UJz@XDiy~p*i)|h46 zjnHVvDRe^P8UK<6rLw%z1*Ii?ATrb=v}i%WsA9gR()GHt>+|*%o3In@#+VnKeko!0 z5&Bd=*Q#%-{#w1So0iVyyv_zlS=zSO11W(PY0R;3p$kmQa|O9A-31rrulpZ)mNL=bl9I(KNH?F>pDd5G+G?s@ZQ8RXmZ z2g@y~EdTB~wT4bm6xN3TOh=mU3S#yd9g2^w(!!)ZIBpw47?jB7-GDAco^WlWxksP_ z8ALsYdhVu|2#=8U9k>$jz)@4{MZ9*Bx%bmf$Z=v#fqxzgJedJ2@SfV3?6h7xOg6|% zuu+)ke@KAH3BsP%^&Bk|IO~vypV{{pH4TvQRyC0fU71PaqsQIMl&i+Y&Q$U*;#!V_ zzMvY7!ju8$4sog}#2Sk8&)170TfHAhVj`IgKb>=^928Y*Eo)K`F1KE7({KO21J z%lWIucdw>I8C)nd>Lbu|7E(HA z<{()WK<4Rob&T$zC^X}%?Pl%~)@hcq4IP1N`&gDg5><>>%| z76*@YvB_bMzen917Fh9g49u&^Hx^rMj~Vx#b&X5k8r`j2^#nggh=X%3N`1aEElFrI zmV`6pR{lbXWP+}li01)J@p=*vK%=4aJC>Amdc0gBvqv*;JalsXqA2J5GJTVUjujJ> zt&WSfta9V7a5~9M^y75Z-NORKsS=>KaqD_wVpayZ5gWB`4C%X^l9atNL`5w6?6%%y z*!M?pgP^GTzKO}8&GE)(g<4H)1NdaXWM8R{eC9=6BP4Sn&rxa@H7@+(Z|Wc|2%WZREO_|fT@Bu2W%=22YT#$Re>c}uQLuN z9%SS^xU8biM2&`fLcyW1C;Cn2Z(HkokJwF2Ag)+xe0~2`s(L5i*wnU1xDDeD4ybFQ zRyAc0C{R4!bhC=g5Cb2P!_j?-W>|YTaHz6DxBl4nNaL4_!E*egw5QLF?~xq5{`6e; zXR`Ged?%ne;oe$w>g}A**5Y=x>L3ViZ-=UgW)=eiIB=di(^xS`yj4+EUH9o%@7@0M zbz#OQ)N8y7D*ck>w(Rj|R(G9!_^co*05kZYX*k9A0pybIf+#pa?pf*uTI?qzCI4co zH`mfvX9zj=eW>1>o7Ld*)5WsnAV!U&DcY*TZEIJ6k=0m=(`pHmvT7V#_-DtqQ@Twh z7boSLQ#wr#y`>7a(_cATOeE93MqziRY9G|s4YuxqftlkuZ5S7Bs^1zg(yuM@}PFAPJR-L zm0L4ZN@R^_1x@3nBM)D%R1IMg78^M9F5YjkUCR@iAdn<*VN7=00zS%7sX7lKEWY7a zWAxB$<4B50SnH1oAH6%lxk1B)=Z_jMb`I^PlX<_GbnUyzP5GGOqf&rsIV2g${uii@FN%1CeQ;V;!H39OCa8{W(zuogJ3JV#*(5FZ}^ zL`S^@!Y2Rzn6y#RCeBFCD{sS7i5a+D7jizqd(_{n@a;8f*83M0J5zRaqz2rgAhkFp zqAe4k${0NYyN#w9TMF|8zM)f+n$8?pTL~%3#W0=2>vwfpx=Gd3X6yRs>G})d4t0^V ziBgX^t3i~a_Ah8Ak1XGQG3 zPq8UK8tY!By{xJP_pw^Z*4Tru{#W`GNCB^{|E)s)|Gf%X%b$M~Bhkc-OrP$duQLy1 zzEpl!1C!KQ+Fw_H2QKMuxuNp2{sCW!QanofSiNHN^({x7WED0K$4{j`%Q+~Zk$UDn z%tZYkBIL$@5g{A>ZxQnUS%f@vwD@px@hRQj57us4NTnB`+`p{=P^wKG54klQ*Go8j z`N?7aNW9~A-xeBijjfF`Hq-SVJ(P)gjg4aPN8fop%UGxHr93t z%JApRTK~bD|AzWCa^7jWL3K~!CwS=VnM;9%HV@NQtB;nWPj~~$3%MTiP?&<%9D_Wj zev5VVKc?X$P3$;p9--5=+r3p4T%Xi)u?ezfD3eL&ml^A|xqDj@s%sBJuz=6tG_4p( zokow9Fbxwp^X#)+!eR^~Z;CE9Ikz@RLh@9$b!~u;tsHH`CV5vkpmNZ9_O7q?C)@^r zT_eYGfiI-O>ER_o&P)oZDetP%!pmSzadY3*M-&tYXEThI5zM*>X5-M34jPpo8|0C+ zKP+g%`Mgq^$F36>cU>KvEn;So>)w30-$;IB-l=mhb8zKI&*~K8)L&~j&8~XnHqaISm}$&NolqGuu!LE{ z7=x6zLjvPb(Jpw9LZ|8;&TLaDVsdZdHy7?oyn)*ln_>N&6ID%vWMko@Z}tOAw_G!x zbr*hd&5$13KN*F3Gz!f40D)^Ug?}0elN4A3YOaMqeNqd$wuvI2TGC(aZ8SOvi@jZ$ z4ix;g4Gr0DHY=A?Y^xDv?9AYwySC0ttltr4pMD%=O5iP`W{Da)dUs!b&5l*keNd%B zz6dJFnybc>D!O%kI0e{DR~veKg~t%S(Pw}5n8LS8?GPA3D*UsUdz`35f!%b!Jkmvj zN6FKsOZjKPrQBTRlDq;RcfSy^~^-hiZQA$?ohhC(z^rstLk_8E1Z2 zfch|bMIckyMwe|==AQ(02Y*gs8%w+mE@nXAu@JBZOUNC)^URicGJ9#sRWjrP(9g1r z`j`u306)C(GW>BV*=#}(V*jOCT9a7+4vBh^a9|G(x*EfQ*pA7I4 zw#7jg>6b>Ad&bAd^N02fE^mGvZW#~n@sxYABHPZ3E$`bu+fX!;ccSk~oV|TLt?x%l zz16vAN_f3inyp2MuVeU3{H+yw_kQ7cO<=FC!%U;kptWAePX!(@mgRe4`yN6BreW4J zA*z0KF^j9lo8;o#EF0x?!vP$2i@IJ+qeqdmWzow(zUyLAHv*@Zq4H+#9^NO*1%9+f z?^iGhs*m65^}~$WbRM=vYU`JYFEJRyQo`7e`Aze?FiqB@!F7o{J{7+^ z?}O|Elc#@!>PA}~vNr-yojQjY)&3f4k}IN6RxvJXSqqXyY`)`j-=gIxOhnaw^-mnrh|WZYWYr)V(*7> z*dzaZUOnV#X)xT;j5_^W?3*ob2tpbG)Pw{B#xmy6MY1@BIn%wuT#QlRG>oRJrhK4K zD#}l2<|z75%XxxTtf-5(%8!w3j^xb_Bu|tje4Vvq1dx(oUTQlq?$DOsViJ1O_-NCe zg6mv07K4GOMSie0o<5LDY!q!uX`$g`iKW_A`Om!JV+prta)VwK5XOo8o$B=AhOX%+ zSZ)Iw($HKqD#oDep_M>R<&~87%&6Fuu5=eE!G4!doPw^Hz-`xC?ej73eg4h@ zUWI1A-AN(m=V36p7IqceT7mu4#mmZ?`tnn74si2pMDpT1Oy)?bXt%2NdQf*(MFN3t zg;bxO`SYxg!X>tmp39BU{+o)F0Ntf-LrQq=BST7{Mtyx+@dx|l7xxfU{{oen+yQ5R zt8^e+P1{WVuns9cm=C5<0r%qE{|d~}|J84(0jn#~|DAA+|B>GY80NQ>sS3g_gZ{^V zw@lFE$2+r?1`LFbZp|EdT%7xzvtMhVw~|BE8|izGIVph#6IyGHxfMn(FgJRGRE!#^~{?(iqD zu3GMDNjvKEtmkRoFGtYc)5ymTQY!nq)W)XUm4^Exf7vFQ$_`^R1B=GqnDENIt1D<# z1KNM(S?)-G7`bczjp6>kuyPQ23irfRoP+Pui&oO>fW?SZ$Vh7|IERPV4fhCAPimJ^ zCFth7Io%M4)48LD^bcNtDth<{vOr@)9q)zbmO^^!#U5*_tIy!T&*e%ovuW*cDqHbz zQgX$B)mTGe{p?IN-o{PmBO6MXqK|j+AfT6&fhL%woq}Tx!gMqkYCY}23u@w^2vN(M z8~?l)6-`!=upLfuM7dj`$>yhAVu;pJXO7hNS4 z3$Mg?+Qv@qhul2n<2VZ)G;HeY1f?OzWaZ~3!Nug}xH4}Jlk(&Drt4oW|QTf6kq9s4VUZRuK+IKBZ+0-lGAu z;);~tQ(CF-MG>+%$u2!jI%T`U&^>9%p$0w|E3N0B<$V|?s&IV^P7MQ-4w=B% zCL(*po5xbLbFk@8wwz@3{K(>Agw>R(QbLtBu6$E^p}-wdXQ-I{4g4WFK~V)0l(6NH z#|&HOJglOftB>O(cNzo^+Z3u=8RU)$_~#fqEoU<60(lru07alUxK@I=iTF(<*V78p zV9;Z*8Z{hto1Q%8%uwSQrX)D;YvL|C`x%qbTM_ZJbG6MBBi+siL$b;S6`&T z*MJql? zI-;IKjax9U zSCfJ@;*F{+=6}xdLP)4z-|to@Pv3|(y}gNIZfb%N2cDjPi+%%JKpjcOR!LyMZMW;f z-SEsFvHo;1rA1%Cu%g8u>5De8>)qHc)1`eZx6!Bc)#wM8|IU`)^{H7-;0zf{wJNh* zTX8CSeoc-YfsaVh~*1J&~zbe@ED<+;UGpQPX>3_1- z5@U0Hv|-plxhpiq{*j9Sv;q#)dvq&U7m(S|*f@&*I?g|5buUv7(kf&fXRybH7(9F) z)O7|T@c$HCgvMd5{VbsrBQV~k=GB<1mpv5bIV@7_(Z=p^yKlC>O{IV=s3xNKJ9gsq zJ|=|dMNn!21#$}0{fQt&M~_jghcFualR$q6rsK1&adG&@b)+#IVh$^v(q=zb-xL*z zUPc*R9{NhvA*V-%826+}U#d8Ep;g~e2ul%d@!(5+!6;C%8z(V`sQzl(yqgclbzNC- zbXOns!(NpSui_cDa>p14zQ~Cvd-EE>@w=ks0vT$5~G_coXHGZT2#>eyD z+(!woGhMBU&cKIWTGCjSR+F~oiVidQQ1nmVYF1(1>#Pby*F<$AJ+;1TF@tMyO;`gY z(|Ug$5siTP41naMHSlf^>ne08O)RpIujojn31LLiU{!-95#_glIy~APYd{7}hn{0S z$9vW5gn`0mA~7hB_lPsvPF&vs+cD16pETt9_eT?TRW!^{D1(C3`te)Ng?y6t50wbV zWB2R2P0Q6<=uy%Fk2*+{t-PK&LyyekU@|ELQ=25Yp-Ey*(yZ|Lt=aP_AEkq%jIC0| zr1l64D4rx4K`7UDu{`kR^v@~wCV>Q$gqUXUKydRTt-Vhhc8ZLRcW`@lB^pqDP)qrf z`yE|z+Bx)8M$g>E_&xb-%^=IkxwF@1?7Y@mkGbitUcXYE*J(xjU>{;ZkDZ?_>XbC3 zWONjS6kw5>e!gnexJAk|o)&TmI$8*6G6f*R#xb0r?4RyblV>kSuY^sX9r1h4y1nDe zIK!ikn$yt3R{ZOx8S0Ke$h8G}gsGft6-Y#Q3kNnzct(0S>TgiZ*g~u7QL2gaG-3O^ z3u~mXcy+BaRr`;F{zMaV6oz*DShw%~K`RvbZAu0h9V~`FBUI@LWIU(ftegCsP!BHy z0-10Tgm0s*w5ez^CKB88T(> zoszlYol=qIW80_y=KS_a`v(CoMn1^AvX>W+#ijZOpFMn}g6;C+bv;^6<0BA1F5Jf2 zU_wFsXNm^WVPYah>?g`P-NutSa0F#t6)Ar5Fh??~4P=p+Gqp(K8MFppx3~3e%^S+syhSYNNA~n5(8iuu zX((^-qooUE7I=Gxn~x&syV0gQK&iI%rbiJ%>0`Atl16pexK#Yge8Z``dAL`vbL(~U ziavLs#3GlL`kbk4vS^*1_nPw$pSe}Hiu3OeK`;HZBqM z-6(oTj~q@Qkx`wTVy!jj@$v*jaUWJE&tuu(+s1|z>SAYgE<(18HaS?ziW6~(k~7O0;gD4&Qj{A0*?^% ztGQFNl+)S8@|EcAk*`l*c|XWN8q+7c>I3hf!pRVr#M~$@EXOD^3DBXguG4CE9A#_| zj$@-@%;E-}3RUGcnlmj)7{$Vcv`;-H3Dzk6?^f@ zQ`Xp1g1dz2{&se_g*$ha_@HoN;eN-P`GCyx4W=oTd{Z~1MRil0<;b1#A47|59MagL zT=R>DYB$dMx>$7=sGxl#r<5jNLa5ZyXJvjaeH??$I9k*}Y%7({CY&NIPM# zf1@D7s`BUv(cP=~po_yJxXs<%7bH}cy|<2m%|aUc>P@+WHp9vfhKBQbbib-udz+Bv zE34nRH7)e<%IQGX*c3WUttyJ8GG&a9Ca8cJcu<6LP*vc9;V>z?Pp)TcN6{Fq4b7-3 z+VHQ@Lmnp+tz3)Bbh2+Y9#h}r!B^u@K;$v!pMU~jQUZN0JVK-k!#C@gmqD2e9Vy=O z35C!_X5ZxG(&>}8jIsj?cc;a2EY!{KVMevjD0+%|=^QJ{=X9Pmlv611hBUw}co3^K zr>auRiO6mu24%rVmtKAH(WuViA5$-xPydj%!xACu`sYrKx}V$jZW{_FTmkJ>MNY$X z2*wB2-3|( z*@1^2_zxQ|EjVj6(2iLx*6A~6cCIEKI`8`=?}`KI;IEa5T|*5>B-yX&d+Ul_zzkLj zSXA_gP6!LD`oooQVOYI>PP#}AAHz0anRQ!E_@B_#*YUJW962M|+bnPV@}hEMGxzzX zorWXuJ~XY8=fhoTg~z}+7b(Epjh6^IFC8jt&Es zv-9bXQpVfDi>ywKcd34Urk~s(S{}^+Lxjda*9hd3+sjP%Lid7V(N9DgRotkk?(86J zPs!)}f$=X)MGu)(gh_Z$I>s~LqRcu~`@DlT$Mac_syubv-@Hg2T#h`-_f{~^5yuJRy)A8?UAC#gF?faz7 z?Q8{a;jKZ+R5IahXE?XA16F1Dc*<5W2*)HPV&LH9ohAE&k1A@$jBUF_<>x_YlME}KFqQ#fi88dzGC5WqWp)xtcD6}uQ~pk1*aday~ggA z-4j<|TrfxHI@$tbHY$c*y7 ztARlX$8E*kR!g*CB*six5o9_FtV1@I5#DIVU_cg6ZogC7N0ckeDCqLvwYi%8uukDY z6212|<{YHSKZmgwRs#T1?<3m|wZDMF2P)SE>M@5XC01pK0dJR??5MncwFrISVb=e7 z5$*oP?eldwkujm2;*#HxLz=7Xq-->m7no6-)x1Tz9E?(00jILvE{JqC%_!~5xcA!@ zU!QdG_d)F)Hyd7J>S{WzmJT}|Qrk0uYdK80*+a(%s#Ur5x5llSAcC0%+?h@Q!`ZT_ z0x-W~rNhf=G^1cuu(!8|H~eMAORLcP_87J{wZH6A!`9-fPhL+Z`>Zr4uQjAtjuji( z0xW8lGk6xoBD;MYrs4s`UTGr`g+nui-R>n!5~R_!XfX}PCyNFq_d(YF!P5=w9WFs&F831NbvTCu zGKOgFKO|7@{I&u2*HIzGEi>)*^UM#HW*y338@<{VVX-vF_9kCOf4n|EJX?3LaU&OI zR}oa@=6QW6f4Ee8$yNDI_6My7MP0k|xPY3Yx2I=2RQv4OhT=vpKgmrUx6ORE_w+*MU#<=fB;K#4br!Mfpj|ad>$7FiiSB`b*`M_ zs=oB)d{FM+Km-p-b9Qx%`?Ef+Q|ID`+2H-zzz!u*1L>M^0kPxZiEg0s=qwO0YvsbQO#4>j?m8#JnXDN-fK{tie1I`jgX&G0=y)+d`Ia+1c z>E74-OGX`s7s^O(AsOfCcTGxihs*~Muj8!sK{NX4|73b4|ba14*{xStDwzr`@1 zcd7IWBKlH4T zdE~JEg;&XE_7PrY!mAFt)vgye=>IGl5^c1NfmcQSC2*dBq|Akx+P{!OkM>i1 zDN(4;Z!tD#|4gTH_% z@z@j9;Tq>yPopQ&aW+ibwO{ll(`7(x!sq-J>nGBM9D?8>9&%b!6Rd#^Oa~S2F0CIm zj2ckk=NPra#LGnb7zeITILE4$#1tZ1ANkcM5_f*0SEmH()c2$H(xsm3s^j}U$F}Sg z^3;y!gm-`?JT(t{xS8k>oyY>7C+wIx5shHiuX73)_PhIXsTT!bP&JB&6lruC@ z2$cSE$?3?=ubl$VPs2^ec?}%WlV-&+fhA~&A#r0{RXptsPu;=Cg6XngJ=qV5*U~gA zYO@WusXV^Kz8vvf%v^f-q)z(kxJbE~zugW|K>y{43v{2b9kl)D(jEMdm2K9*^D>0l zcgUmYTxgh)anHoyl^L^SA%eQ+sCnKO+?!_I-8tl^oQklk*~hcBA|Vex0^0{#k*4}V z<5RG|O!qh=MaCTKm*j{A`#ja!jU#l%e8ucOCsClJ%v^poHAv^=6 z&?#Eal`D+H>}4Ko&AKES9)ktLXJ`)h(N~y#FHpalH?1;}QOS zU7B}rYglp&!a7VKN4!Nc#1?scny_ub^kC>i#B^j|-?%yQff1oG{tw;t==ayQS~&!Q zNOq4hX3^zfMJ*r~fj!F9Nt2k@B{awWV@N=ix zQE(_wt=E;RQNSJ&qzGR2f0Ftyo=XEf*HONyz{vCjTn%%Z!+xU1D6Kw%>@ITekZ|fL zK0i3tb-416itFvA+yh3(!rtAu+jtyIA$2n(VWORh>HO0{*Rj+|En$wD9a^%5!Lsk9 zb+CR#n1$+(t{jV2=bya)dXLiCmUJ)molSB=g9-SL(IL0U(NHNW{ofyrqHkivzb80U z0a<%;jd@{~lsgh>BV~=ozIo8ng~cU>%a!*oRI_Uf7Pr+VpPbT8j0!lv-B@i0&{0(C zMbTS%$HBXfuV>>~I=~IH?t3As+GDG44xl~cHI(IIYR#`Xf;XU%)b)II02 zkvI;$J!2@90$j2P|;xL*kYugMZFni_vOJvc3NR6s6 zxW-iVw#p2b`Ps=k^^fKEk;D+|rxgn}&AhK^fE$F6S|Ws_niY<##z=_ND3tk+==7iG zPDfFZA`AA<0ATkY@ga!3bKQUSH&}B4h$vxULAaJvq;`;`&cU}V_tc9?niXNG% zn}qO1ih~U5jT2E|?pYhesCs-;TV>WqF=+R&o2zK+M$Ir2NoEK;mQd*#FWABjzrZ!e zhgT6c9t`{87IL9+oVz zN@bH6AG1Q!8+6>W7e>vN|BHp{3IS*_gZ{|7P#pt}$5j?aH*XMJ0}7iEcC_Imxfxbs z-loa|#QY`Df2V5CggDg3@Jip7Z+jg6aJ@${CmoCxFdXrs&7ywFZj5a17hDOE zy&<^`^I*bj?Ipi^cT51W#jwKSCy+Hd#g}C`Y?yn$lw1FUB-mhYwCOfgc_eWQz zi(bfYF^$+1e(umuml6Ufl>Ao&uGRtnO*1`!xws>QT?8mi(&^iApS}%;(i@{si+AFcG2$HYb_pF^7j{c!b<+dMj>aUn(pIy>ZBo%}N?*;+|IPmEt18k`f^E``RRT(e#i66v7>Yv7*oj|AnOSF`pId7)T)k&%5; zJ^uZr*jG1p7$5yMOCpp2DCKXly$z(bWwdbnDUjTl%K;0>qkI?OPXGsjD2%>Iq=xOU zA7yRiDomqZSB4hOt=SY5BEk;(nO6so%~H#Bre=K3tPj#etl!Tp=sl2*`+@#pow~~? z#Lsjnw^<2t<>Lf9KpPGw4{V!j9s&+D;K=NBgS6V$(22S_tlLA&4aag==eo6@UQc&) zjLEq>ng3$j?!Q}F-u+`Goir?%dNrFaj1XA8UQm)`$LbVTQ1_4)RylvOlxH9k%e35!iMHC)#XhpRzXK)* zo5})bN0T^joC9AJPVo~YT2)U`F&oQ*Y$kuY@vqV>!}2;?BmNTb<1qenw>$Y>d;(xQGOl1zk*?6^SebSL4L^yJ*fwM&l< zr1)+dJ=*rIG}+C{XY)s7Y6yG->^6xuI%N^vyc0O%DlC2m1C=C<0aHq~EtH|vgf>H+ z-E3lYI)5ZH^SaU4ud&-H?}N(DZr&xX{kBLuZ%uU^2+01FbOn)Z1unRo09t~-#D*yx zILHV(kzPG%KFH8!YjaqW1vj8NEu-1%N4FaSa;JlT-WXCs=#6~Lm%xsiuIKc>!z*jH zNut|EMH@y;{N)Q6@TmHc$J_G?X;9>r<;rESXPj2*W}E#N+n z^7eq43BB0Nz-SW`Ck&wZM_`+?&XnVwhD*sA+GJN^h&}aAxob&?tMd+R1!;w33H=D) z3d58e2K=M{c+JZHSOV%n+E$6%neK_l;^?@TCN#0$P3|!9Cp=i6o@H-OVW3V0&tvsO zZScw(mMjO*IiKLFn|FFsBu%$qjDb`lFHUdQ{3)sE{PNn`Y|Y5=wnw0y={@)QtwXXG zd)v*(cOM21j`Pi}|Mdl0UPYOk60pQ3xSMEtbISqR!itW}(2|O|5$d;?P|N@=P?*4R zGY#orldpFGM?>i7Z!yIp?NwvmBA6t{#ZxNOfK4P_vdR{^An>1d+hnD4tgA1Q-uJxm zwqBM=(%Bd5d+PCj`w8&q!WUSRph~9gVh=Z*k(R_n%Ob#6B{=;CP#K3Xip5?uEZJu* z%w;iqjNIUwwI%oNUv2j(+F6?qK4>&rNc`uwg!;#aW=5L~6etPud#()jB1qF8ev66k z9G&)EZRHXK0Ai6i+^EPmL6~lbHhSc@@sA;{Kw;AtM+IjP#g@#HxQPb_>I{o|vZ%Ml zb(cq6Kf*`He!@r5BU=Av0j!w5%|w>#*+#D>b*e?z*R%u-iFRSUI8l+4k|8Wwh-rG! zoZX~OZ#-|#__|55a)Ii-t(=!nR-^Vcv;Xzo-*GPZP~G^F(h(veKwEuSJy|EqQ0{Ab zb%m>i;5aoHH1yv&@sIuP_0Qic-!(7jg~)tqFI<%Zi&BcQg4(7+Se{d?!Zsb)`62Y8 zK~K{mATniA%VyIKxEZdEaEDO^4j|V8*da!o$-?rzEGOylWlxYRE?up>fWV<38 z36^u{puJ#l^|}cA_sHx9Av6b6*O8<3o~*r+1&V@xUK~W?*!MHgQ6hOd=E#V^b6RhQ;7_W_ z6;LAGfuLpQ+`c=MJZLPFssHHq16!u%2YT0rhqN~NZ;1U*pTNI=`?p=1d#LC~P3XR| zm6fY9SoF#RbRO~=N_^{!idh#VI8Yy-tZt;l>?gi)Lk9>dr*3i;1c&+~9|Scp^5Nnp zR-U=Ead*gNGD!F4kVe>1#hOT0XN|{G)|C2#-&R5%uD>mH?O%hv0ol9E_Zi(0*Z8f# z`s`te+oh)}$-X6$npaP49dEAH`O4qXpuOif%hc)FD_wPZ!pxZyeJ4x!{|FKPjM5$l zCk%X@8jK0irlwFZ-(NH+(i_I$n0jMf=OMFcTqJx8#*(3Rf!xq!Sk>I3B zoE8>#sO=W@Wy?Vpui7|aI-7c|KHVkPR}7M43>x@zC>=}Fhp;4ld+TH8-&Vz+J$}Pu zIqDy8{6E5TZef$0!hn9mSf|~iW%SF2hK5x)S@E6_f3kq3p)u!kO<7CVO()cLHgIYb zIB&t%VRa#6#>@W2EEFIJk|HU0p{fL{Huf9D<>u^_OkQ!->WU1x@$DrQnd$e>-kQBI zJPP;2_5z%*R2N~4&eU9VFhEBc{uYzgm)e9m*vNs!E{ACGuPW*0oEDM=_8aO`b#-4w z>_gr*{LX%N;@ZL*Q+v0&VXlc*QNo$&9RNU9sf%gbr4c6o!w9v_a{Y?u9gVcXw3pO~sp${bGFGe>-_B z%RYJu^oOxxV1E7>N3Dw;P1u3+I4!{pNa3HsTqSc=iJS&Fl{M;I^GUnvbE(X1=bA)* zMff!t^Vv76zs2U`_T1=y+zMIo&Iw8Sr{$-aU2TMQ^OLF-kRDgric4t2JrS934||W^ zZIzHHQE{PqA3#1Yb>XvA@4PYjp?xpoq1A1WBK_-r*6j8%M?M@EO?<5krPtOpv-3V> z%?`pRDFh>f(e!p+jZGJGr33v0PKL2U%LLbCyA{XiHEUPhIt&uW zn@gza*l~LfRl3t4>(oNcrb-|1-<;_M*L*KH>KO2W=}j z9a4D6P^ga74{Pv0^x;Ldx7Ba4KjJ;0gIXB2-{PrQpMP7bKcgJuF&M69CvG#wxPhzr z4lm|}vEz0o1*5bxC70h~ss2IsS?aBzC6pxoxqF*^9%AGWN|5kwB+4N?AT<ur<2#E#6=2phWZ@+l9{p>&HJquzW<6VB8J zNz#j9`Mkt;uw&_)Dc(35-wmqcUTKG~dkrZg7@$aZkfZs;!~jT06kN(GOQ6X8bnRr< zzL9{m^3|gqYFS5aeJ$TB)dQOJpVg+_A9Iy?7SLl3wnL#UGb-9dt_0)8Y5OwXC0AX* zC{m9{8g|TH$n;RiKnJ```myM-UqW z<^lca9yMLGaaR}Tvcu}++~@zdCZ!+#zm=2*(zjnwnt88DECGV8(1KvmKfXDGyMy1H z%Cn72td*U@f|?g}3H}73tW*42B;`2np6!@gC%mcaZJ``Z+kqVAH+F&P%K^uuohbg36yXB4&Wkkoi4yz2o7Mu zu|qiR3T}J8xF4s_6~7(|)q`GB+)Ne`HhBrGQ8TWYx$r6F9vD)ASJbrPBUF_jm|@;D z{0V3<834ShNS!l7k1E4hGm*;COB+eize?!R)p^@r7Jbnlu<-NzQI@LiAFqAs>M9EG z@B(@L=*MbOi`tkS7#P>K9WEP;u_3)_X(_UBX8bh)U>F6A&O}OEVsoj@& zcJX6co2eV_PZ%cy8W&-f(E>SecE10GYxSH)ul@v=Sx2MaV(KF?&4DMqCrF{07q^sX zR6Wz#UrIc*KbMGjG49~vJ(+1-V-&JtgDPwq^jRZ_<62B89=JGQxY~)MiMv5L1zny~ zwj1ANi$P}>9&B60sAgstl;qspUC}M8MQ=k^+bO4#uT;e!6+R#lfD-07w??o!PlvR! zev2i|B?q;2Cg+_W$$aOy%h<1*D%#-s6}!{zN2ktWvxrDB97p$78p!n?(r5rm7^@1) z{pshDUJ~D;kdAjwI@CV{?UwZh>DF3Bsy5*zGPrrQzc1(iaal>S0IpGB#StXDOW@)V zC;Tc@_a^ApLZ|#ApYj~9-(oi^lJ=z%t`C=16@8u1{2(LV3hbdx?dTykT%^W}Wa;1~u5%ccz6Zxl) z<2e9kgAY^H6XsEF+~x_n6#=8J{?=Y=GOq*VFC zvu~01H26l!R35wv-$9@q@4!`JL1lcy5Vx=mCxba+D>WGe+NXA0&j?-AFQk-oDI4|B z{B?=(u;t4jA1fTgO!1N!s&ziL)zp=8( zbYm@)b@i!jqD+-!?}NhMV0&ma?dZ>OU2t*i7VKxGF%CUU5UN{}<4p^uzD}HX-Nh!} zBa2h-(`m$B!Js(`fj|@?pU(BHH+K7Sz3X74eB#OdHWECLQ-t|I7E`!){bPmGcvko{ zJX)zH5q8`9FuQ{jr02#x(ZDH;G`OI%^d#M(T3);Q%IVbn3SvpG!Mn$9u{*w!A7*bA zW?_$D+`um#^qN%W;KMYirl&>2ry}s3lJE<4^^sc~FLOO(i2}#a-6>zXxBWRb{-yB- zaK_pZD}i!h7C2zLdrN8y{8E}`hoTIda-2=CHcej3Pf1yR;7vGo_17%h=Y^o})I>qA zc?o@TwrLvVfUXpf2-9rAU^B2IQp1FDU&j>(mDq5+UC(&tet)0rtyES}biM6P;T$hc zy-TsKxw$rSQfH}1vE|lIS@$!1ZS0r}yGt*Iq5uP(gm}U_1N2x5xT2qYJ*T?2nKP1C z8f+K=W@B}x$n9tU80ZrVf;2u(oc2unm7?%O#*!56j}+xs0$w9{s*Lv zVoV^XY-1b6xe|=KHE)pB1P^X@9)oDp<6iqVZCC2IfuX+gQ@EFYTPrUfxGZh$iS5i* z6c%80uw#U(Ko*(%jps|n-KI-GdGE0DkS9ZJb5PV}08e>1e_DRw8kN0l+&RG3D`hc4 zh>nZZ<;qd1gPT(S8+-2=)ztR(i`ozs5d{GSAzNu8Z7T$nmTjR62uLs43J4*Tjg(MA zvMqr0EeI$GQL0FZln|+rE+8Tuf)El!6bK{&VQI=f^MBs^;oJ{*j63do?zi)S0fR9J zSy^k&`8>bpSGsSFs9_bBTW}aki~kR9HPY46(P+kWDm6$=siMH#?D1f}BU)oaGpdZ> zQ$bczYHj%DFBrwM?C0>DC^n(DB5@7e_ESLoQ-7GMgE(e<#w6m_tq>Hy=FNwVQkQuv zxsPP2{E)E2xy0ZbYT72!1?TiNotd)WAiAEh;MWbypoYZb?}$laQP6qrDHoJmZZG20 zx1Gk{uhPGf%FCwmztEfdRQTNM&^?a4>ImkW5zGlTh4Y?;co>17 z7Vf3VQx*uYdL9*%tgbVB5Gw9y#(G_1dnoylbKO&1*+&W@^EBgirykhqE;BJBRX@nt zheP{21rxAclwkZJPke_>`(CUNKie0YSlEEhjn2;d&uh!fXZe*wS~n(I5#^A#y9`e{ z*Url##5)8;OBbU)&5;JR-Q&~l*QV;{7oXI)^Yf=eU2B%*`TU*e(@Mii8i7xamxI}W z*_)EpUI~J_(fl3Kk~R0EL4}2;d-=pb|5;B#`ga(}INc327~?FnwK%%i!@}Pu8)zqH z0_;_~(J78~tXUc=M~eESQP=jb{)kuab8>T->Ks*-->pa7r*#C<}}M7}g#``;>(#qYbMD5?>69qOCtPZU`zA zk1cB6JZf7gm_-0b8h^rG+ytzS#-wxgv9NqjCSjp3vDFIn_`OK6t<_aI1DDoMJX_uD zaPqI~zn*Y%#4zRa-_M`aSl7HIaamyvf8NoxLd7A|`jx6hn7dPEu;%c!QcV|fNaT@q zrtaNO`Cr1GNMvPnRcF=N4w0&=@iCSI{Uv{Tfy2iR>suam{X3`*-fX0A(Wn7kjI_kg zmf-|)UP5-@s8>Kt+{a4vt7)`@N7-Y1skLw5dcAu8ZxK<2@a)J_NgyG6{%mqrxBL+T zM}|FLz`YUWz`#9bb?(qhzgEBKGx&_o^ZZ!v{mN}+@Rdc$@GXt_s&p&cQ?Cc!-}rs^ zTiaxS&fsu<6{|M63i*(s_~3Q1Q);S&p!hi_nwsUs6}&Db$UugS7^nGOm9-< zfgoZq+~CoqY|xpdujvz<0gr~4#tM`8g*<$NR;Sh;z?$|911?=HoFSGVmuoeRwMpqY z7R8v?l7^N$on=gmh1y6QVs%uaZF`~}>qC84d3`@-&u6#Z%&ToB1!l)X9YgGF3MkRm z8nQdGU14pqjagOeKQosyNBXSG515``!VYJK2x$C&cb$uq@p!W6d*^4-`uH<$5x_wc zlW=mlK`?+opoXc$1>I7v77DpfhiU89K0?xPzdjkPGS)C_7q;A|n;jYve0Zb%1YPT^ zv|V!eW0f27opvZma4xr%9N7U z@~V4EtDN;6$HkRCX#Doi+G)xBLwF;>Z-V!DIf_4Q26(fsWq{Z&CwAZ3K|re}_q>NA zlALj8*uhDRIcM`vI#!Mor`sM5RY93lc7(N8Z|6IgEd70(ZVaLLLA@yg%hq-#1Z1eC zw_gxU<3$0KM00{RSyvbLlYQA{m!St$X)?WEL&VV*>hfpCXqKbDy-6597+6i5bO|g< z@N3CVLh&X;oQ_Uf22jm%9V;z0=7(l1E~!N3J<&K(6Ld-HpA?&Q<^VrG)M><^r(`Xw z)o)0}f75EPbuZZE0-=|SwlgWcG*SX!@$K7Ump{bG_)Dazm|79Vp?Oz8Qe$Y(T(k4A#|j?EZZ+*3n}gy_0->uSyQUx{L58FB^;6 zt>^5Nq6ToIdBZhFRL_*1+hyf~{I>bV^(p_koebwFmpS{e*dbb3zT(zML`&nweC-bH z-Nx*qJK$eS260sdBZxXmD&EI7P5#`c8lDyX0PixUG~k!>qcxqc@mhk8SKoHXZ>s0+ zHO1Fx1n;xo_m6&vXAj;)?S}{*5LobmA=Ez33?#%g#bWqROZlk+VL`u)-lJU8PrS=D z-(_fzcvp!~`uzNdkF;s&nE3dd+*>|byXufS^}}a1_Eg!m;o7G#3O5qr#G3hHe>;g8;!YpYZ`Ssvmu0EsiD3fHB3` z`m^>EhFS{;POFvqzlp1K7$*iVr%w)a_KNl|&(pkYCEHqKffV7a;^DlX`3vRDIkt8; zQi=y>g>j1csXSNiZ>xx-ShZ*d?Fd??th8)3Pw^9~NQF|{P8lop-x{(XJ(+pue&~|L ziGO+w3*8T`QWi=m^}v^xis3j{Hmw_>kp)|cC_jeq!&{Tj`^x77=8sLPlPb|_ z6_TDs_qVIsKa9qQXnmmFmzD%RU}?63LkO@Kq`;jp!wnr1^k{I8MTsz+J8i`wyxoLA z5#8)n{0bSBYu|7b#fhpEeCoC@3;a-3Yw#9CAS+u2(A}a zA9@DjopS60yv~m|SmOrPMP&wbGEJM?qRMUw)x7udEnv&;3#y8I9tLA=YE8@U&^P-2 z-IdfX$*;!lUZjZ#K1iiu7BM4mrtz2W1noZY>$p#~raRg%@9$N3ZK3yG77lUQ-W*4M z0|lLCun0_D$2ezUb*3*t6qH&oevgjrVJ~#EkoExo_)}B_DzgO=0 z&gLI$Q`S#^Gr5C6uTW|UJyGq3-;5Pm8C{+RU4=(l`2TE&JZ#RMYiI;1az{xM557-c z=sR8r+^Au*GOE{t_FaCyUx?MK0YCZL*2@AeP^^3lbc~kS24>-_CYN+`4R8I7R{dD+ z+5?}_OQg&(17c?pK3iTpDr1c_mIHBm4@gCULb;AmKBtNWyAP@%kBGX_h=MS;Hwp9Z zizlPE7z~EFPaBE)J#3%NS6#Dxbc>pDMk4RHJ4YB&l1?ZCb2cBI4ipixD2K3}Xh@vE zn%oK|=g1JL#yt_iZht9}nz)LBoBdCxP5UB+a56k!el~cEFTC_w6rO~^;vDfEESYc+ z8wT9xw5E)uqNX}vhg+={q}bM_Jzw0^Lj!a%#;0Y-2}GUz`jY-r@9QqVQg^s!en{ov zFTxt=()aa4m8@#x^WWMv`GwV6L?MX6aUerMC8(H>O{G!K=#w?u6zKrxJZ(>sap2Z1YygECW z<53&pS5S;*5(3G4mL1=c8fJqj1}~o&Xh!Z2ROP+n7YGguCXqtzzk`S!Vygp73RGM2 zr|S86Re)rDjV+l&F8L{tWD6yY1!D)FmbY0%h(x;`WNe0SkbZxnO7*_IxFrl+dY+Mx ze^bdxa8EN%AymrVHoO`n+U$nCJfTips6VfLK63^rhovkOL9JVV|MjlDnV9N?n=}>u z$ZV5{dbmZZVl+Zr8tQ|Lzb@MloD*aT z`Zs@+=30^R*vH-s?F;%%D4I9`lMoEu;Awze_%@D={kZ6MY1gj%|PVUn?X31Tepac8?rQ~7@}en=pO)&>x%}R=`EThcdDCZq zWu@Frp0Ru$LSg%z=v}q8=Mc@%rUz#lX?H}#R82mHL``Pe+S0E$#E^WYxo=lyTT3_= zu+bPCp#Rqf66(hUlPK;@=z!qE$mh7ntr2{(A3s@hAIw|HSU?1@k5aH@`<7K#T9u1j2H{*r#yt0^V04srToVNI93;egTLIz&E=FqvkS24kazP3*m?yFc^{`SsIdG4vY0E%eS@q zb-v?rg%wrJ^nA#qqw3Yztu#|+YhGO}VGP+znBC-@=07igLxMKBZdfD8BfgMp=oe@icp@j_b2Ir-(RmY|T@_Vh( zx@6*I;Np4f?GGI$ysP_%>HYBCr$x2a6RlD>Prek;nZDAb=Qk_+iepr4#P8Y+4d<+y zY2+NqbVLoH^Jm7;&0#LlMmiJcvOMZ0N4}CnzQu^mKHywz;=fekXMyZTo{$z39@qnW z=j~B1_$;oD@b_=;S&r_FAoJM|wVs|C%%*C4-2!)5=n8m#usw8IM&X$ee4toj;d$Gc zTpNNtyR_^vLsUODP5&$~w-A4D_f^%7Nzgy_ucOx==N#^&=(Haee58?xot{^?oIl6^ z6UEpr4w2ENyf8MVr&?tZpF|2$_Ba;(|&a zK{zV0M_OJnL~`1!0=4Onm=Q$Op+bK$w%qc2GaT~NUgTVOUAaCl8lwdiglEmxG{7zg z;zKbkJf{_kU~6|!_NteRw|ZCj=kc>Y%fO$Y6w@MN*w;HnJpXw=Xu!}tFzdp+$g_fm zxT1p3h{pd!pm2dti3_w34Btc2u_|13tTl9raRA>{@n#T*8RnFQlO3-pfu((L(8#vb~CPLFUNe`@H|&3YIwmkiJmfqxzf}E z_EI>smNybqGP$|0Ui$! z`gUKS2ki8HRznzS2$2;|^Al=Vi+R#=X=~NNn4t z7HUHA^4$vz16z>#kuV7q%crBNEfp?hok@zCYy+=1sihPR* z#dhcQzE*r-|2Ca5Fx7qZK=Fh|Pz3CpT2}3yC`RswDz;i?C?|#(4R8P0{IzbveddaV zZgIefjdcLKQwwETQocTOndrQxC5McuOZ(}L8?fmwm*k&x$I%eAO3Q0p4A0wo^rKRZnA zqch3xY){#SqDrEAwpUM=czL*84GRt3=ZwQ-Zgz+kbXgn?`o470SIC^$h1ielr?74W z8FUg3;Qeg@qUsDgfi+`EaMIsGFM*m?!g_(J#{kk}gkHR+&c4AGE?fbc_C&3VYr8IK{W!jx-d-`NU+XXV8c59cq=7fT~7&{#U@o7+hFp%Ca`DL1dN$BO#uhNY8lm zq0*Zeb(f>YRm~IjFLlh53a1@Ue!p5U@b3O@;!WZ`{4L}?F9alAgZun@R7>aYkTw|m zNE%;SE!1YEwKX&jAEw%r-YN}la;Yu}4W5!_d-f~deQoKZp+Nk2g1_xLZr7j$Bb~6E zecNPW(Zj5A4~@}q7zw0%k|Jr2#cYgj)^3DHg z(Em?^{{Mf2J}!l1Ic!XeQvAdcD5S(kc(m9aNO}R;lO#T7Ho09L+<8E8+^}AeihoKL zzqT`(@2Pn!>2vq{h3A^+G6>XDLel*E#s~Z8O#%(U3_{dcu47T-KOK7Re>(I%^V0z| zf3DY~Zj&GO)uYE~#p;#&z$aVj{PMVphc9i$WToYVK@gxA_7-b-jl|9}gOKbD z#uPJ`E$r6L0>~ZY(A&J8It#`ghL_6&vZ_<--Tf#3@fZ#XTy0>(mRs(kmanHp_-v&rrTT^r+b)m3_&J44{4+`E{Oq z6(o`twjbiDP2o=C#=S0qv!D1N=6d7ms{q`Ew~gd6aaScrvNO8q%mu^QYgU8Oy(4*` zJ3N8!xcZ+YKS?Nr#a+}(139&{1R1l=jZ@UmXk%%%5U1r&^^M#0IU-g`Ri&uNfyqs; zo_+0h8nTwh{#H!pHSt>vrMa=V&$z?qH)uJA5gLzlfGJ{4Vr>k? z*6zfefQH$TJIGOxO8I@VF~%>k)wK%!i5k#+jarrG;Z~RtJS_6=)c9`+ zKLryxS{o?T#2u@j3)~a;-r4w@M0D*qgPo*qxK>#{2O1 z{=tjn^HMIu13zBP))RHNhm30~HkVYu{-mSJ{I8hfW)&xJBiBH|BmYjU5f)~+CT^^TkK;Qdo zFNLG65G_0ODPzajiM}+onz1%PpJ_Fa?mM2vaUaF|j!y{Apq<%eSz+a$#g11oCGedV zq5Q%}r<*5iuGf03H`zb<;QiPOxBo}iXto*Z$4cYGl?Opv&e^Vgxsi0`>?h;;Va_MK z)f|c_n2$fbR&xvMEcrn)f1%nm;L*AZ%Rl=52BoGL3~R?PY7Jw}(ur%n6;XsvFJbls zt&3^ZM`2|}MNYVeKl659eYN#-!+TPNRF{d8e5^5(n)KTrI5tyo5iC@J99EA12DHPJ zX^Jh&w3-cLNcYA*%5opqsW>fVEP4Nz0eQ@4;^b^poMQvGry&8Z$(T1YxDkvU~^iRVF9*(-_0L+h~gjQ&Pl9;a(%f@{>@7d`d|q~lP|`D%W}SMI=y8F1V}f# z*_I9$bp$qtMrY(GG<3)pFXAC(U@w=^L6Ze0(Mbdm1Yl<*78->lQ5syKdSXD#@ZaUR zj-!9d+$k=1flqWree5+d?J^m2o4wvxTTdo?zqRd(W-6aRpx0RGm6kus%9ydV8P#uX z-%Kuj^!TWMS7)ac-edRBFZo9HTKW4Hck0D9rteH)1`3kvT)(T-)3$0b)!1f`f*oQZO1g$G)d7|Zqw;B zyRv_FwR}0ZJCeF<&}ZRx!`Y&R<8~&y{&#=jL5#;V9=$=ucOq(sL0<~oZRRhqIHijs zVu!5(RmDyB@t2e#Gy92+pe?5Hs7h0gh=1A;bNu`>zw9$fE-h~y;SAUzd@w?yzY6=; z2qF+MLw4mSGI9H(B8Pi+3eT~1E6Z2pkG71grw%H{a3BU37^N< z_XQrm^q9+Zu}5Bp{A_^=WJV{i@#$JrqRZqWDS=BLPV-3@HRzV{qu$cvpmjdveDZ30 z@wlq=?X5!|Rd=pbZfb4bV}wA;JIo}uAJ|`*2>S3$7bgmAnI{FqV_Ns%!-sgX?5T_` zegKEt?Iq1tl=)b!d7rG;t~}C;IQ%Q3&dtp%+F-`wZtc&;&{<`ha22IX6&iAxG2CX) ze(EspGmj>wj?<2INO`D1c|CPAbHWuB+jOy{Z5`$QM3B4&*kItalz+*>jdA|2=hq2j z23eLn4sS=i*|t1IT?K3X`0CcFyVR2(I;55~f|m086)&Y>gnmoiDU|2eQ+#Mr*t1gt zeF)5xSvV5NHYVq|;Yt}UaI45MW-v*yF7xBba$4P`;KlK(!hN?7r-U85f$bSy(CYx( zdLmPWfls9fwHrnOfJHB3(tVh^w+nZHok}}_wO*J*dNq%>8qtB=tK#)MN^oLqi+_Y_ z9ugsI;4ei*bNh?aRZD)fwrWiy(XYj3Z4Ywp1R>?5w-lCKBhazsh(eMC=$lC z@V9Fv7YbVkd|JKnh~YZtH8!;QFlp~WH=~wG#PhV)30ORV?6>C52L0U4F=H{o1VK!o zg!Cu7ImVCrQ<>RQxtN+hb!t$P>iPYQUl*19Pw#ur(Cg%t!-j1;!J|^nj048)ZnSs! zV<1i0mOb3{pTz1IFy^p^b$@~=9eMb_e?(mGe&jdZ-%4 zA&7Sw@QXwSnMO;2aml7{pM3aw$(Zw3*GJaF*Oi_fu4wkfxu19EdgfeFmn_xt&W}l3 zU!JtXEZL*0nZrT(Un%Oy=8cWJulj#9@%cbvo->Nu6}7>+#maqzh2g8d{kyAy0%{}r zo)p)HsBRgjm%4=5IR7cjX>ZBdY0~TCU#u1XOx1%Oq5=%lM<9fTiQyMs$X{X&--8p6 zdEz>7`nJ2QOHI^|4KspF8El=FtECO`H+nEt2e+UCfrFv=E_2B(81MMT0-{HZD$LP? z5?}E4jW9_YR0DeB%VLml8idBE-{{gy#wxv{uFCjau+VvP?Xkya*OX|hA@QGg8+|Ts ziVcV=2o7p<|9}h_z%X+Etoc}Y$WajA*q|Y0OAY=-XC%iC;X877aOxv9^oJf9Mm1RH z*T|E}ia_&g-*a;h`cTvBv*STt0a_;hot9dJhz|_99lV^ax-r5KPt!t)TvEch^K3Nc z4Pswg{rf8audghfNnbbLpC5-$Qpi-&I&&aJnYH*smh%cMtzf~Fyf&W*>{5pG zVTlxpc6C4vtH*}YDN-}GbS)`J`wv!jLric%$;?Hz2wFzDg0bp8ryTeDoU3uFa{7%C z#oO~&4W`<7m%)pL;YN(r!1qwx1g(A^xXESg1AdB2Tx%M%GJE3_N#nwllRT^mN(66O#-r8_xthNkC_NT;(WIuOXoJnO#_njPF*8vi zV^?dP(i5w^8n`T>rM)(}j;xkxKkML3W5U4a!DDw}gZH)-jI>2qz*YCZ zNQlC88?G+qu9L^CGa{Nf^@^3O z`O%faQIs09?(U|A`(hIRcmM6{w9HC+J4h^T}PZv~&WgyleQuB=Y2%vjeMYUU{mj zVS3v@#bw&ly{&tsayBSOd)aSLHo-lQrw#>im2sabwdD_5P3U6x32y*7tH)gn5UQ&X zN2hqqU+5J=d5Hy$~*p|X)ksil2ia!>)YvL?=e}4YB$CY5N?bqvwwA=2Pkb@Ix)U?< zSGR|2N~}3OqhH<2{pla{q*KysO#r44WsCLFQoIgA+H4{qeIeG07sb+y_h&H>UE@>4 zHMr7bxVvi*qcz>zTOrNBvCe(=Qn0!&?a}&fSJzAvWcS8Q2FI1H#T?^mV~zN^@F-2J zNCoc@SbMl{gd=O~+lo;++W1j9cXyK>}n|yXxhkXrFqEQ>gY3RWB{=Auya?D3ke`!Va+*;hAwyM@<2Z zJpPff8nGiqEGZ^_B8@^|9Q4z@@mV=Vv9dx=YSGRCB?|3p>h6TqKUUomqhjdgfbR}! z6yzJ>q^fo)f+$3OhmbZ@U8KnR+g7-K3^%Ys%hIt4%OBF!7X6GmyYm11>acl6vL7al zrus4I>@dNhTctc_{!7FHLH;5eIB9bYphqag{y#^tjtd=hu3i)3PAmIkJzDQk*3Fjj z_|{C#0{;_gkuxl2pz=e5QIjmdB&`DD#QI=;9dry57qj`D$}4Me1PWA@TX zy9iZOOeoq67r9b-vBmhGCpqcooyaEQgOBF^(Ab%*nxYg(v^8a?ihC z_UGYG_g_jch0NG4tQW9hf z87cD@xhyxII!Wae!&}a0UWzE$R4_}w;C(KvGO??-CR@qc#%9>`rS*V@e^$_Vwj*2O1U#>V9WyCcnOVF=lB!KfW<Tc~;5C*5lB&pR?cw?!~-lSS76>jstj}g_|aN0f9Ky_xxwfkwty}8 zlIsLf=sP?KE*;rpJL4cYW`LMp=HyRc?W*RG0l(yn%U8T)bm!GFyF41wWxwAM;iTF0 z;0ATh&zl&#T=BNm@c{+vIsR+xF}8#o1Zq{-3_~o%33(77Xym=AhdlY-ey?~f&HKb)=dD0U_1$#*lW^5K&v0pKV=*8G1Ts8u241aq`q;ThAj z3~6QyE2~!q!-TOCJDTBNOUs8AT2^BxCf0+Vzsr%zs(gx@GWt5}Xm+d7>CiN>-!>8d zrkr_|k#UNr0L?9i{H=>PqCCXYq`cM9v z_qbXP@5?p%=(zM_-YyIvSFLe$q`Vnd1*(7doj$x@K*cw9@hrvytI8XH?_Z#apqlZ? z>qIv-6s$je!)xUB{ixELk4u_0SGn)p1H!W{^n-rTvOHm0dK2T>b5EYj1>}{~ys|Qj zEY4B3d~)j|mnO+S!$8|W3ntOAELiub|q zg0wv|=$vx)1BO{ohQth{*HL(MWVw;lG!cRB&Wo!Ki&ocd%xP@QnUcm}E;LU689#e? z+5yzHj<{hOX^N@9y}>gYUq}2oXVwX8uB2W z8+vkLJzO?rNIw?2W;XPp(|p-HThSH_2JZhO_&}2Z5f@^Kv_m}4MdWlZ?35YE-j2xa z{tK(s2C}Jthmsb)1#0Qc+)2JYGUGNFedNVhS?;U%NoVg>*LW!YtnnYWFE?=;z3Ut^ zJ2Rgd6Q23CFym&{sO9sLIX4^0029mA981KaWwBfJDWrNtv#sre!n1s7Ac#Z}r3It& zC!q(?iyJeu-IV(B6r2o{V7;F)=8{9R8{Ky4KKj|@)wh_QETf$2vJY7gH;c})ZBMNAYI2fJ##3(re9@kf)NcW;*di;#|I0IrR*fFKsp&UQY zXTtbkR2SzTnAB-#{#S1F6;yH_$BW|@4TT1ML0D(kOTa;95% zUOSgP39LRo2@3G$yF2-j4ZeDD&KCl{cf7MJYf>6AatK~^?g!ylbv7O&$#s|sSNO#b zg6wZa$saD89KW&qzwspu!pCO7*oJxRZ#nI9cCM78XCLmVVh zGw1;4Bye^nNW`Q@N?mXo_pAxmhYj9JMQ^Oxx=I%MNWp{%XSm5s@Hq;UJF)9g}K;%|C;$pXZs9a{cvhoshXFC z9(7&K(XYt5Wd?ZXasQ*hVL4ME6^qtS@E6w^XX<#`EOzOy=ZCMp zLP~IA9ch@uUg5PRnZ4C#Hqup3>IK4A=Yg{3{v$yLO*Gv0LAw(4fP0Il$MQ?0_!~sD zvP?Lo3=!EV_~0F%%c0&1AEaXAMqg;3K7YSU<2`Yi2ndI3+Mswo%07}0z8z*yJu()- zUYi7*%M`-i*3x>+c+lopsn9 z^5dV4Um1##na4tsjzp1UFsB&gLxjxQ0esy0SO@@ zM3mqhi$p4H1a^ZY)um&e0ihlK#dalh%}d@8;(yV&4We zWr4z+lTM{96oJP|AdY*l>d-n6-;qhoZW!vA5(;kA(BJtZVDWum+_ojpW8DGAqE$ zEUQ>0l{5bZ+S z?=LUjm|;EEkgp`IlgrO%owq&?Wwo+x};mClDVygRlznI=Ehe`{#k<64h4J}Nb z9)^^kU8d(#hE90Fcf67&Y2EjRePJt2JqDZKB zPFm(aZdGGDi2LA1=UnX)(85*IQ+mg0h{cC0m%67ueZKN)Ca~M) zFB6>ND!z`wq?ARucOzKV!$$-|7Xk?TT5oa9#Zr#)LgKuw1Udik*Pc zC&E|daB4v|*2+s;v#uVPMLX`m=c z?1>Y0Wc%i3@En8a@6~n8d9zQedCxZBa+W3?v)(1B#$4&C%v1XklogXFNst~}9s#L3 zk+^?Q67TaAnjsi!0oo2}xSTVwI8_tkMq3m3QB7z4oHwsu(JpwI;v)u7+d$g18s{Ou z1t0i~2+v54Sq5D#9E3=CV(0`hKux72!h6}8FBikYf*9`64a_@zPFDUw;gwbfb_TOo z)%dR+6RsrKc|-+}e6OTFH{uTGty8a*n#j^_mY?YH&)<2H8)k71N39F~WU1RooPVe} zWpUTiK1erfDKyv!;YnbGbak`w4_kK$bVsN9R^nh=Ju1DA)}grgC}Ad{%sucs#|KYLRYG42IYBe zB0pr>wT&T4Hy#34K7lAUj3q<{DcCe|9)gXb7m}3*W}%eMsW(6a3|KPOg5~V?Nu6nUi6Psj z(xqQkTLL9a&&{r|yIy)Lzzxy=xsbBF&NA53V#cl?g{# zR%1y<`0auHK8Js|)m6r(>7xdY@Q5hOb3>MPlqOYU{~zt{O0npZnvd*k{wn{_MCie+ z_u4pky!O0ZJgDgxNpn{wl;V15jkF|i9>e4WV?cQVs!^R)%C3mv84xalg;!Zu1W=u7 zQ4z0cE@dy_rER_05n(3@wvC13b85$G%Wv+hdn+&f-#Rr`k<-qcTR@6n!hdEA!@?#a zHlXVQ1wAVE>SD&Ry6h}>wiC%P_t#|6LcJu{>7`+{jL##+j12FU5bgr{zkOsB*LEV@ z;+5`XCP$8HR5&=~YU5VWg?84F0x#FwFAq9A8PD?=+WLB9Dc@_u)$^*={6@AHZ*ar* z1la3M1qM7VXo9sD%#Xo+Q^`naJqcqfvBd;OVWKOKq4F;2ua0S&H8t?7ey9qoNOg_y z*-Ihi%I6hBnWlDX`-s=T@(GI4;^5yxawUR~|L)qqhDT{!<>a+bZBz?Cgb#^X)TtF+ z$y|}%q|rQPT?Y20{$mi`dsX|~KhfuCiGT1WS+|7p6fHFJ+=$6Dq{vu#rV zO#8_ADAdIoUg&50)(}}&ttWI0`gtn4mFl-Y?pW70jq6Z^13u0D6jPcm)N5+guKeR_ zT(P0!7kawsjxWdLtenlZ3Rc_FTAScHqY!fKC$UCA_$!b@G}M4+Y2dxl8N2SfWfb!HbDr%nhW)WP*;{aH zP%5cn{Pdjl>REt6=cH;YfM5?sCjdTm?M6Dn4-rox$xXKTu{MBHQr@Ted8-#xj}>*e z(Gg|A_=sS(Y{tWL_MpA#p?fMv->)RjP;O_;_H|JjrydCmT>SBSTW_)MirvTL-E_Je zH9u(}!gRKs(lDO$i<3*=X|7H9`dKkal+hDf`(*HV&=XiL;Rt}zKwUS?AT#;EkYr?D zgMWV;z?G~PuCU}Ja^Np^%^5q;!YWSOU)El~B% z`R2x6=>J9H8e?Ulj0OCxtl-dYwtJ7fyo}%9?RrQ^KjbhnuIQjB1Eqh4>Pa|vN!}k@ zd9PmLjCAkPVG7(TKe?njwk)3(6x|$B*PK4OV=RSo)2+Bz-4MsE@tRyMF5pj=S0;VM zOHmeHAddzbBd|{V0*Y_Q^kNT;G?jw)8&V#&XRjBR2T;8plzu0>r6N)FqZ;cGs-KYH zI1Fmi9jG{LXK^ei{KDCqIPmOLfqh=%KeLVJZ&pDZ8_!|EKnps%UReRfUPFq{mOYCF z@}NMWp@d45TCT-Xi-S+Qm3diEsa0>s>JlOyEC5OX@^6|n0F;KLI}mjn-3Tgy6UA?f zU@G+G$dq@`WM&|h9}ot9UqnBjOS1HKoX zWPQV5FY=!bp^vg^J|a(!v=o&(+dK?V`mnKof;7|o*dh|QrsNlEYHQ)TrEgc^ctzt1 zuU~gVa-FIjhiNE<=^ZbqpCnqWRxT# zcL_Iy3X^M)(g=NJE!5zQX(?b_7h7^g_$&P46C=ifJUW_fqP zgt3nJ1&S1u>&#Y3)C$_-yY+q)8CD9af{Ub7J^`3{XaC{Azxy|jk={XB95sFtBG6il z(+-a0pbcs)y@v1-8v@mqVl5{H=Pk-sBFT}zuEm45^)=OpCaBa26b^ZCUB>rD1Tj;O#8)^_6w}Blt(IDug75}-x_N@jvdZC zVP)%g;pg48e}ejN9Lkz_a3AA{xw)jp#Zx=a>CI5Zb7<0M@+{`h|iy8nOkdl(3!2Y6J*f8kNa{}&!*v)Z}yUwG8u z{|%3d{VzPK%+2rGUW2Dc&`W^dMgVrcB5phr;Okq=>4L6S;5vZN#5!IUg5GNp*bWDhYF z(wLY}*#={#XcH1*iZm6HWeCHRZL$}Y-DIpYwk*R88Fyy+{;tpS{XPFZ$MO7r&-2F| z4#&Y9_ieck7IzTfBTyz+6$*Db~HP3*3dZsM&lfIgJuY!PTe%PjQ<0jzLT37758 zZF-ui@?Nj_bxb>|$6%rO%B#tg-P6>H7$Zd99Ok@9uKF@Jy42=acILReL0Vy?_~)Qp?Q zM2dEu(*b__V42%m)T|&eZ^bj9#gb`x@Lovw1UpF7`A;FCo^$ z%@rV5X~$)!-fEbmfDK-np1e{Ye_^v(e(m5$d! zN5DOx#{iqX`Chy?ltekl>UaoprQev29hmT?rAofZj+*5!rzYkfn;hNqDmFtLZhi=_ zO@%;-RN{FT-JkxDu&-++DV8XE?jFG$AI;4w;$9y4WxgK@NmPpqEYQkH$*mdL`)ctt z`DWP$qvJ{kv>sR)10(r)aHC9CN|MyKfQXm4jyx#EPA@>9v74aG#;^>j3ka$rKY*DoEX$~fQ!{&Dtc&Jn7i33F|fMdW-JC<$*uz@Zw0 z*o+7uuESq|=-lBTmCM}zxMFwj027SAPTK8m&*mf63QR|htULR4_FXRA0b_OCN2w6U z^2l<5b+Uwx5d%@Ik0{Hp`{qP<-2w1uNj@^7oNHY2^c=7YLN)ZS*}r4fes6gbwl95n zk)!X}FU|HfoIk@$nkNf@>eLCiRENwr;{<#gs)!r{?6R9k1)jKA_VGCnyGe3_GFAq4 zIr#~++Ndqgk@fE#`gHAC$Z7Eugsb9jM6x97#aW+Z(1Fz}J`IC9RJuN4L>Sgcy zMjq4tI5RWBU&|lneRBW5)};lE)GlgCc{`$W=zauA@v!;_QcH(23cpYEiWmR~g_d1r zp}A-xH@O2;n4~Z!rf#}dk3I>jUecfRnM}T=@@4FAUkR=U6`0XS=%wd4vb?Q}EYr_K zW;OWx7WKIF?4@zicHkKCxS@ls%54~9LkTE*8$tg%rpW+Z4dn0V@0Qr zIWVb^bO^oEFL-Hjckg)v8%(>pJvxeC-rsILQ!JPd?Y*aHQfI5$}jl$B* z+`fxJDLkeXJQ7Cue@<^iYqTQ26^iDas8`>*c=PwL7yd9?)nJKbP$B7C8RxRN-=gQy zJ6zmTy|`_;Qa7)nIuH#dkOqU(JT7ymDtx}4r&5pFi9a@uy-VDL_XGXb;v>9=3HUuD zTOO}^bnA4#Mam4|f-O+8!!wc9WtT4(e+u51e%E2hYY685yMwI=u6__4h>`*;<_wu@ zt1ct{4m>YZnb@`P7@_H{0*qKa8ryXI3F*D(s6nD%@H@McS+s|W^Am%}=#%Ater&0X zJ7XL$W7Y5ERo#iF>Rr*cE+6eaJX`)Cu!#0 zT|ubuK))G4dRqZj)OHF;@#&j|hr0>dp2BlUEeeKP^o&(9AvHF;H6Y$gQ|r|)W0Oxe z{E3E#)?aurCMu?nWzngq&%!FsOz&dXnq z)F?0<+kTo=HZfs;LX9FLLk}jrk=fyyWaOBo zJ(=?d|dag^SY>aAa2i5FHdTsGlTg~eKqo2*Xau%pq??r zE}Rd52?ef@4$m+=X!kCUMz*G|e-wuxjTO_$GoGY7_-n0%o-q(4S~oYBG20(pa&NBRsZ zW&SM0qdFMQmAQPWXT?s(9VzV*UAAmh-DgxEmSJ9;TqY~mX)C)g`Av$Ctgb*oAl@n? zP{G+Yk8UQ&30*|FH4%|FKLgPOJH$+W-8vB+;ioQ5_|i|5y9U1Q@mJOy zMT1?9$vCS_jQw8n;J4kbTScdD9qs$ZRBd@-Je4JSA;G+`bikE%;#J`mwA;&rFv|nC z=Xj>a@-_QdB7*cuLU`{&Vh0ptBs#ff;w)-QORm;+c?Rdql%M4$nH+(BPUB#8Vov8o}_8 z<8DCfLwOR0NKD3O?7*T|1()0;6X(5|hkaaghJsPs#gl1N-|2DVZ?x-bWL@FgIRzlL zr~Mky{ZQE>tUX3W86v2bfq(leJwL}LZ=jE55-VC`=>gxR>dCTo>Ov&1q+xU;p(|q) ztYq^csJcGgd_AqA*Es6C$B1~O&&Zan+D_SwMU*d4|19hpxjOZ4FL}&|(u83H|C~%@ zZpz4B7<$QIbJ{m_iyM;B?u-%`%i@_(Cm z{8zb&I+(R9KJfPOx_>@j%DCd#ce5UK<#{*K)0JE5d@zEK`_5)eG*$Z?u`W=yh+isZ z|9y0iYwiC|kpG{G`I2Dz(>MZ+Ok9Hl(Zo!6OZ3*vGW#+Ve;QI@opfs^MMh`tM+PgT zVf1^>$XdG^r#L7y>z&%TjFLdBbDg|`Pk{rkaZIrlJ}^m86W-+2k;gJc_U#tDZ5|_} zyrey|%(@qTd&0Ya&Xf(;R?S#NCH=O3;J35Icd)jq1JWbcgLg)fP*#QijeGrm5ia%u z!)S41fZE=28h88{*^z9SFxvXAV_N_x!smU($I2S&2GRa4Y6x~ovG||xPgigbuMS~( zG?q+UjWN&d^DF(HF_InQw`9yxn^jrFe!mAdR@Vg@xZZf|A7Uu!xM7og?m;*2?p24E z$4IOC`tYvr?}`_L>=cS>oCB>69_-F|pAF+^&2w;rE^>pz!NC~r%V`jo2ciaKno+7X zNWmc-hW~$-EO{)8awoZ*rX<-Wqw?A)Vyu}5Nx7HIC z2k`rQfr5DM3{NeS$N#zl-#Fy%ZE55&Sro?ROnnHsMmtQ&msLneo)iwlxX7{T`TGKI z$dtd(=~+)Me^tRPqiEhGdRn+9xAk!7lWt@Ri&v?*&-b9Ud^~p$%qkab5?V{%F*E z=db4`)dRYMP!dvNlOMPe_f%wIZL2D)dRJ66XF5AreLWYLRi{hNzxDE3k%}^j+Z{tP z44t8)M+buWEuzZ0^*B%$0rs3V)OhCN=60ZW(p0W+GUh2cC9TBTVh7`b%~ z7oxK-rzb83M&vFErARcRY=KiC$n8K^_@enn6zGSdSE@IHgxsG3cT_g+NKo`HJATDG!o`knkz%1_QFHwc zXyzy8CDD^$Q_`&u8>V)DU0O4jCL+SPc9AfZd{kFP&A3qS& z|9G*UIKPkI0E$hS0ZO#xYU5-61e6mZwIJy2%XbA2I8#ushyned&l&ifnGpx3*Ks znn*pG@hYfg`>6B0t6++yF!(>=qeE@HU!f9ZF|z9VZh~0&y~jq*feV8 zvyZeVa@OM;{{$2^UlwK5oG^WO4PvJd+9Q$kRT=aqy8y9KcTkAnE;}vjEf%sdK$LfF zH1){;nhwii9K0Bja2saR<2>v9dh%OOreo!kgBKkuk6 zXs<@5*x#<3<>)I>1qW=PUds{9f;p;u2@Y}-_z>F2duogepjJQ+k!GpK)hTrT zR)@~R;Z!?&sgGB?&3lM9;91#Wg8@O*!e3|eCgFhxV?k3I0)JbSPQ)M|t8YVK5d$^i z_CO-5vRb{|Ij$3QQ5B0m&zeXzk&FxAF#O7}s$@oGzVHZ#TUd z^^2r)?t>1a7Oo5j2H<i+AHj^AiA+k(&4?7VlX@N~Y5+dx|jj?hg1%Lj5WZAoJ0eMW433(a8Vr(=K6DFZ)2 z-DL2>e%+IKSqCFN>@-8ws_z~7bTt{}+Gymq)6#5LQK$WzuFjaE9N!)1HtzglEX*eK z(7E=bgXRcuA9<(;fU;-{k;mYVfw|m278uuJ8_Ci&He=CRdwWCV>7L&aO-S~zsq2rp zNaLNEwcEbxUDvfeeIz>T;eyP(G4ER23SqzaOBE`1F$7%9l`MF@cwO&GA?0E~ende# zO91=BaSRT`=q+Bw-U`tClyX40eBkOH{QH%IPBEcAiUfC~ijjkkiJV`r7pQoCm*(`O zm5aX3SGr?;q zr@Mg({UeHHJlhMUF}_Q6-y=WVKC?xxROL{>w^6POu#0OuD`Ow8L=dq__W*KPt-od2 z^%*YPem_&y)_*|t4q4M~O1tyZu`AoRl+ad^WY9DdW^P|)V!&Eolhf|0rA}+V6m^*S zbwj162NgPo*QrSWsD3jLnUWIKr9u&qMpj4D;@u4IG{yNm?vWO;9BD9sarc<({ z=6Ny)BkX#H9${BIFjFn2Hd9BJ2}E7z_qlB==V9*_)9q?Gg^yingI-0Vqpb0x=L2j` z{K}lk`(D?vjBqwXgR@vtbiZy7y`IV&l_E zoVj+(XcF_;*Ei5&qqoKbTwZ6O#PS8&@ogm2Bj@x=B@)NuiI2eJe*qt!FO=ct2^9wU zesnIfVKm_t@Q%Ojtg%(dg##M!3%r!$~I5MF?A09`ttYCp}k|V z&NVPr?eUIAZhmF3etEZekmuQQU%}$!$r~8*Y z6svy}JvSo%?E5`ggM?hyw7%aTPJb#rG{4aa9j6)}|Jt%A;{2iRH0;a6MYoKGyv{qM z=ewUkM>|DV1n|8C9ztB?P@BAFoqphe2cQ0jY)JSDV&KJ?(F*lD@1WFznenlIrn#32 zrSne*v+XSU9BqWrRVNMFZ+~zI)ncUGj8C0vKd9#yS5k)e7o5dQLcw`Lx4yc)_$%D$ zn2?gYuCC0agyISe6nM3ZQsR(PnZ7a^;u-Sstk2HdrsGnnC^%SJOYs)GB@{2v#Z;Nj z&vDs}Ekrppbv9iB*V5RSZ(Ntbq!MI!+et+g_uOQSOuCT96$wuVl_sCahh1EsXQL)UCHt zE!Au4noe_yeiEH*sox;^tW|!Nx$Bv0O@BpQyF>Y?Te#NXupKpvaTc93Gvek*Z(~IL znW0oO7tgH34D$bo;@}AXK{~T)kpBT9n$9!whNEMMdkp?By=$MsV(8Q#+rRz%PH!>4 zs&}a7aSP8>C>P9^;QBWv{e?&IP@GgQ12la}i4VAU5Lw^D=9(0A{qyTF#>BT9ChUtaVHW==fLRAV^3um&D1iy(uhprJv9u3(na+x@+D@WIoN}r}Dzx zxW8X1r&lUdI-;lxIm<`*h$7PI1uH z2O$?~ZXb2q8ti=<9DVzNcA5#$aPwZg)-guIQTmPe4q&s6Gd;|;eAk}_%eze!wPE&=n#3B$vk~w_T?A7q4ug4y(~qbqz!sfO zaN_&*2pV7<4P6e&?%3omJW5ke&x$G_K88N!gUxB8w+7P$yVZhf?;P!$wT@&!d{_q?<7wxo4dwnRPYdgQh55o z6LPcW-p@+wBRwphs!SUb+(_%X_Lnu~XAt>G(X5$Xfu#q&xIH>LGlhe(xqY;vu-wjM ztkQX(@=yx7Dpww8e9Ir(otgc*HYffA@pv9poBltCSrtG7V83h<#S_-uP}|U7r%mW3 z%iy)(EvklgPW3xx3HwT^s2vtNJxo6&PY#$FQj_0)%X~_@xJ7fT@G#UafPob_RTIhr zI!btJHR=vR-h3A{4f^v?#dtklNsPG?j`UfdooO5Yg?fqIdvgJAVT_gbMw|NEq#R53 zjlFp3-J_>+$Sv)Vtw2iLKU!MjA)?2CCAAfthr;=fAxS@O_sPFjTOuV`T<^lbLR{*_ zly+BoU1is`k6l4M-f_7ktDcD+nND(HDfjP=(huk6hFaJdO=MFR{mi}7S3g}_7|#Bd z4Qpded~Rl@mb^>8?GZF7ukR! z%@5;bF7Wy|iJYa5P013Xm*FXzv=aFOv#>7{MjW|Q1?dN4?D4Cne zF9uWBvFo8Iub!GC#KIeE!&!Y5b%ljPXV7^AZM-%MWOV2)Ig>9xkDMrY$Hq*;V~J(7 zCNH>nb0P2^7(o=ScMp(5!E`;>=-*gqjP=R-~bf@{C*NxOb@n>pCjtSM!gnFX8KDF^jxAVMi_I#e`uaFrr`E&JOD zCl@mwn=ow=pGuv~rl0c*G^(VG@R@CEAOE$^+FS=(V|(^ird-B$Ct9h&po|kks^%?N&?$XK@?MSnhS3tQ~Tou*Mz3M zAkA6aG+LZi2-T_YxKv5AMNMk)-VTMbYl+sM$W0#pje^0zGZ%Y8RT7WSUF<0;#%3tJIh!^~ zTmNQ6*go08vmI;YT;)AvOE$s>J%Noj<_cPUn{h`Bako*4a)A2wy#C+o#}O*%eu8qp zk#=qEtC1LlU|Y~Zcwn;EzZj$5u-cpE0{G3Wtq1JHngP5PO{GI+x!*+MTscypX< zyxN+H3li?mAV1Q(B4$>8^tJXcyZ%y-zf|+M^xM6braQYcggc=>f<0oU*a*ZCQot(_ zD%cB=`Bzd}=Ga=5bnfz)H+>9;_uIh>VH)w7KeFL*6u22IIErBOX@CFz@)vnXLk-oY zUpiJx^gYCS6XH!7ATJWs!8!zqo(er*<1g^s8rq4Qhld)+krDtuDXY%O@^M6MhXmF< z?ZkKWQEx7(x=eT`ZD$%$_M{9pD@PnMD%L$_G5htac5$XnerOod_Yc+PgmK3Wnyc=y z18sc!AA?LAufj?yy}HC?_PiQT1jl9ayMVD@i8tLyE}M=gqOlu54Jm;aX{r5UEOYpJ zx9ar?`=ZW!)QGdqQ_lLk0*Ci1UBs0n(XS^#F#q{Qfi2z#fP~e7l&S>DT0xeAW4I)1 zZZs{P$TI9HF<41Nxqf=kwyw%~tH+O)De)9cMe2pYi$#NbrFw^-Iq5X0E75&XgL?== zir)JnErIE*ht9(~4Pd@Kmtck}8lD}IWz@-GDw&$f&Wahi;)g}dB>7);8amz zF8B8GwZg;Dnz{|2yW2r`btyRdkMOt`Nm7d6?6y#~JkQrm6-v zW_*4Cd+^e8@UnMvE^?bxxZZkS*wpjR#q7=5-?#J=IJw{7jfmvGr7dujdeJA2-8KzW zc3jlXZ?qZ;jh0QcTI^vb$o~42UOYp_iu=ixv>5tzLDRrIoB4E)1ir%Rp>y(0bbnjY z=x)ozoV1L?JQBx`ivc`~3T`>8u5>KR-vaUhhst^=AC#3nxvPyxnaiW@L$i^K_rwMP zjQ-o+9`%8G%XXWdU_;0|hqOnBq~tx5w~r?wWDa)hnE5!GnQ_?D+pf#GJ5rPnd=|q~ zk+)}NTN0J7$UApuWKFCDIwb_O1-80Tef+WUXyP=I-0Ll4tmH;9B)StGN7kUSs`e zIrSABj4U8Ot|B&KmCNTkJpz<(D!;6rl$sNkcZJkMqF{M4~a9P4M%C5%!n?qZPnJ% zb10igT|x>Ku&xkBaUN(n1FH_a%jS}xc?ioUZikLEDr8*R;89Dj>;K*S@niR!L^XF~ z>Ek`oOF!NN6#m8q3oE<8$C>wL?c=9oSFdECKOL;B_7uz<&F!-}Z}3C^&)+He%C|@# zdxC;iFBfkaIN>>jebMd`GOm@AyvDJ-ibGkbDRLPP@kUh@{JJ{T(wHXHY^q9pJ9)ox z(vOeN@A$p4tIm0P$NlTcxv(nDlt7)=Z->(WXEuMggQeWM|K_tE%zG3cJfJ^T5qG8X zzCO#s`s{jV-wE!~iI*Aa|5kPWQ{DN`p9j<4W#*xmyxXr{m3~(Btc5-K#`26_j)cMF zsgX%%=~kU}+xx=){jae2zB(1*35L)v8)A5I72+wl_Qs#$j=qKYD;fMjv}S~q0mer=qMR1xv>&579B_&wDmSkG7 w>c3;UPDz~BqELH0hirpVhCFs%3%lqpwM{jzVB+svz0SY?zdA!D|26P`0P>JmGXMYp literal 0 HcmV?d00001 diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..7e240b39f --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,339 @@ +# QuickStart + +## Introduction to Sample Interface Service + +Along with this project, I devised a sample interface service, and you can use it to familiarize how to play with `ApiTestEngine`. + +This sample service mainly has two parts: + +- Authorization, each request of other APIs should sign with some header fields and get token first. +- RESTful APIs for user management, you can do CRUD manipulation on users. + +As you see, it is very similar to the mainstream production systems. Therefore once you are familiar with handling this demo service, you can master most test scenarios in your project. + +## Launch Sample Interface Service + +The demo service is a flask server, we can launch it in this way. + +```text +$ export FLASK_APP=tests/api_server.py +$ flask run + * Serving Flask app "tests.api_server" + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) +``` + +Now the sample interface service is running, and we can move on to the next step. + +## Capture HTTP request and response + +Before we write testcases, we should know the details of the API. It is a good choice to use a web debugging proxy tool like `Charles Proxy` to capture the HTTP traffic. + +For example, the image below illustrates getting token from the sample service first, and then creating one user successfully. + +![](ate-quickstart-http-1.jpg) + +![](ate-quickstart-http-2.jpg) + +After thorough understanding of the APIs, we can now begin to write testcases. + +## Write the first test case + +Open your favorite text editor and you can write test cases like this. + +```yaml +- test: + name: get token + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + user_agent: iOS/10.3 + device_sn: 9TN6O2Bn1vzfybF + os_platform: ios + app_version: 2.8.6 + json: + sign: 19067cf712265eb5426db8d3664026c1ccea02b9 + +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + device_sn: 9TN6O2Bn1vzfybF + token: F8prvGryC5beBr4g + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} +``` + +As you see, each API request is described in a `test` block. And in the `request` field, it describes the detail of HTTP request, includes url, method, headers and data, which are in line with the captured traffic. + +You may wonder why we use the `json` field other than `data`. That's because the post data is in `JSON` format, when we use `json` to indicate the post data, we do not have to specify `Content-Type` to be `application/json` in request headers or dump data before request. + +Have you recalled some familiar scenes? + +Yes! That's what we did in [`requests.request`](requests.request)! Since `ApiTestEngine` takes full reuse of [`Requests`][requests], it inherits all powerful features of [`Requests`][requests], and we can handle HTTP request as the way we do before. + +## Run test cases + +Suppose the test case file is named as [`quickstart-demo-rev-0.yml`][quickstart-demo-rev-0] and is located in `examples` folder, then we can run it in this way. + +```text +ate examples/demo-rev-0.yml +Running tests... +---------------------------------------------------------------------- + get token ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token +INFO:root: status_code: 200, response_time: 48 ms, response_length: 46 bytes +OK (0.049669)s + create user which does not exist ... INFO:root: Start to POST http://127.0.0.1:5000/api/users/1000 +ERROR:root: Failed to POST http://127.0.0.1:5000/api/users/1000! exception msg: 403 Client Error: FORBIDDEN for url: http://127.0.0.1:5000/api/users/1000 +ERROR (0.006471)s +---------------------------------------------------------------------- +Ran 2 tests in 0.056s + +FAILED + (Errors=1) +``` + +Oops! The second test case failed with 403 status code. + +That is because we request with the same data as we captured in `Charles Proxy`, while the `token` is generated dynamically, thus the recorded data can not be be used twice directly. + +## Optimize test case: correlation + +To fix this problem, we should correlate `token` field in the second API test case, which is also called `correlation`. + +```yaml +- test: + name: get token + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + user_agent: iOS/10.3 + device_sn: 9TN6O2Bn1vzfybF + os_platform: ios + app_version: 2.8.6 + json: + sign: 19067cf712265eb5426db8d3664026c1ccea02b9 + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + device_sn: 9TN6O2Bn1vzfybF + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} +``` + +As you see, the `token` field is no longer hardcoded, instead it is extracted from the first API request with `extract_binds` mechanism. In the meanwhile, it is assigned to `token` variable, which can be referenced by the subsequent API requests. + +Now we save the test cases to [`quickstart-demo-rev-1.yml`][quickstart-demo-rev-1] and rerun it, and we will find that both API requests to be successful. + +## Optimize test case: parameterization + +Let's look back to our test set `quickstart-demo-rev-1.yml`, and we can see the `device_sn` field is still hardcoded. This may be quite different from the actual scenarios. + +In actual scenarios, each user's `device_sn` is different, so we should parameterize the request parameters, which is also called `parameterization`. In the meanwhile, the `sign` field is calculated with other header fields, thus it may change significantly if any header field changes slightly. + +However, the test cases are only `YAML` documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine `Python` scripts with `YAML` test cases in `ApiTestEngine`. + +To achieve this goal, we can utilize `import_module_functions` and `variable_binds` mechanisms. + +To be specific, we can create a Python file (`examples/utils.py`) and implement the related algorithm in it. Since we want to import this file, so we should put a `__init__.py` in this folder to make it as a Python module. + +```python +import hashlib +import hmac +import random +import string + +SECRET_KEY = "DebugTalk" + +def get_sign(*args): + content = ''.join(args).encode('ascii') + sign_key = SECRET_KEY.encode('ascii') + sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() + return sign + +def gen_random_string(str_len): + random_char_list = [] + for _ in range(str_len): + random_char = random.choice(string.ascii_letters + string.digits) + random_char_list.append(random_char) + + random_string = ''.join(random_char_list) + return random_string +``` + +And then, we can revise our demo test case and reference the functions. Suppose the revised file named [`quickstart-demo-rev-2.yml`][quickstart-demo-rev-2]. + +```yaml +- test: + name: get token + import_module_functions: + - examples.utils + variable_binds: + - user_agent: 'iOS/10.3' + - device_sn: ${gen_random_string(15)} + - os_platform: 'ios' + - app_version: '2.8.6' + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + device_sn: $device_sn + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} +``` + +In this revised test case, we firstly import module functions in `import_module_functions` block by specifying the Python module path, which is relative to the current working directory. + +To make fields like `device_sn` can be used more than once, we also bind values to variables in `variable_binds` block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. + +When we want to reference a variable in the test case, we can do this with a escape character `$`. For example, `$user_agent` will not be taken as a normal string, and `ApiTestEngine` will consider it as a variable named `user_agent`, search and return its binding value. + +When we want to reference a function, we shall use another escape character `${}`. Any content in `${}` will be considered as function calling, so we should guarantee that we call functions in the right way. At the same time, variables can also be referenced as parameters of function. + +## Optimize test case: overall config block + +There is still one issue unsolved. + +The `device_sn` field is defined in the first API test case, thus it may be impossible to reference it in other test cases. Context separation is a well-designed mechanism, and we should obey this good practice. + +To handle this case, overall `config` block is supported in `ApiTestEngine`. If we define variables or import functions in `config` block, these variables and functions will become global and can be referenced in the whole test set. + +```yaml +# examples/quickstart-demo-rev-3.yml +- config: + name: "smoketest for CRUD users." + import_module_functions: + - examples.utils + variable_binds: + - device_sn: ${gen_random_string(15)} + request: + base_url: http://127.0.0.1:5000 + headers: + device_sn: $device_sn + +- test: + name: get token + variable_binds: + - user_agent: 'iOS/10.3' + - os_platform: 'ios' + - app_version: '2.8.6' + request: + url: /api/get-token + method: POST + headers: + user_agent: $user_agent + os_platform: $os_platform + app_version: $app_version + json: + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} +``` + +As you see, we import public `Python` modules and variables in `config` block. Also, we can set `base_url` in `config` block, thereby we can only specify relative path in each API request url. Besides, we can also set common fields in `config` `request`, such as `device_sn` in headers. + +Until now, the test cases are finished and each detail is handled properly. + +## Run test cases and generate report + +Finally, let's run test set [`quickstart-demo-rev-3.yml`][quickstart-demo-rev-3] once more. + +```text +$ ate examples/quickstart-demo-rev-4.yml +Running tests... +---------------------------------------------------------------------- + get token ... INFO:root: Start to POST http://127.0.0.1:5000/api/get-token +INFO:root: status_code: 200, response_time: 33 ms, response_length: 46 bytes +OK (0.037027)s + create user which does not exist ... INFO:root: Start to POST http://127.0.0.1:5000/api/users/1000 +INFO:root: status_code: 201, response_time: 15 ms, response_length: 54 bytes +OK (0.016414)s +---------------------------------------------------------------------- +Ran 2 tests in 0.054s +OK + +Generating HTML reports... +Template is not specified, load default template instead. +Reports generated: /Users/Leo/MyProjects/ApiTestEngine/reports/quickstart-demo-rev-0/2017-08-01-16-51-51.html +``` + +Great! The test case runs successfully and generates a `HTML` test report. + +![](ate-quickstart-demo-report.jpg) + +## Further more + +This is just a starting point, see the `advanced guide` for the advanced features. + +- templating +- data extraction and validation +- [`comparator`][comparator] + +[requests]: http://docs.python-requests.org/en/master/ +[requests.request]: http://docs.python-requests.org/en/master/api/#requests.request +[comparator]: docs/comparator.md +[quickstart-demo-rev-0]: examples/quickstart-demo-rev-0.yml +[quickstart-demo-rev-1]: examples/quickstart-demo-rev-1.yml +[quickstart-demo-rev-2]: examples/quickstart-demo-rev-2.yml +[quickstart-demo-rev-3]: examples/quickstart-demo-rev-3.yml diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/quickstart-demo-rev-0.yml b/examples/quickstart-demo-rev-0.yml new file mode 100644 index 000000000..1080d5ebf --- /dev/null +++ b/examples/quickstart-demo-rev-0.yml @@ -0,0 +1,30 @@ +- test: + name: get token + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + user_agent: iOS/10.3 + device_sn: 9TN6O2Bn1vzfybF + os_platform: ios + app_version: 2.8.6 + json: + sign: 19067cf712265eb5426db8d3664026c1ccea02b9 + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + device_sn: 9TN6O2Bn1vzfybF + token: F8prvGryC5beBr4g + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} diff --git a/examples/quickstart-demo-rev-1.yml b/examples/quickstart-demo-rev-1.yml new file mode 100644 index 000000000..017a20158 --- /dev/null +++ b/examples/quickstart-demo-rev-1.yml @@ -0,0 +1,32 @@ +- test: + name: get token + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + user_agent: iOS/10.3 + device_sn: 9TN6O2Bn1vzfybF + os_platform: ios + app_version: 2.8.6 + json: + sign: 19067cf712265eb5426db8d3664026c1ccea02b9 + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1001 + method: POST + headers: + device_sn: 9TN6O2Bn1vzfybF + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml new file mode 100644 index 000000000..44addc184 --- /dev/null +++ b/examples/quickstart-demo-rev-2.yml @@ -0,0 +1,39 @@ +- test: + name: get token + import_module_functions: + - examples.utils + variable_binds: + - user_agent: 'iOS/10.3' + - device_sn: ${gen_random_string(15)} + - os_platform: 'ios' + - app_version: '2.8.6' + request: + url: http://127.0.0.1:5000/api/get-token + method: POST + headers: + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: http://127.0.0.1:5000/api/users/1000 + method: POST + headers: + device_sn: $device_sn + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} \ No newline at end of file diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml new file mode 100644 index 000000000..5922606fc --- /dev/null +++ b/examples/quickstart-demo-rev-3.yml @@ -0,0 +1,45 @@ +- config: + name: "smoketest for CRUD users." + import_module_functions: + - examples.utils + variable_binds: + - device_sn: ${gen_random_string(15)} + request: + base_url: http://127.0.0.1:5000 + headers: + device_sn: $device_sn + +- test: + name: get token + variable_binds: + - user_agent: 'iOS/10.3' + - os_platform: 'ios' + - app_version: '2.8.6' + request: + url: /api/get-token + method: POST + headers: + user_agent: $user_agent + os_platform: $os_platform + app_version: $app_version + json: + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + extract_binds: + - token: content.token + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- test: + name: create user which does not exist + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: "user1" + password: "123456" + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} diff --git a/examples/utils.py b/examples/utils.py new file mode 100644 index 000000000..79dc5e605 --- /dev/null +++ b/examples/utils.py @@ -0,0 +1,22 @@ +import hashlib +import hmac +import random +import string + +SECRET_KEY = "DebugTalk" + +def gen_random_string(str_len): + random_char_list = [] + for _ in range(str_len): + random_char = random.choice(string.ascii_letters + string.digits) + random_char_list.append(random_char) + + random_string = ''.join(random_char_list) + return random_string + +def get_sign(*args): + content = ''.join(args).encode('ascii') + sign_key = SECRET_KEY.encode('ascii') + sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() + return sign + From 13fe8aa0348e5aa18d9a91278208d24b539eebeb Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 21 Aug 2017 10:41:27 +0800 Subject: [PATCH 189/354] remove Python 3.7-dev temporarily --- .travis.yml | 1 - README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3ab6479cd..b0ec3a619 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ python: - 3.4 - 3.5 - 3.6 - - 3.7-dev install: - pip install -r requirements_dev.txt script: diff --git a/README.md b/README.md index aa15f9b45..0e87c3c11 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ Enjoy! ## Supported Python Versions -Python `2.7`, `3.3`, `3.4`, `3.5`, `3.6` and `3.7-dev`. +Python `2.7`, `3.3`, `3.4`, `3.5` and `3.6`. `ApiTestEngine` has been tested on `macOS`, `Linux` and `Windows` platforms. From 81b3cc33a2de5f6d19f5eeadbfa95d80fa1eee77 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 22 Aug 2017 15:34:35 +0800 Subject: [PATCH 190/354] bugfix #28: pkg_resources.DistributionNotFound --- requirements_dev.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index d9c5fdd79..bc7d382c0 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ -requests +requests[security] flask PyYAML coveralls diff --git a/setup.py b/setup.py index c68fcc18c..b8b144c72 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ }, keywords='api test', install_requires=[ - "requests", + "requests[security]", "flask", "PyYAML", "coveralls", From 7b44fd39d5f639208b71ff6aa7377f3873d4ddfe Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 22 Aug 2017 16:02:57 +0800 Subject: [PATCH 191/354] bugfix: locustfile template can not be found --- ate/__init__.py | 2 +- ate/cli.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 222c11cfd..b703f5c96 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.4.0' \ No newline at end of file +__version__ = '0.4.1' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index 3453b26cc..4f30f7e65 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -137,7 +137,11 @@ def gen_locustfile(testcase_file_path): """ generate locustfile from template. """ locustfile_path = 'locustfile.py' - with codecs.open('ate/locustfile_template', encoding='utf-8') as template: + template_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'locustfile_template' + ) + with codecs.open(template_path, encoding='utf-8') as template: with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: template_content = template.read() template_content = template_content.replace("$HOST", "https://skypixel.com") From 2e9da796ef18865c239f6b2acfda4d4571b305f0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 22 Aug 2017 17:51:31 +0800 Subject: [PATCH 192/354] bugfix #31: comparator endswith --- ate/utils.py | 2 +- tests/test_utils.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index 093df3a8a..96d7dfbf5 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -204,7 +204,7 @@ def match_expected(value, expected, comparator="eq", check_item=""): elif comparator in ["startswith"]: assert str(value).startswith(str(expected)) elif comparator in ["endswith"]: - assert str(expected).startswith(str(value)) + assert str(value).endswith(str(expected)) else: raise exception.ParamsError("comparator not supported!") diff --git a/tests/test_utils.py b/tests/test_utils.py index 5d7f9b756..619bebf3b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -191,6 +191,9 @@ def test_match_expected(self): self.assertTrue(utils.match_expected("abc123", "ab", "startswith")) self.assertTrue(utils.match_expected("123abc", 12, "startswith")) self.assertTrue(utils.match_expected(12345, 123, "startswith")) + self.assertTrue(utils.match_expected("abc123", 23, "endswith")) + self.assertTrue(utils.match_expected("123abc", "abc", "endswith")) + self.assertTrue(utils.match_expected(12345, 45, "endswith")) self.assertTrue(utils.match_expected(None, None, "eq")) with self.assertRaises(exception.ValidationError): From 1c1451475505c4d04b76164daba628035caaa570 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 23 Aug 2017 12:23:40 +0800 Subject: [PATCH 193/354] add docs: extraction-and-validation --- docs/extraction-and-validation.md | 50 +++++++++++++++++++++++++++++++ docs/quickstart.md | 3 +- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 docs/extraction-and-validation.md diff --git a/docs/extraction-and-validation.md b/docs/extraction-and-validation.md new file mode 100644 index 000000000..d76b80a22 --- /dev/null +++ b/docs/extraction-and-validation.md @@ -0,0 +1,50 @@ +# Extraction and Validation + +Suppose we get the following HTTP response. + +```text +# 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"] + } +} +``` + +In `extract_binds` and `validators`, we can do chain operation to extract data field in HTTP response. + +For instance, if we want to get `Content-Type` in response headers, then we can specify `headers.content-type`; if we want to get `first_name` in response content, we can specify `content.person.name.first_name`. + +There might be slight difference on list, cos we can use index to locate list item. For example, `Guangzhou` in response content can be specified by `content.person.cities.0`. + +```text +{"resp_status_code": "status_code"}, +{"resp_headers_content_type": "headers.content-type"}, +{"resp_content_body_success": "body.success"}, +{"resp_content_content_success": "content.success"}, +{"resp_content_text_success": "text.success"}, +{"resp_content_person_first_name": "content.person.name.first_name"}, +{"resp_content_cities_1": "content.person.cities.1"} +``` + +```yaml +validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "headers.content-type", "expected": "application/json"} + - {"check": "headers.content-length", "comparator": "gt", "expected": 40} + - {"check": "content.success", "comparator": "eq", "expected": True} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} +``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 7e240b39f..9b626d999 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -327,12 +327,13 @@ Great! The test case runs successfully and generates a `HTML` test report. This is just a starting point, see the `advanced guide` for the advanced features. - templating -- data extraction and validation +- [`data extraction and validation`](extraction-and-validation) - [`comparator`][comparator] [requests]: http://docs.python-requests.org/en/master/ [requests.request]: http://docs.python-requests.org/en/master/api/#requests.request [comparator]: docs/comparator.md +[extraction-and-validation]: docs/extraction-and-validation.md [quickstart-demo-rev-0]: examples/quickstart-demo-rev-0.yml [quickstart-demo-rev-1]: examples/quickstart-demo-rev-1.yml [quickstart-demo-rev-2]: examples/quickstart-demo-rev-2.yml From f9a7b1d20e49c69f2d8ed272217c3ea777de12a1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 23 Aug 2017 12:31:48 +0800 Subject: [PATCH 194/354] fix docs links --- docs/extraction-and-validation.md | 22 +++++++++++++++------- docs/quickstart.md | 14 +++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/extraction-and-validation.md b/docs/extraction-and-validation.md index d76b80a22..798ed9c45 100644 --- a/docs/extraction-and-validation.md +++ b/docs/extraction-and-validation.md @@ -31,16 +31,24 @@ For instance, if we want to get `Content-Type` in response headers, then we can There might be slight difference on list, cos we can use index to locate list item. For example, `Guangzhou` in response content can be specified by `content.person.cities.0`. ```text -{"resp_status_code": "status_code"}, -{"resp_headers_content_type": "headers.content-type"}, -{"resp_content_body_success": "body.success"}, -{"resp_content_content_success": "content.success"}, -{"resp_content_text_success": "text.success"}, -{"resp_content_person_first_name": "content.person.name.first_name"}, -{"resp_content_cities_1": "content.person.cities.1"} +# get status code +status_code + +# get headers field +headers.content-type + +# get content field +body.success +content.success +text.success +content.person.name.first_name +content.person.cities.1 ``` ```yaml +extract_binds: + - content_type: headers.content-type + - first_name: content.person.name.first_name validators: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "headers.content-type", "expected": "application/json"} diff --git a/docs/quickstart.md b/docs/quickstart.md index 9b626d999..376e6cae9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -327,14 +327,14 @@ Great! The test case runs successfully and generates a `HTML` test report. This is just a starting point, see the `advanced guide` for the advanced features. - templating -- [`data extraction and validation`](extraction-and-validation) +- [`data extraction and validation`][extraction-and-validation] - [`comparator`][comparator] [requests]: http://docs.python-requests.org/en/master/ [requests.request]: http://docs.python-requests.org/en/master/api/#requests.request -[comparator]: docs/comparator.md -[extraction-and-validation]: docs/extraction-and-validation.md -[quickstart-demo-rev-0]: examples/quickstart-demo-rev-0.yml -[quickstart-demo-rev-1]: examples/quickstart-demo-rev-1.yml -[quickstart-demo-rev-2]: examples/quickstart-demo-rev-2.yml -[quickstart-demo-rev-3]: examples/quickstart-demo-rev-3.yml +[comparator]: comparator.md +[extraction-and-validation]: extraction-and-validation.md +[quickstart-demo-rev-0]: ../examples/quickstart-demo-rev-0.yml +[quickstart-demo-rev-1]: ../examples/quickstart-demo-rev-1.yml +[quickstart-demo-rev-2]: ../examples/quickstart-demo-rev-2.yml +[quickstart-demo-rev-3]: ../examples/quickstart-demo-rev-3.yml From accff05ce08f8bb98a0bf83c104dbe44809e7208 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 23 Aug 2017 15:42:47 +0800 Subject: [PATCH 195/354] fix pep8 --- ate/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 96d7dfbf5..f2ad95cd9 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -190,10 +190,10 @@ def match_expected(value, expected, comparator="eq", check_item=""): assert isinstance(expected, int) assert len(value) <= expected elif comparator in ["contains"]: - assert isinstance(value, (list,tuple,dict,string_type)) + assert isinstance(value, (list, tuple, dict, string_type)) assert expected in value elif comparator in ["contained_by"]: - assert isinstance(expected, (list,tuple,dict,string_type)) + assert isinstance(expected, (list, tuple, dict, string_type)) assert value in expected elif comparator in ["type"]: assert isinstance(value, expected) From bcbc4df24f757a8dda9c6d7c1fbb23f6e3404c02 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 25 Aug 2017 15:18:03 +0800 Subject: [PATCH 196/354] bugfix #32: fix docstring --- ate/testcase.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ate/testcase.py b/ate/testcase.py index dc586b118..b1e00cce0 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -137,7 +137,8 @@ def parse_content_with_bindings(content, variables_binds, functions_binds): { "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", "random": "A2dEx", - "data": {"name": "user", "password": "123456"} + "data": {"name": "user", "password": "123456"}, + "uuid": 1000 } @param (dict) functions_binds, functions binds mapping { From fdb08e978d61a3e0530760222938fba8625b0312 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 25 Aug 2017 20:08:58 +0800 Subject: [PATCH 197/354] fix #33: invoke functions in url --- ate/__init__.py | 2 +- ate/testcase.py | 105 +++++++++++++++++++++------------- tests/test_testcase.py | 125 ++++++++++++++++++++++++++--------------- 3 files changed, 145 insertions(+), 87 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index b703f5c96..9bdd4d277 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.4.1' \ No newline at end of file +__version__ = '0.5.0' \ No newline at end of file diff --git a/ate/testcase.py b/ate/testcase.py index b1e00cce0..428b489f9 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -5,10 +5,11 @@ from ate.exception import ParamsError variable_regexp = r"\$([\w_]+)" -function_regexp = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") +function_regexp = r"\$\{[\w_]+\([\$\w_ =,]*\)\}" +function_regexp_compile = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") -def get_contain_variables(content): +def extract_variables(content): """ extract all variable names from content, which is in format $variable @param (str) content @return (list) variable name list @@ -18,9 +19,12 @@ def get_contain_variables(content): /$var1/$var2 => ["var1", "var2"] abc => [] """ - return re.findall(variable_regexp, content) + try: + return re.findall(variable_regexp, content) + except TypeError: + return [] -def parse_variables(content, variable_mapping): +def eval_content_variables(content, variable_mapping): """ replace all variables of string content with mapping value. @param (str) content @return (str) parsed content @@ -35,8 +39,8 @@ def parse_variables(content, variable_mapping): /$var_1/$var_2/var3 => "/abc/def/var3" ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" """ - variable_name_list = get_contain_variables(content) - for variable_name in variable_name_list: + variables_list = extract_variables(content) + for variable_name in variables_list: if variable_name not in variable_mapping: raise ParamsError( "%s is not defined in bind variables!" % variable_name) @@ -54,20 +58,52 @@ def parse_variables(content, variable_mapping): return content -def is_functon(content): - """ check if content is a function, which is in format ${func()} +def extract_functions(content): + """ extract all functions from string content, which are in format ${fun()} + Notice: extract_functions should be called after eval_content_variables, thus + there will not be any variables in given content @param (str) content - @return (bool) True or False - - e.g. ${func()} => True - ${func(5)} => True - ${func(1, 2)} => True - ${func(a=1, b=2)} => True - $abc => False - abc => False + @return (list) functions list + + e.g. ${func(5)} => ["${func(5)}"] + ${func(a=1, b=2)} => ["${func(a=1, b=2)}"] + /api/1000?_t=${get_timestamp()} => ["get_timestamp()"] + /api/${add(1, 2)} => ["add(1, 2)"] + "/api/${add(1, 2)}?_t=${get_timestamp()}" => ["${add(1, 2)}", "${get_timestamp()}"] """ - matched = function_regexp.match(content) - return True if matched else False + try: + return re.findall(function_regexp, content) + except TypeError: + return [] + +def eval_content_functions(content, variables_binds, functions_binds): + functions_list = extract_functions(content) + for func_content in functions_list: + function_meta = parse_function(func_content) + func_name = function_meta['func_name'] + + func = functions_binds.get(func_name) + if func is None: + raise ParamsError( + "%s is not defined in bind functions!" % func_name) + + args = function_meta.get('args', []) + kwargs = function_meta.get('kwargs', {}) + args = parse_content_with_bindings(args, variables_binds, functions_binds) + kwargs = parse_content_with_bindings(kwargs, variables_binds, functions_binds) + eval_value = func(*args, **kwargs) + + if func_content == content: + # content is a variable + content = eval_value + else: + # content contains one or many variables + content = content.replace( + func_content, + str(eval_value), 1 + ) + + return content def parse_string_value(str_value): """ parse string to number if possible @@ -99,7 +135,7 @@ def parse_function(content): "args": [], "kwargs": {} } - matched = function_regexp.match(content) + matched = function_regexp_compile.match(content) function_meta["func_name"] = matched.group(1) args_str = matched.group(2).replace(" ", "") @@ -167,8 +203,11 @@ def parse_content_with_bindings(content, variables_binds, functions_binds): if isinstance(content, dict): evaluated_data = {} for key, value in content.items(): - evaluated_data[key] = parse_content_with_bindings( + eval_key = parse_content_with_bindings( + key, variables_binds, functions_binds) + eval_value = parse_content_with_bindings( value, variables_binds, functions_binds) + evaluated_data[eval_key] = eval_value return evaluated_data @@ -178,25 +217,11 @@ def parse_content_with_bindings(content, variables_binds, functions_binds): # content is in string format here content = "" if content is None else content.strip() - if is_functon(content): - # function marker: ${func(1, 2, a=3, b=4)} - fuction_meta = parse_function(content) - func_name = fuction_meta['func_name'] + # replace functions with evaluated value + # Notice: eval_content_functions must be called before eval_content_variables + content = eval_content_functions(content, variables_binds, functions_binds) - func = functions_binds.get(func_name) - if func is None: - raise ParamsError( - "%s is not defined in bind functions!" % func_name) - - args = fuction_meta.get('args', []) - kwargs = fuction_meta.get('kwargs', {}) - args = parse_content_with_bindings(args, variables_binds, functions_binds) - kwargs = parse_content_with_bindings(kwargs, variables_binds, functions_binds) - return func(*args, **kwargs) + # replace variables with binding value + content = eval_content_variables(content, variables_binds) - elif get_contain_variables(content): - parsed_data = parse_variables(content, variables_binds) - return parsed_data - - else: - return content + return content diff --git a/tests/test_testcase.py b/tests/test_testcase.py index faef86596..3fb07a9a8 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -1,58 +1,59 @@ +import time import unittest -from ate.exception import ParamsError from ate import testcase +from ate.exception import ParamsError class TestcaseParserUnittest(unittest.TestCase): - def test_get_contain_variables(self): + def test_extract_variables(self): self.assertEqual( - testcase.get_contain_variables("$var"), + testcase.extract_variables("$var"), ["var"] ) self.assertEqual( - testcase.get_contain_variables("$var123"), + testcase.extract_variables("$var123"), ["var123"] ) self.assertEqual( - testcase.get_contain_variables("$var_name"), + testcase.extract_variables("$var_name"), ["var_name"] ) self.assertEqual( - testcase.get_contain_variables("var"), + testcase.extract_variables("var"), [] ) self.assertEqual( - testcase.get_contain_variables("a$var"), + testcase.extract_variables("a$var"), ["var"] ) self.assertEqual( - testcase.get_contain_variables("$v ar"), + testcase.extract_variables("$v ar"), ["v"] ) self.assertEqual( - testcase.get_contain_variables(" "), + testcase.extract_variables(" "), [] ) self.assertEqual( - testcase.get_contain_variables("$abc*"), + testcase.extract_variables("$abc*"), ["abc"] ) self.assertEqual( - testcase.get_contain_variables("${func()}"), + testcase.extract_variables("${func()}"), [] ) self.assertEqual( - testcase.get_contain_variables("${func(1,2)}"), + testcase.extract_variables("${func(1,2)}"), [] ) self.assertEqual( - testcase.get_contain_variables("${gen_md5($TOKEN, $data, $random)}"), + testcase.extract_variables("${gen_md5($TOKEN, $data, $random)}"), ["TOKEN", "data", "random"] ) - def test_parse_variables(self): + def test_eval_content_variables(self): variable_mapping = { "var_1": "abc", "var_2": "def", @@ -62,67 +63,54 @@ def test_parse_variables(self): "var_6": None } self.assertEqual( - testcase.parse_variables("$var_1", variable_mapping), + testcase.eval_content_variables("$var_1", variable_mapping), "abc" ) self.assertEqual( - testcase.parse_variables("var_1", variable_mapping), + testcase.eval_content_variables("var_1", variable_mapping), "var_1" ) self.assertEqual( - testcase.parse_variables("$var_1#XYZ", variable_mapping), + testcase.eval_content_variables("$var_1#XYZ", variable_mapping), "abc#XYZ" ) self.assertEqual( - testcase.parse_variables("/$var_1/$var_2/var3", variable_mapping), + testcase.eval_content_variables("/$var_1/$var_2/var3", variable_mapping), "/abc/def/var3" ) self.assertEqual( - testcase.parse_variables("/$var_1/$var_2/$var_1", variable_mapping), + testcase.eval_content_variables("/$var_1/$var_2/$var_1", variable_mapping), "/abc/def/abc" ) self.assertEqual( - testcase.parse_variables("${func($var_1, $var_2, xyz)}", variable_mapping), + testcase.eval_content_variables("${func($var_1, $var_2, xyz)}", variable_mapping), "${func(abc, def, xyz)}" ) self.assertEqual( - testcase.parse_variables("$var_3", variable_mapping), + testcase.eval_content_variables("$var_3", variable_mapping), 123 ) self.assertEqual( - testcase.parse_variables("$var_4", variable_mapping), + testcase.eval_content_variables("$var_4", variable_mapping), {"a": 1} ) self.assertEqual( - testcase.parse_variables("$var_5", variable_mapping), + testcase.eval_content_variables("$var_5", variable_mapping), True ) self.assertEqual( - testcase.parse_variables("abc$var_5", variable_mapping), + testcase.eval_content_variables("abc$var_5", variable_mapping), "abcTrue" ) self.assertEqual( - testcase.parse_variables("abc$var_4", variable_mapping), + testcase.eval_content_variables("abc$var_4", variable_mapping), "abc{'a': 1}" ) self.assertEqual( - testcase.parse_variables("$var_6", variable_mapping), + testcase.eval_content_variables("$var_6", variable_mapping), None ) - def test_is_functon(self): - self.assertTrue(testcase.is_functon("${func()}")) - self.assertTrue(testcase.is_functon("${func(5)}")) - self.assertTrue(testcase.is_functon("${func(1, 2)}")) - self.assertTrue(testcase.is_functon("${func($a, $b)}")) - self.assertTrue(testcase.is_functon("${func(a=1, b=2)}")) - self.assertTrue(testcase.is_functon("${func(1, 2, a=3, b=4)}")) - self.assertTrue(testcase.is_functon("${func(1, $b, c=$x, d=4)}")) - self.assertFalse(testcase.is_functon("${func}")) - self.assertFalse(testcase.is_functon("$abc")) - self.assertFalse(testcase.is_functon("abc")) - self.assertFalse(testcase.is_functon("${}")) - def test_parse_string_value(self): self.assertEqual(testcase.parse_string_value("123"), 123) self.assertEqual(testcase.parse_string_value("12.3"), 12.3) @@ -205,7 +193,6 @@ def test_parse_variables_multiple_identical_variables(self): "/users/100/1000/1498?userId=1000&data=1498" ) - def test_parse_content_with_bindings_functions(self): import random, string functions_binds = { @@ -227,20 +214,66 @@ def test_parse_content_with_bindings_functions(self): 3 ) + def test_extract_functions(self): + self.assertEqual( + testcase.extract_functions("${func()}"), + ["${func()}"] + ) + self.assertEqual( + testcase.extract_functions("${func(5)}"), + ["${func(5)}"] + ) + self.assertEqual( + testcase.extract_functions("${func(a=1, b=2)}"), + ["${func(a=1, b=2)}"] + ) + self.assertEqual( + testcase.extract_functions("${func(1, $b, c=$x, d=4)}"), + ["${func(1, $b, c=$x, d=4)}"] + ) + self.assertEqual( + testcase.extract_functions("/api/1000?_t=${get_timestamp()}"), + ["${get_timestamp()}"] + ) + self.assertEqual( + testcase.extract_functions("/api/${add(1, 2)}"), + ["${add(1, 2)}"] + ) + self.assertEqual( + testcase.extract_functions("/api/${add(1, 2)}?_t=${get_timestamp()}"), + ["${add(1, 2)}", "${get_timestamp()}"] + ) + self.assertEqual( + testcase.extract_functions("abc${func(1, 2, a=3, b=4)}def"), + ["${func(1, 2, a=3, b=4)}"] + ) + + def test_eval_content_functions(self): + functions_binds = { + "add_two_nums": lambda a, b=1: a + b + } + self.assertEqual( + testcase.eval_content_functions("${add_two_nums(1, 2)}", {}, functions_binds), + 3 + ) + self.assertEqual( + testcase.eval_content_functions("/api/${add_two_nums(1, 2)}", {}, functions_binds), + "/api/3" + ) + def test_parse_content_with_bindings_testcase(self): variables_binds = { "uid": "1000", "random": "A2dEx", "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "data": {"name": "user", "password": "123456"}, - "expected_status": 201, - "expected_success": True + "data": {"name": "user", "password": "123456"} } functions_binds = { - "add_two_nums": lambda a, b=1: a + b + "add_two_nums": lambda a, b=1: a + b, + "get_timestamp": lambda: int(time.time() * 1000) } testcase_template = { - "url": "http://127.0.0.1:5000/api/users/$uid", + "url": "http://127.0.0.1:5000/api/users/$uid/${add_two_nums(1,2)}", "method": "POST", "headers": { "Content-Type": "application/json", @@ -255,7 +288,7 @@ def test_parse_content_with_bindings_testcase(self): self.assertEqual( parsed_testcase["url"], - "http://127.0.0.1:5000/api/users/%s" % variables_binds["uid"] + "http://127.0.0.1:5000/api/users/1000/3" ) self.assertEqual( parsed_testcase["headers"]["authorization"], From 20677b8731d660464c20c84612f83a6e1750a004 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 25 Aug 2017 21:09:57 +0800 Subject: [PATCH 198/354] refactor ate/testcase.py: organise code with TestcaseParser, reduce passing parameters --- README.md | 2 +- ate/context.py | 21 ++-- ate/testcase.py | 267 +++++++++++++++++++++-------------------- tests/test_testcase.py | 33 ++--- 4 files changed, 170 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 0e87c3c11..e29230b95 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.4.0 +ApiTestEngine version: 0.5.0 ``` Execute the command `ate -h` to view command help. diff --git a/ate/context.py b/ate/context.py index ebd212384..6d3967164 100644 --- a/ate/context.py +++ b/ate/context.py @@ -6,7 +6,8 @@ import types from collections import OrderedDict -from ate import testcase, utils +from ate.testcase import TestcaseParser +from ate import utils def is_function(tup): @@ -22,6 +23,7 @@ class Context(object): def __init__(self): self.testset_shared_variables_mapping = OrderedDict() self.testcase_variables_mapping = OrderedDict() + self.testcase_parser = TestcaseParser() self.init_context() def init_context(self, level='testset'): @@ -40,6 +42,9 @@ def init_context(self, level='testset'): self.testcase_request_config = {} self.testcase_variables_mapping = copy.deepcopy(self.testset_shared_variables_mapping) + self.testcase_parser.bind_functions(self.testcase_functions_config) + self.testcase_parser.bind_variables(self.testcase_variables_mapping) + def import_requires(self, modules): """ import required modules dynamicly """ @@ -88,16 +93,13 @@ def bind_variables(self, variable_binds, level="testcase"): """ for variable_bind in variable_binds: for variable_name, value in variable_bind.items(): - variable_evale_value = testcase.parse_content_with_bindings( - value, - self.testcase_variables_mapping, - self.testcase_functions_config - ) + variable_evale_value = self.testcase_parser.parse_content_with_bindings(value) if level == "testset": self.testset_shared_variables_mapping[variable_name] = variable_evale_value self.testcase_variables_mapping[variable_name] = variable_evale_value + self.testcase_parser.bind_variables(self.testcase_variables_mapping) def __update_context_functions_config(self, level, config_mapping): """ @@ -109,6 +111,7 @@ def __update_context_functions_config(self, level, config_mapping): self.testset_functions_config.update(config_mapping) self.testcase_functions_config.update(config_mapping) + self.testcase_parser.bind_functions(self.testcase_functions_config) def register_request(self, request_dict, level="testcase"): self.__update_context_request_config(level, request_dict) @@ -130,10 +133,8 @@ def __update_context_request_config(self, level, config_mapping): def get_parsed_request(self): """ get parsed request, with each variable replaced by bind value. """ - parsed_request = testcase.parse_content_with_bindings( - self.testcase_request_config, - self.testcase_variables_mapping, - self.testcase_functions_config + parsed_request = self.testcase_parser.parse_content_with_bindings( + self.testcase_request_config ) return parsed_request diff --git a/ate/testcase.py b/ate/testcase.py index 428b489f9..9b5f5146a 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -24,44 +24,8 @@ def extract_variables(content): except TypeError: return [] -def eval_content_variables(content, variable_mapping): - """ replace all variables of string content with mapping value. - @param (str) content - @return (str) parsed content - - e.g. - variable_mapping = { - "var_1": "abc", - "var_2": "def" - } - $var_1 => "abc" - $var_1#XYZ => "abc#XYZ" - /$var_1/$var_2/var3 => "/abc/def/var3" - ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" - """ - variables_list = extract_variables(content) - for variable_name in variables_list: - if variable_name not in variable_mapping: - raise ParamsError( - "%s is not defined in bind variables!" % variable_name) - - variable_value = variable_mapping.get(variable_name) - if "${}".format(variable_name) == content: - # content is a variable - content = variable_value - else: - # content contains one or many variables - content = content.replace( - "${}".format(variable_name), - str(variable_value), 1 - ) - - return content - def extract_functions(content): """ extract all functions from string content, which are in format ${fun()} - Notice: extract_functions should be called after eval_content_variables, thus - there will not be any variables in given content @param (str) content @return (list) functions list @@ -76,35 +40,6 @@ def extract_functions(content): except TypeError: return [] -def eval_content_functions(content, variables_binds, functions_binds): - functions_list = extract_functions(content) - for func_content in functions_list: - function_meta = parse_function(func_content) - func_name = function_meta['func_name'] - - func = functions_binds.get(func_name) - if func is None: - raise ParamsError( - "%s is not defined in bind functions!" % func_name) - - args = function_meta.get('args', []) - kwargs = function_meta.get('kwargs', {}) - args = parse_content_with_bindings(args, variables_binds, functions_binds) - kwargs = parse_content_with_bindings(kwargs, variables_binds, functions_binds) - eval_value = func(*args, **kwargs) - - if func_content == content: - # content is a variable - content = eval_value - else: - # content contains one or many variables - content = content.replace( - func_content, - str(eval_value), 1 - ) - - return content - def parse_string_value(str_value): """ parse string to number if possible e.g. "123" => 123 @@ -152,76 +87,152 @@ def parse_function(content): return function_meta -def parse_content_with_bindings(content, variables_binds, functions_binds): - """ evaluate content recursively, each variable in content will be - evaluated with bind variables and functions. - - variables marker: $variable. - @param (dict) content in any data structure - { - "url": "http://127.0.0.1:5000/api/users/$uid", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "authorization": "$authorization", - "random": "$random", - "sum": "${add_two_nums(1, 2)}" - }, - "body": "$data" - } - @param (dict) variables_binds, variables binds mapping - { - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx", - "data": {"name": "user", "password": "123456"}, - "uuid": 1000 - } - @param (dict) functions_binds, functions binds mapping - { - "add_two_nums": lambda a, b=1: a + b - } - @return (dict) parsed content with evaluated bind values - { - "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", - "random": "A2dEx", - "sum": 3 - }, - "body": {"name": "user", "password": "123456"} +def eval_content_variables(content, variable_mapping): + """ replace all variables of string content with mapping value. + @param (str) content + @return (str) parsed content + + e.g. + variable_mapping = { + "var_1": "abc", + "var_2": "def" } + $var_1 => "abc" + $var_1#XYZ => "abc#XYZ" + /$var_1/$var_2/var3 => "/abc/def/var3" + ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" """ + variables_list = extract_variables(content) + for variable_name in variables_list: + if variable_name not in variable_mapping: + raise ParamsError( + "%s is not defined in bind variables!" % variable_name) - if isinstance(content, (list, tuple)): - return [ - parse_content_with_bindings(item, variables_binds, functions_binds) - for item in content - ] + variable_value = variable_mapping.get(variable_name) + if "${}".format(variable_name) == content: + # content is a variable + content = variable_value + else: + # content contains one or many variables + content = content.replace( + "${}".format(variable_name), + str(variable_value), 1 + ) - if isinstance(content, dict): - evaluated_data = {} - for key, value in content.items(): - eval_key = parse_content_with_bindings( - key, variables_binds, functions_binds) - eval_value = parse_content_with_bindings( - value, variables_binds, functions_binds) - evaluated_data[eval_key] = eval_value + return content - return evaluated_data - if isinstance(content, (int, float)): - return content +class TestcaseParser(object): + + def __init__(self, variables_binds={}, functions_binds={}): + self.bind_variables(variables_binds) + self.bind_functions(functions_binds) - # content is in string format here - content = "" if content is None else content.strip() + def bind_variables(self, variables_binds): + """ bind variables to current testcase parser + @param (dict) variables_binds, variables binds mapping + { + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx", + "data": {"name": "user", "password": "123456"}, + "uuid": 1000 + } + """ + self.variables_binds = variables_binds + + def bind_functions(self, functions_binds): + """ bind functions to current testcase parser + @param (dict) functions_binds, functions binds mapping + { + "add_two_nums": lambda a, b=1: a + b + } + """ + self.functions_binds = functions_binds + + def eval_content_functions(self, content): + functions_list = extract_functions(content) + for func_content in functions_list: + function_meta = parse_function(func_content) + func_name = function_meta['func_name'] + + func = self.functions_binds.get(func_name) + if func is None: + raise ParamsError( + "%s is not defined in bind functions!" % func_name) + + args = function_meta.get('args', []) + kwargs = function_meta.get('kwargs', {}) + args = self.parse_content_with_bindings(args) + kwargs = self.parse_content_with_bindings(kwargs) + eval_value = func(*args, **kwargs) + + if func_content == content: + # content is a variable + content = eval_value + else: + # content contains one or many variables + content = content.replace( + func_content, + str(eval_value), 1 + ) - # replace functions with evaluated value - # Notice: eval_content_functions must be called before eval_content_variables - content = eval_content_functions(content, variables_binds, functions_binds) + return content - # replace variables with binding value - content = eval_content_variables(content, variables_binds) + def parse_content_with_bindings(self, content): + """ parse content recursively, each variable and function in content will be evaluated. + + @param (dict) content in any data structure + { + "url": "http://127.0.0.1:5000/api/users/$uid/${add_two_nums(1, 1)}", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "$authorization", + "random": "$random", + "sum": "${add_two_nums(1, 2)}" + }, + "body": "$data" + } + @return (dict) parsed content with evaluated bind values + { + "url": "http://127.0.0.1:5000/api/users/1000/2", + "method": "POST", + "headers": { + "Content-Type": "application/json", + "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", + "random": "A2dEx", + "sum": 3 + }, + "body": {"name": "user", "password": "123456"} + } + """ + + if isinstance(content, (list, tuple)): + return [ + self.parse_content_with_bindings(item) + for item in content + ] + + if isinstance(content, dict): + evaluated_data = {} + for key, value in content.items(): + eval_key = self.parse_content_with_bindings(key) + eval_value = self.parse_content_with_bindings(value) + evaluated_data[eval_key] = eval_value + + return evaluated_data + + if isinstance(content, (int, float)): + return content + + # content is in string format here + content = "" if content is None else content.strip() + + # replace functions with evaluated value + # Notice: eval_content_functions must be called before eval_content_variables + content = self.eval_content_functions(content) + + # replace variables with binding value + content = eval_content_variables(content, self.variables_binds) - return content + return content diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 3fb07a9a8..0ead19b3c 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -149,24 +149,25 @@ def test_parse_content_with_bindings_variables(self): "str_1": "str_value1", "str_2": "str_value2" } + testcase_parser = testcase.TestcaseParser(variables_binds=variables_binds) self.assertEqual( - testcase.parse_content_with_bindings("$str_1", variables_binds, {}), + testcase_parser.parse_content_with_bindings("$str_1"), "str_value1" ) self.assertEqual( - testcase.parse_content_with_bindings("123$str_1/456", variables_binds, {}), + testcase_parser.parse_content_with_bindings("123$str_1/456"), "123str_value1/456" ) with self.assertRaises(ParamsError): - testcase.parse_content_with_bindings("$str_3", variables_binds, {}) + testcase_parser.parse_content_with_bindings("$str_3") self.assertEqual( - testcase.parse_content_with_bindings(["$str_1", "str3"], variables_binds, {}), + testcase_parser.parse_content_with_bindings(["$str_1", "str3"]), ["str_value1", "str3"] ) self.assertEqual( - testcase.parse_content_with_bindings({"key": "$str_1"}, variables_binds, {}), + testcase_parser.parse_content_with_bindings({"key": "$str_1"}), {"key": "str_value1"} ) @@ -175,9 +176,10 @@ def test_parse_content_with_bindings_multiple_identical_variables(self): "userid": 100, "data": 1498 } + testcase_parser = testcase.TestcaseParser(variables_binds=variables_binds) content = "/users/$userid/training/$data?userId=$userid&data=$data" self.assertEqual( - testcase.parse_content_with_bindings(content, variables_binds, {}), + testcase_parser.parse_content_with_bindings(content), "/users/100/training/1498?userId=100&data=1498" ) @@ -187,9 +189,10 @@ def test_parse_variables_multiple_identical_variables(self): "userid": 1000, "data": 1498 } + testcase_parser = testcase.TestcaseParser(variables_binds=variables_binds) content = "/users/$user/$userid/$data?userId=$userid&data=$data" self.assertEqual( - testcase.parse_content_with_bindings(content, variables_binds, {}), + testcase_parser.parse_content_with_bindings(content), "/users/100/1000/1498?userId=1000&data=1498" ) @@ -199,18 +202,19 @@ def test_parse_content_with_bindings_functions(self): "gen_random_string": lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ for _ in range(str_len)) } + testcase_parser = testcase.TestcaseParser(functions_binds=functions_binds) - result = testcase.parse_content_with_bindings("${gen_random_string(5)}", {}, functions_binds) + result = testcase_parser.parse_content_with_bindings("${gen_random_string(5)}") self.assertEqual(len(result), 5) add_two_nums = lambda a, b=1: a + b functions_binds["add_two_nums"] = add_two_nums self.assertEqual( - testcase.parse_content_with_bindings("${add_two_nums(1)}", {}, functions_binds), + testcase_parser.parse_content_with_bindings("${add_two_nums(1)}"), 2 ) self.assertEqual( - testcase.parse_content_with_bindings("${add_two_nums(1, 2)}", {}, functions_binds), + testcase_parser.parse_content_with_bindings("${add_two_nums(1, 2)}"), 3 ) @@ -252,12 +256,13 @@ def test_eval_content_functions(self): functions_binds = { "add_two_nums": lambda a, b=1: a + b } + testcase_parser = testcase.TestcaseParser(functions_binds=functions_binds) self.assertEqual( - testcase.eval_content_functions("${add_two_nums(1, 2)}", {}, functions_binds), + testcase_parser.eval_content_functions("${add_two_nums(1, 2)}"), 3 ) self.assertEqual( - testcase.eval_content_functions("/api/${add_two_nums(1, 2)}", {}, functions_binds), + testcase_parser.eval_content_functions("/api/${add_two_nums(1, 2)}"), "/api/3" ) @@ -283,8 +288,8 @@ def test_parse_content_with_bindings_testcase(self): }, "body": "$data" } - parsed_testcase = testcase.parse_content_with_bindings( - testcase_template, variables_binds, functions_binds) + parsed_testcase = testcase.TestcaseParser(variables_binds, functions_binds)\ + .parse_content_with_bindings(testcase_template) self.assertEqual( parsed_testcase["url"], From ea4e9c04ebe8e409236aabc12691575b0573c7e6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 25 Aug 2017 21:24:29 +0800 Subject: [PATCH 199/354] update README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e29230b95..ee8543b8c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -jenkins-mail-py version: 0.2.5 ApiTestEngine version: 0.5.0 ``` @@ -73,6 +72,9 @@ To install mail helper, run this command in your terminal: ```text $ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py +$ ate -V +jenkins-mail-py version: 0.2.5 +ApiTestEngine version: 0.5.0 ``` With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. From f43fcfe6c282acfc5af72d1ca8dba930fd892df8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 26 Aug 2017 23:07:35 +0800 Subject: [PATCH 200/354] rename CLI name: ate-locust => locusts --- README.md | 6 +++--- setup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ee8543b8c..fb3dec55f 100644 --- a/README.md +++ b/README.md @@ -220,16 +220,16 @@ $ ate filepath/testcase.yml --report-name ${BUILD_NUMBER} \ With reuse of [`Locust`][Locust], you can run performance test without extra work. ```bash -$ ate-locust -V +$ locusts -V Locust 0.8a2 ``` -For full usage, you can run `ate-locust -h` to see help, and you will find that it is the same with `locust -h`. +For full usage, you can run `locusts -h` to see help, and you will find that it is the same with `locust -h`. The only difference is the `-f` argument. If you specify `-f` with a Python locustfile, it will be the same as `locust`, while if you specify `-f` with a `YAML/JSON` testcase file, it will convert to Python locustfile first and then pass to `locust`. ```bash -$ ate-locust -f examples/first-testcase.yml +$ locusts -f examples/first-testcase.yml [2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 [2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 ``` diff --git a/setup.py b/setup.py index b8b144c72..4b502e244 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ entry_points={ 'console_scripts': [ 'ate=ate.cli:main_ate', - 'ate-locust=ate.cli:main_locust' + 'locusts=ate.cli:main_locust' ] } ) From 6bf3daae115a92e68163f13c96cbdee63f190bc9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 26 Aug 2017 23:29:18 +0800 Subject: [PATCH 201/354] run locusts at full speed with master and several slaves, make the most use of all cpus --- ate/__init__.py | 2 +- ate/cli.py | 11 ++++++++--- ate/locusts.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 ate/locusts.py diff --git a/ate/__init__.py b/ate/__init__.py index 9bdd4d277..08d79c0e9 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.5.0' \ No newline at end of file +__version__ = '0.5.1' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index 4f30f7e65..fd0482f2f 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -4,11 +4,12 @@ import os import sys from collections import OrderedDict -import PyUnitReport from ate import __version__ from ate.task import create_task +import PyUnitReport + def main_ate(): """ API test: parse command line options and run commands. @@ -88,7 +89,7 @@ def main_locust(): """ Performance test with locust: parse command line options and run commands. """ try: - from locust.main import main + from ate.locusts import main, run_locusts_at_full_speed except ImportError: print("Locust is not installed, exit.") exit(1) @@ -110,7 +111,11 @@ def main_locust(): testcase_file_path = sys.argv[testcase_index] sys.argv[testcase_index] = parse_locustfile(testcase_file_path) - main() + + if "--full-speed" in sys.argv: + run_locusts_at_full_speed(sys.argv) + else: + main() def parse_locustfile(file_path): """ parse testcase file and return locustfile path. diff --git a/ate/locusts.py b/ate/locusts.py new file mode 100644 index 000000000..b087e7505 --- /dev/null +++ b/ate/locusts.py @@ -0,0 +1,31 @@ +import multiprocessing +import sys + +from locust.main import main + + +def start_master(sys_argv): + sys_argv.append("--master") + sys.argv = sys_argv + main() + +def start_slave(sys_argv): + sys_argv.extend(["--slave"]) + sys.argv = sys_argv + main() + +def run_locusts_at_full_speed(sys_argv): + sys_argv.pop(sys_argv.index("--full-speed")) + slaves_num = multiprocessing.cpu_count() + + processes = [] + for _ in range(slaves_num): + p_slave = multiprocessing.Process(target=start_slave, args=(sys_argv,)) + p_slave.daemon = True + p_slave.start() + processes.append(p_slave) + + try: + start_master(sys_argv) + except KeyboardInterrupt: + sys.exit(0) From d683457eaf2060000cf35292e2a2b4896f2581e7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 26 Aug 2017 23:45:21 +0800 Subject: [PATCH 202/354] adjust code structure related to locust --- ate/cli.py | 49 +++++-------------------------------------------- ate/locusts.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index fd0482f2f..4c37a5461 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -1,5 +1,4 @@ import argparse -import codecs import logging import os import sys @@ -89,7 +88,7 @@ def main_locust(): """ Performance test with locust: parse command line options and run commands. """ try: - from ate.locusts import main, run_locusts_at_full_speed + from ate import locusts except ImportError: print("Locust is not installed, exit.") exit(1) @@ -99,7 +98,7 @@ def main_locust(): sys.argv.extend(["-h"]) if sys.argv[1] in ["-h", "--help", "-V", "--version"]: - main() + locusts.main() sys.exit(0) try: @@ -110,47 +109,9 @@ def main_locust(): sys.exit(1) testcase_file_path = sys.argv[testcase_index] - sys.argv[testcase_index] = parse_locustfile(testcase_file_path) + sys.argv[testcase_index] = locusts.parse_locustfile(testcase_file_path) if "--full-speed" in sys.argv: - run_locusts_at_full_speed(sys.argv) + locusts.run_locusts_at_full_speed(sys.argv) else: - main() - -def parse_locustfile(file_path): - """ parse testcase file and return locustfile path. - if file_path is a Python file, assume it is a locustfile - if file_path is a YAML/JSON file, convert it to locustfile - """ - if not os.path.isfile(file_path): - print("file path invalid, exit.") - sys.exit(1) - - file_suffix = os.path.splitext(file_path)[1] - if file_suffix == ".py": - locustfile_path = file_path - elif file_suffix in ['.yaml', '.yml', '.json']: - locustfile_path = gen_locustfile(file_path) - else: - # '' or other suffix - print("file type should be YAML/JSON/Python, exit.") - sys.exit(1) - - return locustfile_path - -def gen_locustfile(testcase_file_path): - """ generate locustfile from template. - """ - locustfile_path = 'locustfile.py' - template_path = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - 'locustfile_template' - ) - with codecs.open(template_path, encoding='utf-8') as template: - with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: - template_content = template.read() - template_content = template_content.replace("$HOST", "https://skypixel.com") - template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) - locustfile.write(template_content) - - return locustfile_path + locusts.main() diff --git a/ate/locusts.py b/ate/locusts.py index b087e7505..4d0cb6c09 100644 --- a/ate/locusts.py +++ b/ate/locusts.py @@ -1,9 +1,49 @@ +import codecs import multiprocessing +import os import sys from locust.main import main +def parse_locustfile(file_path): + """ parse testcase file and return locustfile path. + if file_path is a Python file, assume it is a locustfile + if file_path is a YAML/JSON file, convert it to locustfile + """ + if not os.path.isfile(file_path): + print("file path invalid, exit.") + sys.exit(1) + + file_suffix = os.path.splitext(file_path)[1] + if file_suffix == ".py": + locustfile_path = file_path + elif file_suffix in ['.yaml', '.yml', '.json']: + locustfile_path = gen_locustfile(file_path) + else: + # '' or other suffix + print("file type should be YAML/JSON/Python, exit.") + sys.exit(1) + + return locustfile_path + +def gen_locustfile(testcase_file_path): + """ generate locustfile from template. + """ + locustfile_path = 'locustfile.py' + template_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'locustfile_template' + ) + with codecs.open(template_path, encoding='utf-8') as template: + with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: + template_content = template.read() + template_content = template_content.replace("$HOST", "https://skypixel.com") + template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) + locustfile.write(template_content) + + return locustfile_path + def start_master(sys_argv): sys_argv.append("--master") sys.argv = sys_argv From 5a29b1d96d98411bb0fc049412d234af5ac0f5de Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 26 Aug 2017 23:53:53 +0800 Subject: [PATCH 203/354] update README --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fb3dec55f..3905106df 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.5.0 +ApiTestEngine version: 0.5.1 ``` Execute the command `ate -h` to view command help. @@ -74,7 +74,7 @@ To install mail helper, run this command in your terminal: $ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py $ ate -V jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.5.0 +ApiTestEngine version: 0.5.1 ``` With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. @@ -221,7 +221,8 @@ With reuse of [`Locust`][Locust], you can run performance test without extra wor ```bash $ locusts -V -Locust 0.8a2 +[2017-08-26 23:45:42,246] bogon/INFO/stdout: Locust 0.8a2 +[2017-08-26 23:45:42,246] bogon/INFO/stdout: ``` For full usage, you can run `locusts -h` to see help, and you will find that it is the same with `locust -h`. @@ -236,6 +237,22 @@ $ locusts -f examples/first-testcase.yml In this case, you can reuse all features of [`Locust`][Locust]. +That’s not all about it. With the argument `--full-speed`, you can even start locust with master and several slaves (default to cpu cores) at one time, which means you can leverage all cpus of your machine. + +```bash +$ locusts -f examples/first-testcase.yml --full-speed +[2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8089 +[2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,080] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,083] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,084] bogon/INFO/locust.runners: Client 'bogon_656e0af8e968a8533d379dd252422ad3' reported as ready. Currently 1 clients ready to swarm. +[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_09f73850252ee4ec739ed77d3c4c6dba' reported as ready. Currently 2 clients ready to swarm. +[2017-08-26 23:51:47,084] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_869f7ed671b1a9952b56610f01e2006f' reported as ready. Currently 3 clients ready to swarm. +[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm. +``` + Enjoy! ## Supported Python Versions From 887e5c8e52b40867ae4a2d029b3a268542e46812 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 28 Aug 2017 10:21:16 +0800 Subject: [PATCH 204/354] update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3905106df..4cb3fa5ce 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ $ locusts -f examples/first-testcase.yml In this case, you can reuse all features of [`Locust`][Locust]. -That’s not all about it. With the argument `--full-speed`, you can even start locust with master and several slaves (default to cpu cores) at one time, which means you can leverage all cpus of your machine. +That’s not all about it. With the argument `--full-speed`, you can even start locust with master and several slaves (default to cpu cores number) at one time, which means you can leverage all cpus of your machine. ```bash $ locusts -f examples/first-testcase.yml --full-speed From 067cc43a1d17cccb5f83fe166e74635d1cb30044 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 28 Aug 2017 11:06:58 +0800 Subject: [PATCH 205/354] add locusts-full-speed images --- README.md | 2 ++ docs/locusts-full-speed.jpg | Bin 0 -> 65133 bytes 2 files changed, 2 insertions(+) create mode 100644 docs/locusts-full-speed.jpg diff --git a/README.md b/README.md index 4cb3fa5ce..fc066194e 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ $ locusts -f examples/first-testcase.yml --full-speed [2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm. ``` +![](docs/locusts-full-speed.jpg) + Enjoy! ## Supported Python Versions diff --git a/docs/locusts-full-speed.jpg b/docs/locusts-full-speed.jpg new file mode 100644 index 0000000000000000000000000000000000000000..67efa3eadf6fc17c0372d2aa3d5e0ee142e5ca7a GIT binary patch literal 65133 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 literal 0 HcmV?d00001 From c606bf373b78ecfd727efb00e5d4a7571d4fd357 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 28 Aug 2017 18:52:55 +0800 Subject: [PATCH 206/354] convert keys in request headers to lowercase --- ate/context.py | 10 +++++++++- tests/test_context.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/ate/context.py b/ate/context.py index 6d3967164..bf80a8cec 100644 --- a/ate/context.py +++ b/ate/context.py @@ -6,8 +6,9 @@ import types from collections import OrderedDict -from ate.testcase import TestcaseParser from ate import utils +from ate.exception import ParamsError +from ate.testcase import TestcaseParser def is_function(tup): @@ -114,6 +115,13 @@ def __update_context_functions_config(self, level, config_mapping): self.testcase_parser.bind_functions(self.testcase_functions_config) def register_request(self, request_dict, level="testcase"): + if "headers" in request_dict: + # convert keys in request headers to lowercase + headers = request_dict.pop("headers") + if not isinstance(headers, dict): + raise ParamsError("HTTP Request Headers invalid!") + request_dict["headers"] = {key.lower(): headers[key] for key in headers} + self.__update_context_request_config(level, request_dict) def __update_context_request_config(self, level, config_mapping): diff --git a/tests/test_context.py b/tests/test_context.py index 0205de02e..1a3f24dc9 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,6 +3,7 @@ from ate import utils, runner from ate.context import Context +from ate.exception import ParamsError class VariableBindsUnittest(unittest.TestCase): @@ -159,6 +160,28 @@ def test_import_module_functions(self): authorization = context_variables["authorization"] self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) + def test_register_request(self): + request_dict = { + "url": "http://debugtalk.com", + "method": "GET", + "headers": { + "Content-Type": "application/json", + "USER-AGENT": "ios/10.3" + } + } + self.context.register_request(request_dict) + + parsed_request = self.context.get_parsed_request() + self.assertIn("content-type", parsed_request["headers"]) + self.assertIn("user-agent", parsed_request["headers"]) + + request_dict = { + "headers": "invalid headers" + } + with self.assertRaises(ParamsError): + self.context.register_request(request_dict) + + def test_get_parsed_request(self): test_runner = runner.Runner() testcase = { From 4ca827ada61a2d30955a52d035b608484957b8f5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 28 Aug 2017 19:18:06 +0800 Subject: [PATCH 207/354] if request content-type is application/json, request data should be dumped --- ate/__init__.py | 2 +- ate/client.py | 15 +++++++++++++-- tests/test_client.py | 15 ++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 08d79c0e9..f93e0653b 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.5.1' \ No newline at end of file +__version__ = '0.5.2' \ No newline at end of file diff --git a/ate/client.py b/ate/client.py index a6d29ea0e..81ca0acea 100644 --- a/ate/client.py +++ b/ate/client.py @@ -1,17 +1,27 @@ +import json import logging import re import time import requests +from ate.exception import ParamsError from requests import Request, Response from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, RequestException) -from ate.exception import ParamsError - absolute_http_url_regexp = re.compile(r"^https?://", re.I) +def process_kwargs(method, **kwargs): + if method == "POST": + # if request content-type is application/json, request data should be dumped + content_type = kwargs.get("headers", {}).get("content-type", "") + if content_type.startswith("application/json") and "data" in kwargs: + kwargs["data"] = json.dumps(kwargs["data"]) + + return kwargs + + class ApiResponse(Response): def raise_for_status(self): @@ -142,6 +152,7 @@ def _send_request_safe_mode(self, method, url, **kwargs): Safe mode has been removed from requests 1.x. """ try: + kwargs = process_kwargs(method, **kwargs) return requests.Session.request(self, method, url, **kwargs) except (MissingSchema, InvalidSchema, InvalidURL): raise diff --git a/tests/test_client.py b/tests/test_client.py index b6ac1009b..6e13f47a1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -from ate.client import HttpSession +from ate.client import HttpSession, process_kwargs from tests.base import ApiServerUnittest class TestHttpClient(ApiServerUnittest): @@ -35,3 +35,16 @@ def test_request_without_base_url(self): resp = self.api_client.post(url, json=data, headers=self.headers) self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()['success']) + + def test_process_kwargs(self): + kwargs = { + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "data": { + "a": 1, + "b": 2 + } + } + kwargs = process_kwargs("POST", **kwargs) + self.assertEqual(kwargs["data"], '{"a": 1, "b": 2}') From 969c3cce786049d90e8a821f3370125be983dd9f Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 12:37:30 +0800 Subject: [PATCH 208/354] bugfix #35: load YAML/JSON config base_url as locust host --- ate/locusts.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ate/locusts.py b/ate/locusts.py index 4d0cb6c09..c3299b0d3 100644 --- a/ate/locusts.py +++ b/ate/locusts.py @@ -3,6 +3,7 @@ import os import sys +from ate.utils import load_testcases_by_path from locust.main import main @@ -35,10 +36,13 @@ def gen_locustfile(testcase_file_path): os.path.dirname(os.path.realpath(__file__)), 'locustfile_template' ) + testsets = load_testcases_by_path(testcase_file_path) + host = testsets[0].get("config", {}).get("request", {}).get("base_url", "") + with codecs.open(template_path, encoding='utf-8') as template: with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: template_content = template.read() - template_content = template_content.replace("$HOST", "https://skypixel.com") + template_content = template_content.replace("$HOST", host) template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) locustfile.write(template_content) From 1080d620bd70df96bf0a54a60554d57cf865537c Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 14:44:44 +0800 Subject: [PATCH 209/354] bugfix #34: handle exception when response content is empty --- ate/__init__.py | 2 +- ate/exception.py | 3 +++ ate/response.py | 7 +++++-- ate/utils.py | 17 ++++++++++------- tests/test_response.py | 28 ++++++++++++++++++++++++++++ tests/test_utils.py | 11 +++++++++++ 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index f93e0653b..b4a839ece 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.5.2' \ No newline at end of file +__version__ = '0.5.3' \ No newline at end of file diff --git a/ate/exception.py b/ate/exception.py index 56768985e..53b91138e 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -6,6 +6,9 @@ class MyBaseError(BaseException): class ParamsError(MyBaseError): pass +class ResponseError(MyBaseError): + pass + class ParseResponseError(MyBaseError): pass diff --git a/ate/response.py b/ate/response.py index 34dc0f433..d97e4a890 100644 --- a/ate/response.py +++ b/ate/response.py @@ -31,10 +31,13 @@ def extract_field(self, field, delimiter='.'): "content.person.name.first_name" """ try: - field += "." # string.split(sep=None, maxsplit=-1) -> list of strings # e.g. "content.person.name" => ["content", "person.name"] - top_query, sub_query = field.split(delimiter, 1) + try: + top_query, sub_query = field.split(delimiter, 1) + except ValueError: + top_query = field + sub_query = None if top_query in ["body", "content", "text"]: json_content = self.parsed_body() diff --git a/ate/utils.py b/ate/utils.py index f2ad95cd9..00ea94f58 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -6,9 +6,10 @@ import random import re import string -import yaml +import yaml from ate import exception +from requests.structures import CaseInsensitiveDict try: string_type = basestring @@ -131,15 +132,17 @@ def query_json(json_content, query, delimiter='.'): "person.cities.0" => "Guangzhou" @return queried result """ - stripped_query = query.strip(delimiter) - if not stripped_query: - return None + if json_content == "": + raise exception.ResponseError("response content is empty!") try: - for key in stripped_query.split(delimiter): + for key in query.split(delimiter): if isinstance(json_content, list): - key = int(key) - json_content = json_content[key] + json_content = json_content[int(key)] + elif isinstance(json_content, (dict, CaseInsensitiveDict)): + json_content = json_content[key] + else: + raise exception.ParseResponseError("response content is in text format! failed to query key {}!".format(key)) except (KeyError, ValueError, IndexError): raise exception.ParseResponseError("failed to query json when extracting response!") diff --git a/tests/test_response.py b/tests/test_response.py index b290c9471..8ad281bdb 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -149,6 +149,34 @@ def test_extract_response_json_string(self): "abc" ) + def test_extract_response_empty(self): + resp = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'Content-Type': "application/json" + }, + 'body': "" + } + ) + + extract_binds_list = [ + {"resp_content_body": "content"} + ] + resp_obj = response.ResponseObject(resp) + extract_binds_dict_list = resp_obj.extract_response(extract_binds_list) + self.assertEqual( + extract_binds_dict_list[0]["resp_content_body"], + "" + ) + + extract_binds_list = [ + {"resp_content_body": "content.abc"} + ] + resp_obj = response.ResponseObject(resp) + with self.assertRaises(exception.ResponseError): + resp_obj.extract_response(extract_binds_list) + def test_validate(self): url = "http://127.0.0.1:5000/" resp = requests.get(url) diff --git a/tests/test_utils.py b/tests/test_utils.py index 619bebf3b..8b8692662 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -154,6 +154,17 @@ def test_query_json(self): result = utils.query_json(json_content, query) self.assertEqual(result, "Leo") + def test_query_json_content_is_text(self): + json_content = "" + query = "key" + with self.assertRaises(exception.ResponseError): + utils.query_json(json_content, query) + + json_content = "content" + query = "key" + with self.assertRaises(exception.ParseResponseError): + utils.query_json(json_content, query) + def test_match_expected(self): self.assertTrue(utils.match_expected(1, 1, "eq")) self.assertTrue(utils.match_expected("abc", "abc", "==")) From 953a1d54ca213de12d2ae3f1c9e2f6d17aaaa3be Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 14:54:51 +0800 Subject: [PATCH 210/354] fix travis-ci job 152 --- ate/utils.py | 3 ++- tests/test_client.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 00ea94f58..be9efa7b2 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -142,7 +142,8 @@ def query_json(json_content, query, delimiter='.'): elif isinstance(json_content, (dict, CaseInsensitiveDict)): json_content = json_content[key] else: - raise exception.ParseResponseError("response content is in text format! failed to query key {}!".format(key)) + raise exception.ParseResponseError( + "response content is in text format! failed to query key {}!".format(key)) except (KeyError, ValueError, IndexError): raise exception.ParseResponseError("failed to query json when extracting response!") diff --git a/tests/test_client.py b/tests/test_client.py index 6e13f47a1..51331232a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -47,4 +47,5 @@ def test_process_kwargs(self): } } kwargs = process_kwargs("POST", **kwargs) - self.assertEqual(kwargs["data"], '{"a": 1, "b": 2}') + self.assertIn('"a": 1', kwargs["data"]) + self.assertIn('"b": 2', kwargs["data"]) From d3a068cb6da94d7d8b82a2de53d01468454b0da6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 15:17:52 +0800 Subject: [PATCH 211/354] prepare_kwargs: reference kwargs do not need to return --- ate/client.py | 6 ++---- tests/test_client.py | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/ate/client.py b/ate/client.py index 81ca0acea..a56c9fc94 100644 --- a/ate/client.py +++ b/ate/client.py @@ -12,15 +12,13 @@ absolute_http_url_regexp = re.compile(r"^https?://", re.I) -def process_kwargs(method, **kwargs): +def prepare_kwargs(method, kwargs): if method == "POST": # if request content-type is application/json, request data should be dumped content_type = kwargs.get("headers", {}).get("content-type", "") if content_type.startswith("application/json") and "data" in kwargs: kwargs["data"] = json.dumps(kwargs["data"]) - return kwargs - class ApiResponse(Response): @@ -152,7 +150,7 @@ def _send_request_safe_mode(self, method, url, **kwargs): Safe mode has been removed from requests 1.x. """ try: - kwargs = process_kwargs(method, **kwargs) + prepare_kwargs(method, kwargs) return requests.Session.request(self, method, url, **kwargs) except (MissingSchema, InvalidSchema, InvalidURL): raise diff --git a/tests/test_client.py b/tests/test_client.py index 51331232a..bd169f26a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -from ate.client import HttpSession, process_kwargs +from ate.client import HttpSession, prepare_kwargs from tests.base import ApiServerUnittest class TestHttpClient(ApiServerUnittest): @@ -36,7 +36,7 @@ def test_request_without_base_url(self): self.assertEqual(201, resp.status_code) self.assertEqual(True, resp.json()['success']) - def test_process_kwargs(self): + def test_prepare_kwargs(self): kwargs = { "headers": { "content-type": "application/json; charset=utf-8" @@ -46,6 +46,6 @@ def test_process_kwargs(self): "b": 2 } } - kwargs = process_kwargs("POST", **kwargs) + prepare_kwargs("POST", kwargs) self.assertIn('"a": 1', kwargs["data"]) self.assertIn('"b": 2', kwargs["data"]) From 5e7905e51c97a877ff346d2f9fea3d7d89e7f615 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 18:10:55 +0800 Subject: [PATCH 212/354] bugfix: numeric types should include long and complex --- ate/testcase.py | 4 ++-- ate/utils.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 9b5f5146a..b1e36921e 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,7 +1,7 @@ import ast import re -from ate import utils +from ate.utils import long_type from ate.exception import ParamsError variable_regexp = r"\$([\w_]+)" @@ -222,7 +222,7 @@ def parse_content_with_bindings(self, content): return evaluated_data - if isinstance(content, (int, float)): + if isinstance(content, (int, long_type, float, complex)): return content # content is in string format here diff --git a/ate/utils.py b/ate/utils.py index be9efa7b2..c3521d100 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -13,9 +13,11 @@ try: string_type = basestring + long_type = long PYTHON_VERSION = 2 except NameError: string_type = str + long_type = int PYTHON_VERSION = 3 SECRET_KEY = "DebugTalk" From 70bdbb90e1296b2144941d418a8d978010958096 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 21:33:02 +0800 Subject: [PATCH 213/354] #36: add debugtalk.py local per-directory plugins --- ate/__init__.py | 2 +- ate/context.py | 3 +++ ate/debugtalk.py | 26 ++++++++++++++++++++++++++ tests/data/custom_functions.py | 3 --- tests/test_context.py | 14 ++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 ate/debugtalk.py diff --git a/ate/__init__.py b/ate/__init__.py index b4a839ece..a774690a6 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.5.3' \ No newline at end of file +__version__ = '0.5.4' \ No newline at end of file diff --git a/ate/context.py b/ate/context.py index bf80a8cec..0cb82b324 100644 --- a/ate/context.py +++ b/ate/context.py @@ -46,6 +46,9 @@ def init_context(self, level='testset'): self.testcase_parser.bind_functions(self.testcase_functions_config) self.testcase_parser.bind_variables(self.testcase_variables_mapping) + if level == "testset": + self.import_module_functions(["ate.debugtalk"], "testset") + def import_requires(self, modules): """ import required modules dynamicly """ diff --git a/ate/debugtalk.py b/ate/debugtalk.py new file mode 100644 index 000000000..7b14f2078 --- /dev/null +++ b/ate/debugtalk.py @@ -0,0 +1,26 @@ +import datetime +import random +import string +import time + +from ate.exception import ParamsError + + +def gen_random_string(str_len): + """ generate random string with specified length + """ + return ''.join( + random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) + +def get_timestamp(str_len=13): + """ get timestamp string, length can only between 0 and 16 + """ + if isinstance(str_len, int) and 0 < str_len < 17: + return str(time.time()).replace(".", "")[:str_len] + + raise ParamsError("timestamp length can only between 0 and 16.") + +def get_current_date(fmt="%Y-%m-%d"): + """ get current date, default format is %Y-%m-%d + """ + return datetime.datetime.now().strftime(fmt) diff --git a/tests/data/custom_functions.py b/tests/data/custom_functions.py index 1cd6de6fa..da4f9c955 100644 --- a/tests/data/custom_functions.py +++ b/tests/data/custom_functions.py @@ -65,6 +65,3 @@ def gen_urlencode_str(**kargs): urlencoded_str += "&" return urlencoded_str.strip("&") - -def get_timestamp(): - return int(time.time() * 1000) diff --git a/tests/test_context.py b/tests/test_context.py index 1a3f24dc9..873d6cfe7 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -13,6 +13,20 @@ def setUp(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_binds.yml') self.testcases = utils.load_testcases(testcase_file_path) + def test_context_init_functions(self): + self.assertIn("get_timestamp", self.context.testset_functions_config) + self.assertIn("gen_random_string", self.context.testset_functions_config) + + variable_binds = [ + {"random": "${gen_random_string(5)}"}, + {"timestamp10": "${get_timestamp(10)}"} + ] + self.context.bind_variables(variable_binds) + context_variables = self.context.get_testcase_variables_mapping() + + self.assertEqual(len(context_variables["random"]), 5) + self.assertEqual(len(context_variables["timestamp10"]), 10) + def test_context_bind_testset_variables(self): # testcase in JSON format testcase1 = { From 62ffacce208bafc24bd1bf40c4ff74f855fae7be Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 21:50:24 +0800 Subject: [PATCH 214/354] add module doc --- ate/debugtalk.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ate/debugtalk.py b/ate/debugtalk.py index 7b14f2078..ffc1867d8 100644 --- a/ate/debugtalk.py +++ b/ate/debugtalk.py @@ -1,3 +1,7 @@ +""" +Built-in dependent functions used in YAML/JSON testcases. +""" + import datetime import random import string From 34d0c6a1e8cbf7ece934209bc00e1c337f9515d3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 29 Aug 2017 21:59:42 +0800 Subject: [PATCH 215/354] rename fixture module name --- README.md | 6 +- ate/runner.py | 2 +- examples/{utils.py => debugtalk.py} | 0 examples/quickstart-demo-rev-2.yml | 2 +- examples/quickstart-demo-rev-3.yml | 2 +- tests/data/custom_functions.py | 67 ------------------- tests/data/debugtalk.py | 31 +++++++++ tests/data/demo_binds.yml | 2 +- ...demo_testset_template_import_functions.yml | 2 +- tests/test_context.py | 4 +- 10 files changed, 41 insertions(+), 77 deletions(-) rename examples/{utils.py => debugtalk.py} (100%) delete mode 100644 tests/data/custom_functions.py create mode 100644 tests/data/debugtalk.py diff --git a/README.md b/README.md index fc066194e..ca4b42c2f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.5.1 +ApiTestEngine version: 0.5.4 ``` Execute the command `ate -h` to view command help. @@ -74,7 +74,7 @@ To install mail helper, run this command in your terminal: $ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py $ ate -V jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.5.1 +ApiTestEngine version: 0.5.4 ``` With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. @@ -133,7 +133,7 @@ And here is testset example of typical scenario: get token at the beginning, and - config: name: "create user testsets." import_module_functions: - - tests.data.custom_functions + - tests.data.debugtalk variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} diff --git a/ate/runner.py b/ate/runner.py index 9b2a33023..b73995adb 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -25,7 +25,7 @@ def init_config(self, config_dict, level): "lambda *str_args: hashlib.md5(''.join(str_args).\ encode('utf-8')).hexdigest()" }, - "import_module_functions": ["test.data.custom_functions"], + "import_module_functions": ["test.data.debugtalk"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, diff --git a/examples/utils.py b/examples/debugtalk.py similarity index 100% rename from examples/utils.py rename to examples/debugtalk.py diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index 44addc184..4380e0473 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -1,7 +1,7 @@ - test: name: get token import_module_functions: - - examples.utils + - examples.debugtalk variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index 5922606fc..3b3f81d08 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -1,7 +1,7 @@ - config: name: "smoketest for CRUD users." import_module_functions: - - examples.utils + - examples.debugtalk variable_binds: - device_sn: ${gen_random_string(15)} request: diff --git a/tests/data/custom_functions.py b/tests/data/custom_functions.py deleted file mode 100644 index da4f9c955..000000000 --- a/tests/data/custom_functions.py +++ /dev/null @@ -1,67 +0,0 @@ -import hashlib -import hmac -import json -import random -import string -import time - -try: - string_type = basestring - PYTHON_VERSION = 2 - import urllib -except NameError: - string_type = str - PYTHON_VERSION = 3 - import urllib.parse as urllib - -SECRET_KEY = "DebugTalk" - -def gen_random_string(str_len): - random_char_list = [] - for _ in range(str_len): - random_char = random.choice(string.ascii_letters + string.digits) - random_char_list.append(random_char) - - random_string = ''.join(random_char_list) - return random_string - -gen_random_string_lambda = lambda str_len: ''.join( - random.choice(string.ascii_letters + string.digits) for _ in range(str_len)) - -def get_sign(*args): - content = ''.join(args).encode('ascii') - sign_key = SECRET_KEY.encode('ascii') - sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() - return sign - -get_sign_lambda = lambda *args: hmac.new( - 'DebugTalk'.encode('ascii'), - ''.join(args).encode('ascii'), - hashlib.sha1).hexdigest() - -def gen_md5(*args): - return hashlib.md5("".join(args).encode('utf-8')).hexdigest() - -def gen_urlencode_str(**kargs): - urlencoded_str = "" - quote_times = int(kargs.pop("quote_times", 1)) - - for key, value in kargs.items(): - urlencoded_str += key - urlencoded_str += "=" - if value == "undefined": - urlencoded_str += "undefined" - else: - if isinstance(value, (dict, list)): - value = json.dumps(value) - elif isinstance(value, (int, float)): - value = str(value) - - value_str = value.encode('utf-8') - for _ in range(quote_times): - value_str = urllib.quote_plus(value_str) - urlencoded_str += value_str - - urlencoded_str += "&" - - return urlencoded_str.strip("&") diff --git a/tests/data/debugtalk.py b/tests/data/debugtalk.py new file mode 100644 index 000000000..19dd4ce81 --- /dev/null +++ b/tests/data/debugtalk.py @@ -0,0 +1,31 @@ +import hashlib +import hmac +import json +import random +import string +import time + +try: + string_type = basestring + PYTHON_VERSION = 2 + import urllib +except NameError: + string_type = str + PYTHON_VERSION = 3 + import urllib.parse as urllib + +SECRET_KEY = "DebugTalk" + +def get_sign(*args): + content = ''.join(args).encode('ascii') + sign_key = SECRET_KEY.encode('ascii') + sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() + return sign + +get_sign_lambda = lambda *args: hmac.new( + 'DebugTalk'.encode('ascii'), + ''.join(args).encode('ascii'), + hashlib.sha1).hexdigest() + +def gen_md5(*args): + return hashlib.md5("".join(args).encode('utf-8')).hexdigest() diff --git a/tests/data/demo_binds.yml b/tests/data/demo_binds.yml index f1e5327e2..5a1a70d93 100644 --- a/tests/data/demo_binds.yml +++ b/tests/data/demo_binds.yml @@ -28,7 +28,7 @@ bind_lambda_functions_with_import: bind_module_functions: function_binds: import_module_functions: - - tests.data.custom_functions + - tests.data.debugtalk variable_binds: - TOKEN: debugtalk - random: ${gen_random_string(5)} diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index 44e7feb7e..702c1df98 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -1,7 +1,7 @@ - config: name: "create user testsets." import_module_functions: - - tests.data.custom_functions + - tests.data.debugtalk variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} diff --git a/tests/test_context.py b/tests/test_context.py index 873d6cfe7..96609cac4 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -142,7 +142,7 @@ def test_context_bind_lambda_functions_with_import(self): def test_import_module_functions(self): testcase1 = { - "import_module_functions": ["tests.data.custom_functions"], + "import_module_functions": ["tests.data.debugtalk"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, @@ -199,7 +199,7 @@ def test_register_request(self): def test_get_parsed_request(self): test_runner = runner.Runner() testcase = { - "import_module_functions": ["tests.data.custom_functions"], + "import_module_functions": ["tests.data.debugtalk"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, From cbb2dcdcb399bd4889fec9bb011348c60f82b446 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 30 Aug 2017 12:15:44 +0800 Subject: [PATCH 216/354] add match filter to load_foler_files --- ate/utils.py | 11 ++++++++--- tests/test_utils.py | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index c3521d100..1b6c22aee 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,4 +1,5 @@ import codecs +import fnmatch import hashlib import hmac import json @@ -53,13 +54,17 @@ def load_testcases(testcase_file_path): # '' or other suffix return [] -def load_foler_files(folder_path): +def load_foler_files(folder_path, match_filter_list=["*"]): """ load folder path, return all files in list format. """ file_list = [] for dirpath, dirnames, filenames in os.walk(folder_path): - for filename in filenames: + filenames_list = [] + for match_filter in match_filter_list: + filenames_list.extend(fnmatch.filter(filenames, match_filter)) + + for filename in filenames_list: file_path = os.path.join(dirpath, filename) file_list.append(file_path) @@ -91,7 +96,7 @@ def load_testcases_by_path(path): path = os.path.join(os.getcwd(), path) if os.path.isdir(path): - files_list = load_foler_files(path) + files_list = load_foler_files(path, ["*.yml", "*.json"]) return load_testcases_by_path(files_list) elif os.path.isfile(path): diff --git a/tests/test_utils.py b/tests/test_utils.py index 8b8692662..f6d2976d2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -33,11 +33,12 @@ def test_load_yaml_testcases(self): def test_load_foler_files(self): folder = os.path.join(os.getcwd(), 'tests') - files = utils.load_foler_files(folder) file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') file2 = os.path.join(os.getcwd(), 'tests', 'data', 'demo_binds.yml') + + files = utils.load_foler_files(folder, ["*.py"]) self.assertIn(file1, files) - self.assertIn(file2, files) + self.assertNotIn(file2, files) def test_load_testcases_by_path_files(self): testsets_list = [] From 19a5580d34e9719115fee7fa1370147d00c9f812 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 30 Aug 2017 12:18:32 +0800 Subject: [PATCH 217/354] filter if files do not include testcases --- ate/utils.py | 4 ++-- tests/test_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 1b6c22aee..4922c0d34 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -96,7 +96,7 @@ def load_testcases_by_path(path): path = os.path.join(os.getcwd(), path) if os.path.isdir(path): - files_list = load_foler_files(path, ["*.yml", "*.json"]) + files_list = load_foler_files(path, ["*.yml", "*.yaml", "*.json"]) return load_testcases_by_path(files_list) elif os.path.isfile(path): @@ -115,7 +115,7 @@ def load_testcases_by_path(path): elif key == "test": testset["testcases"].append(item["test"]) - return [testset] + return [testset] if testset["testcases"] else [] else: return [] diff --git a/tests/test_utils.py b/tests/test_utils.py index f6d2976d2..18379b83e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -81,7 +81,7 @@ def test_load_testcases_by_path_folder(self): # absolute folder path path = os.path.join(os.getcwd(), 'tests/data') testset_list_1 = utils.load_testcases_by_path(path) - self.assertGreater(len(testset_list_1), 5) + self.assertGreater(len(testset_list_1), 4) # relative folder path path = 'tests/data/' From 371fa8582bc1b85a6dc14d4959f4ff1687bc63d5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 30 Aug 2017 16:54:55 +0800 Subject: [PATCH 218/354] save testcase file path in testset config --- ate/utils.py | 6 ++++-- tests/test_utils.py | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 4922c0d34..3934c00d8 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -102,7 +102,9 @@ def load_testcases_by_path(path): elif os.path.isfile(path): testset = { "name": "", - "config": {}, + "config": { + "path": path + }, "testcases": [] } testcases_list = load_testcases(path) @@ -110,7 +112,7 @@ def load_testcases_by_path(path): for item in testcases_list: for key in item: if key == "config": - testset["config"] = item["config"] + testset["config"].update(item["config"]) testset["name"] = item["config"].get("name", "") elif key == "test": testset["testcases"].append(item["test"]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 18379b83e..d033dbdb6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -48,6 +48,8 @@ def test_load_testcases_by_path_files(self): os.getcwd(), 'tests/data/demo_testset_hardcode.json') testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) + self.assertIn("path", testset_list[0]["config"]) + self.assertEqual(testset_list[0]["config"]["path"], path) self.assertEqual(len(testset_list[0]["testcases"]), 3) testsets_list.extend(testset_list) @@ -55,6 +57,8 @@ def test_load_testcases_by_path_files(self): path = 'tests/data/demo_testset_hardcode.yml' testset_list = utils.load_testcases_by_path(path) self.assertEqual(len(testset_list), 1) + self.assertIn("path", testset_list[0]["config"]) + self.assertIn(path, testset_list[0]["config"]["path"]) self.assertEqual(len(testset_list[0]["testcases"]), 3) testsets_list.extend(testset_list) From d41545dcc619c0c5f2949204bf8ed425d34d68b8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 30 Aug 2017 16:59:11 +0800 Subject: [PATCH 219/354] fix doc string --- ate/runner.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index b73995adb..513e64908 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -129,8 +129,8 @@ def run_testset(self, testset): } @return (list) test results of testcases [ - (success, diff_content), # testcase1 - (success, diff_content) # testcase2 + True, # testcase11 + True # testcase12 ] """ results = [] @@ -154,12 +154,12 @@ def run_testsets(self, testsets): @return (list) test results of testsets [ [ # testset1 - (success, diff_content), # testcase11 - (success, diff_content) # testcase12 + True, # testcase11 + True # testcase12 ], [ # testset2 - (success, diff_content), # testcase21 - (success, diff_content) # testcase22 + True, # testcase21 + True # testcase22 ] ] """ From 4f154c5d8872b82ae04121b4219570683e921834 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 10:35:05 +0800 Subject: [PATCH 220/354] relocate functions --- ate/context.py | 10 +--------- ate/utils.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ate/context.py b/ate/context.py index 0cb82b324..03b692da9 100644 --- a/ate/context.py +++ b/ate/context.py @@ -3,7 +3,6 @@ import os import re import sys -import types from collections import OrderedDict from ate import utils @@ -11,12 +10,6 @@ from ate.testcase import TestcaseParser -def is_function(tup): - """ Takes (name, object) tuple, returns True if it is a function. - """ - name, item = tup - return isinstance(item, types.FunctionType) - class Context(object): """ Manages context functions and variables. context has two levels, testset and testcase. @@ -77,8 +70,7 @@ def import_module_functions(self, modules, level="testcase"): """ sys.path.insert(0, os.getcwd()) for module_name in modules: - imported = importlib.import_module(module_name) - imported_functions_dict = dict(filter(is_function, vars(imported).items())) + imported_functions_dict = utils.get_module_functions(module_name) self.__update_context_functions_config(level, imported_functions_dict) def bind_variables(self, variable_binds, level="testcase"): diff --git a/ate/utils.py b/ate/utils.py index 3934c00d8..4f4694d30 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -2,11 +2,13 @@ import fnmatch import hashlib import hmac +import importlib import json import os.path import random import re import string +import types import yaml from ate import exception @@ -246,3 +248,16 @@ def deep_update_dict(origin_dict, override_dict): origin_dict[key] = override_dict[key] return origin_dict + +def is_function(tup): + """ Takes (name, object) tuple, returns True if it is a function. + """ + name, item = tup + return isinstance(item, types.FunctionType) + +def get_module_functions(module_name): + """ import module and return filtered functions + """ + imported = importlib.import_module(module_name) + module_functions_dict = dict(filter(is_function, vars(imported).items())) + return module_functions_dict From b1f3ba34e8989434dbea6cf9382bf0250bf7131c Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 10:48:15 +0800 Subject: [PATCH 221/354] separate get_imported_module --- ate/context.py | 6 +++--- ate/utils.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ate/context.py b/ate/context.py index 03b692da9..5dacd3d4b 100644 --- a/ate/context.py +++ b/ate/context.py @@ -1,5 +1,4 @@ import copy -import importlib import os import re import sys @@ -46,7 +45,7 @@ def import_requires(self, modules): """ import required modules dynamicly """ for module_name in modules: - globals()[module_name] = importlib.import_module(module_name) + globals()[module_name] = utils.get_imported_module(module_name) def bind_functions(self, function_binds, level="testcase"): """ Bind named functions within the context @@ -70,7 +69,8 @@ def import_module_functions(self, modules, level="testcase"): """ sys.path.insert(0, os.getcwd()) for module_name in modules: - imported_functions_dict = utils.get_module_functions(module_name) + imported_module = utils.get_imported_module(module_name) + imported_functions_dict = utils.filter_module_functions(imported_module) self.__update_context_functions_config(level, imported_functions_dict) def bind_variables(self, variable_binds, level="testcase"): diff --git a/ate/utils.py b/ate/utils.py index 4f4694d30..b795984eb 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -255,9 +255,13 @@ def is_function(tup): name, item = tup return isinstance(item, types.FunctionType) -def get_module_functions(module_name): - """ import module and return filtered functions +def get_imported_module(module_name): + """ import module and return imported module """ - imported = importlib.import_module(module_name) - module_functions_dict = dict(filter(is_function, vars(imported).items())) + return importlib.import_module(module_name) + +def filter_module_functions(module): + """ filter functions from import module + """ + module_functions_dict = dict(filter(is_function, vars(module).items())) return module_functions_dict From 660549dc23ec176616b47f6daa233fb7f330ea6d Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 12:25:15 +0800 Subject: [PATCH 222/354] search expected function recursive upward --- ate/exception.py | 7 +++++++ ate/utils.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_utils.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/ate/exception.py b/ate/exception.py index 53b91138e..92df58877 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -1,4 +1,8 @@ #coding: utf-8 +try: + FileNotFoundError = FileNotFoundError +except NameError: + FileNotFoundError = IOError class MyBaseError(BaseException): pass @@ -14,3 +18,6 @@ class ParseResponseError(MyBaseError): class ValidationError(MyBaseError): pass + +class FunctionNotFound(NameError): + pass diff --git a/ate/utils.py b/ate/utils.py index b795984eb..4eb1eb504 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -2,6 +2,7 @@ import fnmatch import hashlib import hmac +import imp import importlib import json import os.path @@ -260,8 +261,42 @@ def get_imported_module(module_name): """ return importlib.import_module(module_name) +def get_imported_module_from_file(file_path): + """ import module from python file path and return imported module + """ + + if PYTHON_VERSION == 3: + imported_module = importlib.machinery.SourceFileLoader( + 'module_name', file_path).load_module() + else: + # Python 2.7 + imported_module = imp.load_source('module_name', file_path) + + return imported_module + def filter_module_functions(module): """ filter functions from import module """ module_functions_dict = dict(filter(is_function, vars(module).items())) return module_functions_dict + +def search_conf_function(start_path, func): + """ search expected function recursive upward + """ + dir_path = os.path.dirname(os.path.abspath(start_path)) + target_file = os.path.join(dir_path, "debugtalk.py") + + if os.path.isfile(target_file): + imported_module = get_imported_module_from_file(target_file) + functions_dict = filter_module_functions(imported_module) + if func in functions_dict: + return functions_dict[func] + else: + return search_conf_function(dir_path, func) + + if dir_path == start_path: + # system root path + err_msg = "{} not found in recursive upward path!".format(func) + raise exception.FunctionNotFound(err_msg) + + return search_conf_function(dir_path, func) diff --git a/tests/test_utils.py b/tests/test_utils.py index d033dbdb6..697e75c97 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -225,3 +225,41 @@ def test_deep_update_dict(self): updated_dict, {'a': 2, 'b': {'c': 33, 'd': 4, 'e': 5}, 'f': 6, 'g': 7} ) + + def test_get_imported_module(self): + imported_module = utils.get_imported_module("os") + self.assertIn("walk", dir(imported_module)) + + def test_filter_module_functions(self): + imported_module = utils.get_imported_module("ate.utils") + self.assertIn("PYTHON_VERSION", dir(imported_module)) + + functions_dict = utils.filter_module_functions(imported_module) + self.assertIn("filter_module_functions", functions_dict) + self.assertNotIn("PYTHON_VERSION", functions_dict) + + def test_get_imported_module_from_file(self): + imported_module = utils.get_imported_module_from_file("tests/data/debugtalk.py") + self.assertIn("gen_md5", dir(imported_module)) + + functions_dict = utils.filter_module_functions(imported_module) + self.assertIn("gen_md5", functions_dict) + self.assertNotIn("PYTHON_VERSION", functions_dict) + + with self.assertRaises(exception.FileNotFoundError): + utils.get_imported_module_from_file("tests/data/debugtalk2.py") + + def test_search_conf_function(self): + gen_md5 = utils.search_conf_function("tests/data/demo_binds.yml", "gen_md5") + self.assertTrue(utils.is_function(("gen_md5", gen_md5))) + self.assertEqual(gen_md5("abc"), "900150983cd24fb0d6963f7d28e17f72") + + gen_md5 = utils.search_conf_function("tests/data/subfolder/test.yml", "gen_md5") + self.assertTrue(utils.is_function(("_", gen_md5))) + self.assertEqual(gen_md5("abc"), "900150983cd24fb0d6963f7d28e17f72") + + with self.assertRaises(exception.FunctionNotFound): + utils.search_conf_function("tests/data/subfolder/test.yml", "func_not_exist") + + with self.assertRaises(exception.FunctionNotFound): + utils.search_conf_function("/user/local/bin", "gen_md5") From 6c39b07b0f04c705c7dd7087c8612864b76bb46c Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 12:46:38 +0800 Subject: [PATCH 223/354] get bind functions from upward searched debugtalk.py --- ate/testcase.py | 28 +++++++++++++++++++--------- tests/test_testcase.py | 10 ++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index b1e36921e..2c48e8b39 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,8 +1,8 @@ import ast +import os import re -from ate.utils import long_type -from ate.exception import ParamsError +from ate import exception, utils variable_regexp = r"\$([\w_]+)" function_regexp = r"\$\{[\w_]+\([\$\w_ =,]*\)\}" @@ -105,7 +105,7 @@ def eval_content_variables(content, variable_mapping): variables_list = extract_variables(content) for variable_name in variables_list: if variable_name not in variable_mapping: - raise ParamsError( + raise exception.ParamsError( "%s is not defined in bind variables!" % variable_name) variable_value = variable_mapping.get(variable_name) @@ -124,9 +124,10 @@ def eval_content_variables(content, variable_mapping): class TestcaseParser(object): - def __init__(self, variables_binds={}, functions_binds={}): + def __init__(self, variables_binds={}, functions_binds={}, file_path=None): self.bind_variables(variables_binds) self.bind_functions(functions_binds) + self.file_path = file_path def bind_variables(self, variables_binds): """ bind variables to current testcase parser @@ -149,16 +150,25 @@ def bind_functions(self, functions_binds): """ self.functions_binds = functions_binds + def get_bind_fuctions(self, func_name): + func = self.functions_binds.get(func_name) + if func: + return func + + try: + assert self.file_path is not None + return utils.search_conf_function(self.file_path, func_name) + except (AssertionError, exception.FunctionNotFound): + raise exception.ParamsError( + "%s is not defined in bind functions!" % func_name) + def eval_content_functions(self, content): functions_list = extract_functions(content) for func_content in functions_list: function_meta = parse_function(func_content) func_name = function_meta['func_name'] - func = self.functions_binds.get(func_name) - if func is None: - raise ParamsError( - "%s is not defined in bind functions!" % func_name) + func = self.get_bind_fuctions(func_name) args = function_meta.get('args', []) kwargs = function_meta.get('kwargs', {}) @@ -222,7 +232,7 @@ def parse_content_with_bindings(self, content): return evaluated_data - if isinstance(content, (int, long_type, float, complex)): + if isinstance(content, (int, utils.long_type, float, complex)): return content # content is in string format here diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 0ead19b3c..8626e969f 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -266,6 +266,16 @@ def test_eval_content_functions(self): "/api/3" ) + def test_eval_content_functions_search_upward(self): + testcase_parser = testcase.TestcaseParser() + + with self.assertRaises(ParamsError): + testcase_parser.eval_content_functions("/api/${gen_md5(abc)}") + + testcase_parser.file_path = "tests/data/demo_testset_hardcode.yml" + content = testcase_parser.eval_content_functions("/api/${gen_md5(abc)}") + self.assertEqual(content, "/api/900150983cd24fb0d6963f7d28e17f72") + def test_parse_content_with_bindings_testcase(self): variables_binds = { "uid": "1000", From ca9d41544e1445bf113b65b029f5fe2bafe55a86 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 12:51:39 +0800 Subject: [PATCH 224/354] hot plugin support: search functions recursive upward from testset file --- ate/__init__.py | 2 +- ate/runner.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ate/__init__.py b/ate/__init__.py index a774690a6..83e147c62 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.5.4' \ No newline at end of file +__version__ = '0.6.0' \ No newline at end of file diff --git a/ate/runner.py b/ate/runner.py index 513e64908..012fb9f04 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -51,6 +51,7 @@ def init_config(self, config_dict, level): if level == "testset": base_url = request_config.pop("base_url", None) self.http_client_session = self.http_client_session or HttpSession(base_url) + self.context.testcase_parser.file_path = config_dict.get("path", None) else: # testcase self.http_client_session = self.http_client_session or requests.Session() From d9ecc3f3f70bcb139acfb635e88df97ca0ada24a Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 14:20:42 +0800 Subject: [PATCH 225/354] rename built-in file name --- ate/{debugtalk.py => built_in.py} | 0 ate/context.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename ate/{debugtalk.py => built_in.py} (100%) diff --git a/ate/debugtalk.py b/ate/built_in.py similarity index 100% rename from ate/debugtalk.py rename to ate/built_in.py diff --git a/ate/context.py b/ate/context.py index 5dacd3d4b..fd5394b87 100644 --- a/ate/context.py +++ b/ate/context.py @@ -39,7 +39,7 @@ def init_context(self, level='testset'): self.testcase_parser.bind_variables(self.testcase_variables_mapping) if level == "testset": - self.import_module_functions(["ate.debugtalk"], "testset") + self.import_module_functions(["ate.built_in"], "testset") def import_requires(self, modules): """ import required modules dynamicly From e2d9c44a1839f47ec210f6900f948a474442b079 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 31 Aug 2017 15:01:51 +0800 Subject: [PATCH 226/354] update docs for debugtalk.py plugin --- README.md | 11 ++++++----- docs/quickstart.md | 16 ++++++---------- examples/quickstart-demo-rev-2.yml | 2 -- examples/quickstart-demo-rev-3.yml | 2 -- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ca4b42c2f..bc1d2b9ff 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], - Inherit all powerful features of [`Requests`][requests], just have fun to handle HTTP in human way. - Define testcases in YAML or JSON format in concise and elegant manner. - Supports `function`/`variable`/`extract`/`validate` mechanisms to create full test scenarios. +- With `debugtalk.py` plugin, module functions can be auto-discovered in recursive upward directories. - Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. - Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. - Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. Send mail notification with [`jenkins-mail-py`][jenkins-mail-py]. @@ -38,7 +39,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.5.4 +ApiTestEngine version: 0.6.0 ``` Execute the command `ate -h` to view command help. @@ -74,7 +75,7 @@ To install mail helper, run this command in your terminal: $ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py $ ate -V jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.5.4 +ApiTestEngine version: 0.6.0 ``` With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. @@ -127,13 +128,11 @@ optional arguments: It is recommended to write testcases in `YAML` format. -And here is testset example of typical scenario: get token at the beginning, and each subsequent requests should take the token in the headers. +And here is testset example of typical scenario: get `token` at the beginning, and each subsequent requests should take the `token` in the headers. ```yaml - config: name: "create user testsets." - import_module_functions: - - tests.data.debugtalk variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} @@ -178,6 +177,8 @@ And here is testset example of typical scenario: get token at the beginning, and - {"check": "content.success", "comparator": "eq", "expected": true} ``` +Function invoke is supported in `YAML/JSON` format testcases, such as `gen_random_string` and `get_sign` above. This mechanism relies on the `debugtak.py` hot plugin, with which we can define functions in `debugtak.py` file, and then functions can be auto discovered and invoked in runtime. + For detailed regulations of writing testcases, you can read the [`QuickStart`][quickstart] documents. ## Run testcases diff --git a/docs/quickstart.md b/docs/quickstart.md index 376e6cae9..59f58cd27 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -152,11 +152,11 @@ Let's look back to our test set `quickstart-demo-rev-1.yml`, and we can see the In actual scenarios, each user's `device_sn` is different, so we should parameterize the request parameters, which is also called `parameterization`. In the meanwhile, the `sign` field is calculated with other header fields, thus it may change significantly if any header field changes slightly. -However, the test cases are only `YAML` documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine `Python` scripts with `YAML` test cases in `ApiTestEngine`. +However, the test cases are only `YAML` documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine `Python` scripts with `YAML/JSON` test cases in `ApiTestEngine`. -To achieve this goal, we can utilize `import_module_functions` and `variable_binds` mechanisms. +To achieve this goal, we can utilize `debugtalk.py` plugin and `variable_binds` mechanisms. -To be specific, we can create a Python file (`examples/utils.py`) and implement the related algorithm in it. Since we want to import this file, so we should put a `__init__.py` in this folder to make it as a Python module. +To be specific, we can create a Python file (`examples/debugtalk.py`) and implement the related algorithm in it. The `debugtalk.py` file can not only be located beside `YAML/JSON` testset file, but also can be in any upward recursive folder. Since we want `debugtalk.py` to be importable, we should put a `__init__.py` in its folder to make it as a Python module. ```python import hashlib @@ -187,8 +187,6 @@ And then, we can revise our demo test case and reference the functions. Suppose ```yaml - test: name: get token - import_module_functions: - - examples.utils variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} @@ -226,9 +224,9 @@ And then, we can revise our demo test case and reference the functions. Suppose - {"check": "content.success", "comparator": "eq", "expected": true} ``` -In this revised test case, we firstly import module functions in `import_module_functions` block by specifying the Python module path, which is relative to the current working directory. +In this revised test case, `variable reference` and `function invoke` mechanisms are both used. -To make fields like `device_sn` can be used more than once, we also bind values to variables in `variable_binds` block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. +To make fields like `device_sn` can be used more than once, we bind values to variables in `variable_binds` block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. When we want to reference a variable in the test case, we can do this with a escape character `$`. For example, `$user_agent` will not be taken as a normal string, and `ApiTestEngine` will consider it as a variable named `user_agent`, search and return its binding value. @@ -246,8 +244,6 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If # examples/quickstart-demo-rev-3.yml - config: name: "smoketest for CRUD users." - import_module_functions: - - examples.utils variable_binds: - device_sn: ${gen_random_string(15)} request: @@ -291,7 +287,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If - {"check": "content.success", "comparator": "eq", "expected": true} ``` -As you see, we import public `Python` modules and variables in `config` block. Also, we can set `base_url` in `config` block, thereby we can only specify relative path in each API request url. Besides, we can also set common fields in `config` `request`, such as `device_sn` in headers. +As you see, we define variables in `config` block. Also, we can set `base_url` in `config` block, thereby we can specify relative path in each API request url. Besides, we can also set common fields in `config` `request`, such as `device_sn` in headers. Until now, the test cases are finished and each detail is handled properly. diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index 4380e0473..100bf9426 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -1,7 +1,5 @@ - test: name: get token - import_module_functions: - - examples.debugtalk variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index 3b3f81d08..30064ed55 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -1,7 +1,5 @@ - config: name: "smoketest for CRUD users." - import_module_functions: - - examples.debugtalk variable_binds: - device_sn: ${gen_random_string(15)} request: From 9e3b9c47cd64e0604e4ed5130fc627b51e8db4f7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 1 Sep 2017 15:18:34 +0800 Subject: [PATCH 227/354] search and filter module variables --- ate/context.py | 2 +- ate/exception.py | 3 +++ ate/testcase.py | 20 ++++++++++++------- ate/utils.py | 47 +++++++++++++++++++++++++++++++++------------ tests/test_utils.py | 29 +++++++++++++++++++++------- 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/ate/context.py b/ate/context.py index fd5394b87..e9b86f735 100644 --- a/ate/context.py +++ b/ate/context.py @@ -70,7 +70,7 @@ def import_module_functions(self, modules, level="testcase"): sys.path.insert(0, os.getcwd()) for module_name in modules: imported_module = utils.get_imported_module(module_name) - imported_functions_dict = utils.filter_module_functions(imported_module) + imported_functions_dict = utils.filter_module(imported_module, "function") self.__update_context_functions_config(level, imported_functions_dict) def bind_variables(self, variable_binds, level="testcase"): diff --git a/ate/exception.py b/ate/exception.py index 92df58877..fa1cc24f3 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -21,3 +21,6 @@ class ValidationError(MyBaseError): class FunctionNotFound(NameError): pass + +class VariableNotFound(NameError): + pass diff --git a/ate/testcase.py b/ate/testcase.py index 2c48e8b39..2ed5060aa 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -150,17 +150,23 @@ def bind_functions(self, functions_binds): """ self.functions_binds = functions_binds - def get_bind_fuctions(self, func_name): - func = self.functions_binds.get(func_name) - if func: - return func + def get_bind_item(self, item_type, item_name): + if item_type == "function": + item = self.functions_binds.get(item_name) + elif item_type == "variable": + item = self.variables_binds.get(item_name) + else: + raise exception.ParamsError("bind item should only be function or variable.") + + if item: + return item try: assert self.file_path is not None - return utils.search_conf_function(self.file_path, func_name) + return utils.search_conf_item(self.file_path, item_type, item_name) except (AssertionError, exception.FunctionNotFound): raise exception.ParamsError( - "%s is not defined in bind functions!" % func_name) + "{} is not defined in bind {}s!".format(item_name, item_type)) def eval_content_functions(self, content): functions_list = extract_functions(content) @@ -168,7 +174,7 @@ def eval_content_functions(self, content): function_meta = parse_function(func_content) func_name = function_meta['func_name'] - func = self.get_bind_fuctions(func_name) + func = self.get_bind_item("function", func_name) args = function_meta.get('args', []) kwargs = function_meta.get('kwargs', {}) diff --git a/ate/utils.py b/ate/utils.py index 4eb1eb504..2a5a339c6 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -256,6 +256,18 @@ def is_function(tup): name, item = tup return isinstance(item, types.FunctionType) +def is_variable(tup): + """ Takes (name, object) tuple, returns True if it is a variable. + """ + name, item = tup + if callable(item): + return False + + if name.startswith("__"): + return False + + return True + def get_imported_module(module_name): """ import module and return imported module """ @@ -274,29 +286,40 @@ def get_imported_module_from_file(file_path): return imported_module -def filter_module_functions(module): - """ filter functions from import module +def filter_module(module, filter_type): + """ filter functions or variables from import module + @params + module: imported module + filter_type: "function" or "variable" """ - module_functions_dict = dict(filter(is_function, vars(module).items())) + filter_type = is_function if filter_type == "function" else is_variable + module_functions_dict = dict(filter(filter_type, vars(module).items())) return module_functions_dict -def search_conf_function(start_path, func): - """ search expected function recursive upward +def search_conf_item(start_path, item_type, item_name): + """ search expected function or variable recursive upward + @param + start_path: search start path + item_type: "function" or "variable" + item_name: function name or variable name """ dir_path = os.path.dirname(os.path.abspath(start_path)) target_file = os.path.join(dir_path, "debugtalk.py") if os.path.isfile(target_file): imported_module = get_imported_module_from_file(target_file) - functions_dict = filter_module_functions(imported_module) - if func in functions_dict: - return functions_dict[func] + functions_dict = filter_module(imported_module, item_type) + if item_name in functions_dict: + return functions_dict[item_name] else: - return search_conf_function(dir_path, func) + return search_conf_item(dir_path, item_type, item_name) if dir_path == start_path: # system root path - err_msg = "{} not found in recursive upward path!".format(func) - raise exception.FunctionNotFound(err_msg) + err_msg = "{} not found in recursive upward path!".format(item_name) + if item_type == "function": + raise exception.FunctionNotFound(err_msg) + else: + raise exception.VariableNotFound(err_msg) - return search_conf_function(dir_path, func) + return search_conf_item(dir_path, item_type, item_name) diff --git a/tests/test_utils.py b/tests/test_utils.py index 697e75c97..655338d1b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -234,15 +234,15 @@ def test_filter_module_functions(self): imported_module = utils.get_imported_module("ate.utils") self.assertIn("PYTHON_VERSION", dir(imported_module)) - functions_dict = utils.filter_module_functions(imported_module) - self.assertIn("filter_module_functions", functions_dict) + functions_dict = utils.filter_module(imported_module, "function") + self.assertIn("filter_module", functions_dict) self.assertNotIn("PYTHON_VERSION", functions_dict) def test_get_imported_module_from_file(self): imported_module = utils.get_imported_module_from_file("tests/data/debugtalk.py") self.assertIn("gen_md5", dir(imported_module)) - functions_dict = utils.filter_module_functions(imported_module) + functions_dict = utils.filter_module(imported_module, "function") self.assertIn("gen_md5", functions_dict) self.assertNotIn("PYTHON_VERSION", functions_dict) @@ -250,16 +250,31 @@ def test_get_imported_module_from_file(self): utils.get_imported_module_from_file("tests/data/debugtalk2.py") def test_search_conf_function(self): - gen_md5 = utils.search_conf_function("tests/data/demo_binds.yml", "gen_md5") + gen_md5 = utils.search_conf_item("tests/data/demo_binds.yml", "function", "gen_md5") self.assertTrue(utils.is_function(("gen_md5", gen_md5))) self.assertEqual(gen_md5("abc"), "900150983cd24fb0d6963f7d28e17f72") - gen_md5 = utils.search_conf_function("tests/data/subfolder/test.yml", "gen_md5") + gen_md5 = utils.search_conf_item("tests/data/subfolder/test.yml", "function", "gen_md5") self.assertTrue(utils.is_function(("_", gen_md5))) self.assertEqual(gen_md5("abc"), "900150983cd24fb0d6963f7d28e17f72") with self.assertRaises(exception.FunctionNotFound): - utils.search_conf_function("tests/data/subfolder/test.yml", "func_not_exist") + utils.search_conf_item("tests/data/subfolder/test.yml", "function", "func_not_exist") with self.assertRaises(exception.FunctionNotFound): - utils.search_conf_function("/user/local/bin", "gen_md5") + utils.search_conf_item("/user/local/bin", "function", "gen_md5") + + def test_search_conf_variable(self): + SECRET_KEY = utils.search_conf_item("tests/data/demo_binds.yml", "variable", "SECRET_KEY") + self.assertTrue(utils.is_variable(("SECRET_KEY", SECRET_KEY))) + self.assertEqual(SECRET_KEY, "DebugTalk") + + SECRET_KEY = utils.search_conf_item("tests/data/subfolder/test.yml", "variable", "SECRET_KEY") + self.assertTrue(utils.is_variable(("SECRET_KEY", SECRET_KEY))) + self.assertEqual(SECRET_KEY, "DebugTalk") + + with self.assertRaises(exception.VariableNotFound): + utils.search_conf_item("tests/data/subfolder/test.yml", "variable", "variable_not_exist") + + with self.assertRaises(exception.VariableNotFound): + utils.search_conf_item("/user/local/bin", "variable", "SECRET_KEY") From c8543e51eac2d760a6b0f0feba91ed844d565c3a Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 1 Sep 2017 19:01:34 +0800 Subject: [PATCH 228/354] bugfix: imported module should not be considered as variable --- ate/utils.py | 3 +++ tests/test_utils.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index 2a5a339c6..86d22bf8c 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -263,6 +263,9 @@ def is_variable(tup): if callable(item): return False + if isinstance(item, types.ModuleType): + return False + if name.startswith("__"): return False diff --git a/tests/test_utils.py b/tests/test_utils.py index 655338d1b..4b6a7e43e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -278,3 +278,18 @@ def test_search_conf_variable(self): with self.assertRaises(exception.VariableNotFound): utils.search_conf_item("/user/local/bin", "variable", "SECRET_KEY") + + def test_is_variable(self): + var1 = 123 + var2 = "abc" + self.assertTrue(utils.is_variable(("var1", var1))) + self.assertTrue(utils.is_variable(("var2", var2))) + + __var = 123 + self.assertFalse(utils.is_variable(("__var", __var))) + + func = lambda x: x + 1 + self.assertFalse(utils.is_variable(("func", func))) + + self.assertFalse(utils.is_variable(("os", os))) + self.assertFalse(utils.is_variable(("utils", utils))) From 1013971cf9ad724792d4f57a73d2aa32de38c802 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 4 Sep 2017 11:18:53 +0800 Subject: [PATCH 229/354] hot plugin support: search variables recursive upward from testset file --- ate/__init__.py | 2 +- ate/context.py | 8 ++++++-- ate/runner.py | 8 +++++--- tests/data/demo_binds.yml | 2 +- .../demo_testset_template_import_functions.yml | 2 +- tests/test_context.py | 18 +++++++++++------- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 83e147c62..3966a5f15 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.6.0' \ No newline at end of file +__version__ = '0.6.1' \ No newline at end of file diff --git a/ate/context.py b/ate/context.py index e9b86f735..492148980 100644 --- a/ate/context.py +++ b/ate/context.py @@ -39,7 +39,7 @@ def init_context(self, level='testset'): self.testcase_parser.bind_variables(self.testcase_variables_mapping) if level == "testset": - self.import_module_functions(["ate.built_in"], "testset") + self.import_module_items(["ate.built_in"], "testset") def import_requires(self, modules): """ import required modules dynamicly @@ -64,7 +64,7 @@ def bind_functions(self, function_binds, level="testcase"): self.__update_context_functions_config(level, eval_function_binds) - def import_module_functions(self, modules, level="testcase"): + def import_module_items(self, modules, level="testcase"): """ import modules and bind all functions within the context """ sys.path.insert(0, os.getcwd()) @@ -73,6 +73,10 @@ def import_module_functions(self, modules, level="testcase"): imported_functions_dict = utils.filter_module(imported_module, "function") self.__update_context_functions_config(level, imported_functions_dict) + imported_variables_dict = utils.filter_module(imported_module, "variable") + variable_binds = [{key: value} for key, value in imported_variables_dict.items()] + self.bind_variables(variable_binds, level) + def bind_variables(self, variable_binds, level="testcase"): """ bind variables to testset context or current testcase context. variables in testset context can be used in all testcases of current test suite. diff --git a/ate/runner.py b/ate/runner.py index 012fb9f04..ae6447374 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -25,7 +25,7 @@ def init_config(self, config_dict, level): "lambda *str_args: hashlib.md5(''.join(str_args).\ encode('utf-8')).hexdigest()" }, - "import_module_functions": ["test.data.debugtalk"], + "import_module_items": ["test.data.debugtalk"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, @@ -41,8 +41,10 @@ def init_config(self, config_dict, level): function_binds = config_dict.get('function_binds', {}) self.context.bind_functions(function_binds, level) - module_functions = config_dict.get('import_module_functions', []) - self.context.import_module_functions(module_functions, level) + # import_module_functions will be deprecated soon + module_items = config_dict.get('import_module_items', []) \ + or config_dict.get('import_module_functions', []) + self.context.import_module_items(module_items, level) variable_binds = config_dict.get('variable_binds', []) self.context.bind_variables(variable_binds, level) diff --git a/tests/data/demo_binds.yml b/tests/data/demo_binds.yml index 5a1a70d93..18135cc7f 100644 --- a/tests/data/demo_binds.yml +++ b/tests/data/demo_binds.yml @@ -27,7 +27,7 @@ bind_lambda_functions_with_import: bind_module_functions: function_binds: - import_module_functions: + import_module_items: - tests.data.debugtalk variable_binds: - TOKEN: debugtalk diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index 702c1df98..bfb789bcd 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -1,6 +1,6 @@ - config: name: "create user testsets." - import_module_functions: + import_module_items: - tests.data.debugtalk variable_binds: - user_agent: 'iOS/10.3' diff --git a/tests/test_context.py b/tests/test_context.py index 96609cac4..62b7565f1 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -140,9 +140,9 @@ def test_context_bind_lambda_functions_with_import(self): authorization = context_variables["authorization"] self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) - def test_import_module_functions(self): + def test_import_module_items(self): testcase1 = { - "import_module_functions": ["tests.data.debugtalk"], + "import_module_items": ["tests.data.debugtalk"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, @@ -153,8 +153,8 @@ def test_import_module_functions(self): testcase2 = self.testcases["bind_module_functions"] for testcase in [testcase1, testcase2]: - module_functions = testcase.get('import_module_functions', []) - self.context.import_module_functions(module_functions) + module_items = testcase.get('import_module_items', []) + self.context.import_module_items(module_items) variable_binds = testcase['variable_binds'] self.context.bind_variables(variable_binds) @@ -173,6 +173,9 @@ def test_import_module_functions(self): self.assertEqual(len(context_variables["authorization"]), 32) authorization = context_variables["authorization"] self.assertEqual(utils.gen_md5(TOKEN, data, random), authorization) + self.assertIn("SECRET_KEY", context_variables) + SECRET_KEY = context_variables["SECRET_KEY"] + self.assertEqual(SECRET_KEY, "DebugTalk") def test_register_request(self): request_dict = { @@ -195,11 +198,10 @@ def test_register_request(self): with self.assertRaises(ParamsError): self.context.register_request(request_dict) - def test_get_parsed_request(self): test_runner = runner.Runner() testcase = { - "import_module_functions": ["tests.data.debugtalk"], + "import_module_items": ["tests.data.debugtalk"], "variable_binds": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, @@ -212,7 +214,8 @@ def test_get_parsed_request(self): "headers": { "Content-Type": "application/json", "authorization": "$authorization", - "random": "$random" + "random": "$random", + "SECRET_KEY": "$SECRET_KEY" }, "data": "$data" } @@ -225,3 +228,4 @@ def test_get_parsed_request(self): self.assertEqual(len(parsed_request["headers"]["random"]), 5) self.assertIn("data", parsed_request) self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) + self.assertEqual(parsed_request["headers"]["secret_key"], "DebugTalk") From cdae396fd22d62f052ac6035261990474f66eacd Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 4 Sep 2017 11:35:02 +0800 Subject: [PATCH 230/354] bugfix: private property should be _single_leading_underscore --- ate/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ate/utils.py b/ate/utils.py index 86d22bf8c..1a21921aa 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -261,12 +261,15 @@ def is_variable(tup): """ name, item = tup if callable(item): + # function or class return False if isinstance(item, types.ModuleType): + # imported module return False - if name.startswith("__"): + if name.startswith("_"): + # private property return False return True From 27e796f8106457bf55b90c945b8e16b281cf4927 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 4 Sep 2017 22:04:57 +0800 Subject: [PATCH 231/354] bugfix: extract_binds should be list --- ate/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index ae6447374..9e8b7b6de 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -78,7 +78,7 @@ def run_test(self, testcase): }, "body": '{"name": "user", "password": "123456"}' }, - "extract_binds": {}, # optional + "extract_binds": [], # optional "validators": [] # optional } @return True or raise exception during test @@ -93,7 +93,7 @@ def run_test(self, testcase): raise exception.ParamsError("URL or METHOD missed!") run_times = int(testcase.get("times", 1)) - extract_binds = testcase.get("extract_binds", {}) + extract_binds = testcase.get("extract_binds", []) validators = testcase.get("validators", []) for _ in range(run_times): From eb4dcb64a3f8ff27590f8550d17b7fdccd883008 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 4 Sep 2017 22:58:40 +0800 Subject: [PATCH 232/354] add setup and teardown block, in these blocks, we can exec functions such as sleep --- ate/__init__.py | 2 +- ate/built_in.py | 5 +++++ ate/context.py | 7 ++++++- ate/runner.py | 14 +++++++++++++- tests/test_context.py | 12 +++++++++++- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 3966a5f15..39cae2159 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.6.1' \ No newline at end of file +__version__ = '0.6.2' \ No newline at end of file diff --git a/ate/built_in.py b/ate/built_in.py index ffc1867d8..cf2726094 100644 --- a/ate/built_in.py +++ b/ate/built_in.py @@ -28,3 +28,8 @@ def get_current_date(fmt="%Y-%m-%d"): """ get current date, default format is %Y-%m-%d """ return datetime.datetime.now().strftime(fmt) + +def sleep(sec): + """ sleep specified seconds + """ + time.sleep(sec) diff --git a/ate/context.py b/ate/context.py index 492148980..071fa83c2 100644 --- a/ate/context.py +++ b/ate/context.py @@ -138,7 +138,7 @@ def __update_context_request_config(self, level, config_mapping): ) def get_parsed_request(self): - """ get parsed request, with each variable replaced by bind value. + """ get parsed request, with bind variables and functions. """ parsed_request = self.testcase_parser.parse_content_with_bindings( self.testcase_request_config @@ -148,3 +148,8 @@ def get_parsed_request(self): def get_testcase_variables_mapping(self): return self.testcase_variables_mapping + + def exec_content_functions(self, content): + """ execute functions in content. + """ + self.testcase_parser.eval_content_functions(content) diff --git a/ate/runner.py b/ate/runner.py index 9e8b7b6de..859487433 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -79,7 +79,9 @@ def run_test(self, testcase): "body": '{"name": "user", "password": "123456"}' }, "extract_binds": [], # optional - "validators": [] # optional + "validators": [], # optional + "setup": [], # optional + "teardown": [] # optional } @return True or raise exception during test """ @@ -95,8 +97,16 @@ def run_test(self, testcase): run_times = int(testcase.get("times", 1)) extract_binds = testcase.get("extract_binds", []) validators = testcase.get("validators", []) + setup_actions = testcase.get("setup", []) + teardown_actions = testcase.get("teardown", []) + + def setup_teardown(actions): + for action in actions: + self.context.exec_content_functions(action) for _ in range(run_times): + setup_teardown(setup_actions) + resp = self.http_client_session.request(url=url, method=method, **parsed_request) resp_obj = response.ResponseObject(resp) @@ -105,6 +115,8 @@ def run_test(self, testcase): resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) + setup_teardown(teardown_actions) + return True def run_testset(self, testset): diff --git a/tests/test_context.py b/tests/test_context.py index 62b7565f1..369d30c3d 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,7 +1,8 @@ import os +import time import unittest -from ate import utils, runner +from ate import runner, utils from ate.context import Context from ate.exception import ParamsError @@ -229,3 +230,12 @@ def test_get_parsed_request(self): self.assertIn("data", parsed_request) self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) self.assertEqual(parsed_request["headers"]["secret_key"], "DebugTalk") + + def test_exec_content_functions(self): + test_runner = runner.Runner() + content = "${sleep(1)}" + start_time = time.time() + test_runner.context.exec_content_functions(content) + end_time = time.time() + elapsed_time = end_time - start_time + self.assertGreater(elapsed_time, 1) From e226724d7470d0acc9bd24d1d86fc03f27a9168d Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 7 Sep 2017 16:47:04 +0800 Subject: [PATCH 233/354] fix variable name --- ate/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 1a21921aa..18c6cce38 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -314,9 +314,9 @@ def search_conf_item(start_path, item_type, item_name): if os.path.isfile(target_file): imported_module = get_imported_module_from_file(target_file) - functions_dict = filter_module(imported_module, item_type) - if item_name in functions_dict: - return functions_dict[item_name] + items_dict = filter_module(imported_module, item_type) + if item_name in items_dict: + return items_dict[item_name] else: return search_conf_item(dir_path, item_type, item_name) From 2b95289bea4928c8570e6cb6e8c5a9a9defdb609 Mon Sep 17 00:00:00 2001 From: luowentao Date: Mon, 11 Sep 2017 20:44:57 +0800 Subject: [PATCH 234/354] refactor: fix typos --- ate/context.py | 2 +- ate/task.py | 2 +- ate/utils.py | 4 ++-- tests/test_testcase.py | 2 +- tests/test_utils.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ate/context.py b/ate/context.py index 071fa83c2..5214c0bcc 100644 --- a/ate/context.py +++ b/ate/context.py @@ -42,7 +42,7 @@ def init_context(self, level='testset'): self.import_module_items(["ate.built_in"], "testset") def import_requires(self, modules): - """ import required modules dynamicly + """ import required modules dynamically """ for module_name in modules: globals()[module_name] = utils.get_imported_module(module_name) diff --git a/ate/task.py b/ate/task.py index a85f08b9a..5ae997abc 100644 --- a/ate/task.py +++ b/ate/task.py @@ -18,7 +18,7 @@ def runTest(self): def create_suite(testset): """ create test suite with a testset, it may include one or several testcases. - each suite should initialize a seperate Runner() with testset config. + each suite should initialize a separate Runner() with testset config. """ suite = unittest.TestSuite() diff --git a/ate/utils.py b/ate/utils.py index 1a21921aa..a760f4439 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -57,7 +57,7 @@ def load_testcases(testcase_file_path): # '' or other suffix return [] -def load_foler_files(folder_path, match_filter_list=["*"]): +def load_folder_files(folder_path, match_filter_list=["*"]): """ load folder path, return all files in list format. """ file_list = [] @@ -99,7 +99,7 @@ def load_testcases_by_path(path): path = os.path.join(os.getcwd(), path) if os.path.isdir(path): - files_list = load_foler_files(path, ["*.yml", "*.yaml", "*.json"]) + files_list = load_folder_files(path, ["*.yml", "*.yaml", "*.json"]) return load_testcases_by_path(files_list) elif os.path.isfile(path): diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 8626e969f..1bebfe10e 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -118,7 +118,7 @@ def test_parse_string_value(self): self.assertEqual(testcase.parse_string_value("$var"), "$var") self.assertEqual(testcase.parse_string_value("${func}"), "${func}") - def test_parse_functon(self): + def test_parse_function(self): self.assertEqual( testcase.parse_function("${func()}"), {'func_name': 'func', 'args': [], 'kwargs': {}} diff --git a/tests/test_utils.py b/tests/test_utils.py index 4b6a7e43e..51babe001 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -31,12 +31,12 @@ def test_load_yaml_testcases(self): self.assertIn('url', testcase['request']) self.assertIn('method', testcase['request']) - def test_load_foler_files(self): + def test_load_folder_files(self): folder = os.path.join(os.getcwd(), 'tests') file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') file2 = os.path.join(os.getcwd(), 'tests', 'data', 'demo_binds.yml') - files = utils.load_foler_files(folder, ["*.py"]) + files = utils.load_folder_files(folder, ["*.py"]) self.assertIn(file1, files) self.assertNotIn(file2, files) From 701baee3b648309fc14a44d87673a0e406d24ae8 Mon Sep 17 00:00:00 2001 From: luowentao Date: Tue, 12 Sep 2017 15:18:51 +0800 Subject: [PATCH 235/354] fix: Absolute file path in *Nix system start with slash. The heading slash should not be striped in this case. --- ate/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/cli.py b/ate/cli.py index 4c37a5461..d851b76a2 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -57,7 +57,7 @@ def main_ate(): for testset_path in set(args.testset_paths): - testset_path = testset_path.strip('/') + testset_path = testset_path.rstrip('/') task_suite = create_task(testset_path) output_folder_name = os.path.basename(os.path.splitext(testset_path)[0]) From 78b1dec782c53105cdaf92303bb64ef33dc3c22a Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Sep 2017 16:56:22 +0800 Subject: [PATCH 236/354] search variables in debugtalk.py recursively upward --- ate/__init__.py | 2 +- ate/testcase.py | 67 ++++++++++++++++++++---------------------- tests/test_testcase.py | 42 ++++++++++++++------------ 3 files changed, 57 insertions(+), 54 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 39cae2159..de5901e2a 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.6.2' \ No newline at end of file +__version__ = '0.6.3' \ No newline at end of file diff --git a/ate/testcase.py b/ate/testcase.py index 2ed5060aa..8cd0458b7 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -87,40 +87,6 @@ def parse_function(content): return function_meta -def eval_content_variables(content, variable_mapping): - """ replace all variables of string content with mapping value. - @param (str) content - @return (str) parsed content - - e.g. - variable_mapping = { - "var_1": "abc", - "var_2": "def" - } - $var_1 => "abc" - $var_1#XYZ => "abc#XYZ" - /$var_1/$var_2/var3 => "/abc/def/var3" - ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" - """ - variables_list = extract_variables(content) - for variable_name in variables_list: - if variable_name not in variable_mapping: - raise exception.ParamsError( - "%s is not defined in bind variables!" % variable_name) - - variable_value = variable_mapping.get(variable_name) - if "${}".format(variable_name) == content: - # content is a variable - content = variable_value - else: - # content contains one or many variables - content = content.replace( - "${}".format(variable_name), - str(variable_value), 1 - ) - - return content - class TestcaseParser(object): @@ -194,6 +160,37 @@ def eval_content_functions(self, content): return content + def eval_content_variables(self, content): + """ replace all variables of string content with mapping value. + @param (str) content + @return (str) parsed content + + e.g. + variable_mapping = { + "var_1": "abc", + "var_2": "def" + } + $var_1 => "abc" + $var_1#XYZ => "abc#XYZ" + /$var_1/$var_2/var3 => "/abc/def/var3" + ${func($var_1, $var_2, xyz)} => "${func(abc, def, xyz)}" + """ + variables_list = extract_variables(content) + for variable_name in variables_list: + variable_value = self.get_bind_item("variable", variable_name) + + if "${}".format(variable_name) == content: + # content is a variable + content = variable_value + else: + # content contains one or many variables + content = content.replace( + "${}".format(variable_name), + str(variable_value), 1 + ) + + return content + def parse_content_with_bindings(self, content): """ parse content recursively, each variable and function in content will be evaluated. @@ -249,6 +246,6 @@ def parse_content_with_bindings(self, content): content = self.eval_content_functions(content) # replace variables with binding value - content = eval_content_variables(content, self.variables_binds) + content = self.eval_content_variables(content) return content diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 1bebfe10e..7cd279375 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -54,62 +54,68 @@ def test_extract_variables(self): ) def test_eval_content_variables(self): - variable_mapping = { + variable_binds = { "var_1": "abc", "var_2": "def", "var_3": 123, "var_4": {"a": 1}, - "var_5": True, - "var_6": None + "var_5": True } + testcase_parser = testcase.TestcaseParser(variables_binds=variable_binds) self.assertEqual( - testcase.eval_content_variables("$var_1", variable_mapping), + testcase_parser.eval_content_variables("$var_1"), "abc" ) self.assertEqual( - testcase.eval_content_variables("var_1", variable_mapping), + testcase_parser.eval_content_variables("var_1"), "var_1" ) self.assertEqual( - testcase.eval_content_variables("$var_1#XYZ", variable_mapping), + testcase_parser.eval_content_variables("$var_1#XYZ"), "abc#XYZ" ) self.assertEqual( - testcase.eval_content_variables("/$var_1/$var_2/var3", variable_mapping), + testcase_parser.eval_content_variables("/$var_1/$var_2/var3"), "/abc/def/var3" ) self.assertEqual( - testcase.eval_content_variables("/$var_1/$var_2/$var_1", variable_mapping), + testcase_parser.eval_content_variables("/$var_1/$var_2/$var_1"), "/abc/def/abc" ) self.assertEqual( - testcase.eval_content_variables("${func($var_1, $var_2, xyz)}", variable_mapping), + testcase_parser.eval_content_variables("${func($var_1, $var_2, xyz)}"), "${func(abc, def, xyz)}" ) self.assertEqual( - testcase.eval_content_variables("$var_3", variable_mapping), + testcase_parser.eval_content_variables("$var_3"), 123 ) self.assertEqual( - testcase.eval_content_variables("$var_4", variable_mapping), + testcase_parser.eval_content_variables("$var_4"), {"a": 1} ) self.assertEqual( - testcase.eval_content_variables("$var_5", variable_mapping), + testcase_parser.eval_content_variables("$var_5"), True ) self.assertEqual( - testcase.eval_content_variables("abc$var_5", variable_mapping), + testcase_parser.eval_content_variables("abc$var_5"), "abcTrue" ) self.assertEqual( - testcase.eval_content_variables("abc$var_4", variable_mapping), + testcase_parser.eval_content_variables("abc$var_4"), "abc{'a': 1}" ) - self.assertEqual( - testcase.eval_content_variables("$var_6", variable_mapping), - None - ) + + def test_eval_content_variables_search_upward(self): + testcase_parser = testcase.TestcaseParser() + + with self.assertRaises(ParamsError): + testcase_parser.eval_content_variables("/api/$SECRET_KEY") + + testcase_parser.file_path = "tests/data/demo_testset_hardcode.yml" + content = testcase_parser.eval_content_variables("/api/$SECRET_KEY") + self.assertEqual(content, "/api/DebugTalk") def test_parse_string_value(self): self.assertEqual(testcase.parse_string_value("123"), 123) From 09d58818e753b916d6ca99105abcaf5bf34dffab Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Sep 2017 17:09:53 +0800 Subject: [PATCH 237/354] bugfix: when binding variable is None, it should be None other than raise ParamsError" --- ate/testcase.py | 9 ++++----- tests/test_testcase.py | 7 ++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 8cd0458b7..d192e45b8 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -118,15 +118,14 @@ def bind_functions(self, functions_binds): def get_bind_item(self, item_type, item_name): if item_type == "function": - item = self.functions_binds.get(item_name) + if item_name in self.functions_binds: + return self.functions_binds[item_name] elif item_type == "variable": - item = self.variables_binds.get(item_name) + if item_name in self.variables_binds: + return self.variables_binds[item_name] else: raise exception.ParamsError("bind item should only be function or variable.") - if item: - return item - try: assert self.file_path is not None return utils.search_conf_item(self.file_path, item_type, item_name) diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 7cd279375..9cdee695b 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -59,7 +59,8 @@ def test_eval_content_variables(self): "var_2": "def", "var_3": 123, "var_4": {"a": 1}, - "var_5": True + "var_5": True, + "var_6": None } testcase_parser = testcase.TestcaseParser(variables_binds=variable_binds) self.assertEqual( @@ -106,6 +107,10 @@ def test_eval_content_variables(self): testcase_parser.eval_content_variables("abc$var_4"), "abc{'a': 1}" ) + self.assertEqual( + testcase_parser.eval_content_variables("$var_6"), + None + ) def test_eval_content_variables_search_upward(self): testcase_parser = testcase.TestcaseParser() From 5e89c279cd707ba1904a98cd0f76b27988d27e5d Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Sep 2017 19:34:37 +0800 Subject: [PATCH 238/354] refactor: adjust code location --- ate/context.py | 18 ++++++++++++ ate/runner.py | 74 ++++++++++++++++++++++++-------------------------- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/ate/context.py b/ate/context.py index 5214c0bcc..e658e3e3d 100644 --- a/ate/context.py +++ b/ate/context.py @@ -41,6 +41,24 @@ def init_context(self, level='testset'): if level == "testset": self.import_module_items(["ate.built_in"], "testset") + def config_context(self, config_dict, level): + if level == "testset": + self.testcase_parser.file_path = config_dict.get("path", None) + + requires = config_dict.get('requires', []) + self.import_requires(requires) + + function_binds = config_dict.get('function_binds', {}) + self.bind_functions(function_binds, level) + + # import_module_functions will be deprecated soon + module_items = config_dict.get('import_module_items', []) \ + or config_dict.get('import_module_functions', []) + self.import_module_items(module_items, level) + + variable_binds = config_dict.get('variable_binds', []) + self.bind_variables(variable_binds, level) + def import_requires(self, modules): """ import required modules dynamically """ diff --git a/ate/runner.py b/ate/runner.py index 859487433..1caeb287e 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,5 +1,3 @@ -import requests - from ate import exception, response from ate.client import HttpSession from ate.context import Context @@ -14,49 +12,49 @@ def __init__(self, http_client_session=None): def init_config(self, config_dict, level): """ create/update context variables binds @param (dict) config_dict + @param (str) level, "testset" or "testcase" + testset: { - "name": "description content", - "requires": ["random", "hashlib"], - "function_binds": { - "gen_random_string": \ - "lambda str_len: ''.join(random.choice(string.ascii_letters + \ - string.digits) for _ in range(str_len))", - "gen_md5": \ - "lambda *str_args: hashlib.md5(''.join(str_args).\ - encode('utf-8')).hexdigest()" - }, - "import_module_items": ["test.data.debugtalk"], - "variable_binds": [ - {"TOKEN": "debugtalk"}, - {"random": "${gen_random_string(5)}"}, - ] + "name": "smoke testset", + "path": "tests/data/demo_testset_variables.yml", + "requires": [], # optional + "function_binds": {}, # optional + "import_module_items": [], # optional + "variable_binds": [], # optional + "request": { + "base_url": "http://127.0.0.1:5000", + "headers": { + "User-Agent": "iOS/2.8.3" + } + } + } + testcase: + { + "name": "testcase description", + "requires": [], # optional + "function_binds": {}, # optional + "import_module_items": [], # optional + "variable_binds": [], # optional + "request": { + "url": "/api/get-token", + "method": "POST", + "headers": { + "Content-Type": "application/json" + } + + "json": { + "sign": "f1219719911caae89ccc301679857ebfda115ca2" + } } @param (str) context level, testcase or testset """ self.context.init_context(level) - - requires = config_dict.get('requires', []) - self.context.import_requires(requires) - - function_binds = config_dict.get('function_binds', {}) - self.context.bind_functions(function_binds, level) - - # import_module_functions will be deprecated soon - module_items = config_dict.get('import_module_items', []) \ - or config_dict.get('import_module_functions', []) - self.context.import_module_items(module_items, level) - - variable_binds = config_dict.get('variable_binds', []) - self.context.bind_variables(variable_binds, level) + self.context.config_context(config_dict, level) request_config = config_dict.get('request', {}) - if level == "testset": - base_url = request_config.pop("base_url", None) - self.http_client_session = self.http_client_session or HttpSession(base_url) - self.context.testcase_parser.file_path = config_dict.get("path", None) - else: - # testcase - self.http_client_session = self.http_client_session or requests.Session() + base_url = request_config.pop("base_url", None) + self.http_client_session = self.http_client_session or HttpSession(base_url) + self.context.register_request(request_config, level) def run_test(self, testcase): From 8d568d9e0d1c4fa5e059ded32b4c239e4e1f9343 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Sep 2017 22:32:52 +0800 Subject: [PATCH 239/354] request in config should also be parsed --- ate/context.py | 30 ++++++++----------- ate/runner.py | 11 +++---- tests/data/debugtalk.py | 1 + ...demo_testset_template_import_functions.yml | 2 +- ...demo_testset_template_lambda_functions.yml | 2 +- tests/data/demo_testset_variables.yml | 2 +- tests/test_context.py | 10 +++---- 7 files changed, 26 insertions(+), 32 deletions(-) diff --git a/ate/context.py b/ate/context.py index e658e3e3d..0b3fb5080 100644 --- a/ate/context.py +++ b/ate/context.py @@ -32,7 +32,6 @@ def init_context(self, level='testset'): # testcase config shall inherit from testset configs, # but can not change testset configs, that's why we use copy.deepcopy here. self.testcase_functions_config = copy.deepcopy(self.testset_functions_config) - self.testcase_request_config = {} self.testcase_variables_mapping = copy.deepcopy(self.testset_shared_variables_mapping) self.testcase_parser.bind_functions(self.testcase_functions_config) @@ -131,7 +130,11 @@ def __update_context_functions_config(self, level, config_mapping): self.testcase_functions_config.update(config_mapping) self.testcase_parser.bind_functions(self.testcase_functions_config) - def register_request(self, request_dict, level="testcase"): + def get_parsed_request(self, request_dict, level="testcase"): + """ get parsed request with bind variables and functions. + @param request_dict: request config mapping + @param level: testset or testcase + """ if "headers" in request_dict: # convert keys in request headers to lowercase headers = request_dict.pop("headers") @@ -139,27 +142,18 @@ def register_request(self, request_dict, level="testcase"): raise ParamsError("HTTP Request Headers invalid!") request_dict["headers"] = {key.lower(): headers[key] for key in headers} - self.__update_context_request_config(level, request_dict) - - def __update_context_request_config(self, level, config_mapping): - """ - @param level: testset or testcase - @param config_type: request - @param config_mapping: request config mapping - """ if level == "testset": - self.testset_request_config.update(config_mapping) + request_dict = self.testcase_parser.parse_content_with_bindings( + request_dict + ) + self.testset_request_config.update(request_dict) - self.testcase_request_config = utils.deep_update_dict( + testcase_request_config = utils.deep_update_dict( copy.deepcopy(self.testset_request_config), - config_mapping + request_dict ) - - def get_parsed_request(self): - """ get parsed request, with bind variables and functions. - """ parsed_request = self.testcase_parser.parse_content_with_bindings( - self.testcase_request_config + testcase_request_config ) return parsed_request diff --git a/ate/runner.py b/ate/runner.py index 1caeb287e..c24a29cf2 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -41,7 +41,7 @@ def init_config(self, config_dict, level): "headers": { "Content-Type": "application/json" } - + }, "json": { "sign": "f1219719911caae89ccc301679857ebfda115ca2" } @@ -52,10 +52,12 @@ def init_config(self, config_dict, level): self.context.config_context(config_dict, level) request_config = config_dict.get('request', {}) - base_url = request_config.pop("base_url", None) + parsed_request = self.context.get_parsed_request(request_config, level) + + base_url = parsed_request.pop("base_url", None) self.http_client_session = self.http_client_session or HttpSession(base_url) - self.context.register_request(request_config, level) + return parsed_request def run_test(self, testcase): """ run single testcase. @@ -83,8 +85,7 @@ def run_test(self, testcase): } @return True or raise exception during test """ - self.init_config(testcase, level="testcase") - parsed_request = self.context.get_parsed_request() + parsed_request = self.init_config(testcase, level="testcase") try: url = parsed_request.pop('url') diff --git a/tests/data/debugtalk.py b/tests/data/debugtalk.py index 19dd4ce81..491e41036 100644 --- a/tests/data/debugtalk.py +++ b/tests/data/debugtalk.py @@ -15,6 +15,7 @@ import urllib.parse as urllib SECRET_KEY = "DebugTalk" +BASE_URL = "http://127.0.0.1:5000" def get_sign(*args): content = ''.join(args).encode('ascii') diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index bfb789bcd..6531a497e 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -8,7 +8,7 @@ - os_platform: 'ios' - app_version: '2.8.6' request: - base_url: http://127.0.0.1:5000 + base_url: $BASE_URL headers: Content-Type: application/json device_sn: $device_sn diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index 7ea5c42eb..df2e98aab 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -18,7 +18,7 @@ - os_platform: 'ios' - app_version: '2.8.6' request: - base_url: http://127.0.0.1:5000 + base_url: $BASE_URL headers: Content-Type: application/json device_sn: $device_sn diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index fe77c4b12..7a62e6ab0 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -3,7 +3,7 @@ variable_binds: - device_sn: 'HZfFBh6tU59EdXJ' request: - base_url: http://127.0.0.1:5000 + base_url: $BASE_URL headers: Content-Type: application/json device_sn: $device_sn diff --git a/tests/test_context.py b/tests/test_context.py index 369d30c3d..0b40d6f5e 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -178,7 +178,7 @@ def test_import_module_items(self): SECRET_KEY = context_variables["SECRET_KEY"] self.assertEqual(SECRET_KEY, "DebugTalk") - def test_register_request(self): + def test_parse_request(self): request_dict = { "url": "http://debugtalk.com", "method": "GET", @@ -187,9 +187,8 @@ def test_register_request(self): "USER-AGENT": "ios/10.3" } } - self.context.register_request(request_dict) - parsed_request = self.context.get_parsed_request() + parsed_request = self.context.get_parsed_request(request_dict) self.assertIn("content-type", parsed_request["headers"]) self.assertIn("user-agent", parsed_request["headers"]) @@ -197,7 +196,7 @@ def test_register_request(self): "headers": "invalid headers" } with self.assertRaises(ParamsError): - self.context.register_request(request_dict) + self.context.get_parsed_request(request_dict) def test_get_parsed_request(self): test_runner = runner.Runner() @@ -221,8 +220,7 @@ def test_get_parsed_request(self): "data": "$data" } } - test_runner.init_config(testcase, level="testcase") - parsed_request = test_runner.context.get_parsed_request() + parsed_request = test_runner.init_config(testcase, level="testcase") self.assertIn("authorization", parsed_request["headers"]) self.assertEqual(len(parsed_request["headers"]["authorization"]), 32) self.assertIn("random", parsed_request["headers"]) From 93d4a78210204126a4f106a90751fc7a320819c1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 13 Sep 2017 16:17:21 +0800 Subject: [PATCH 240/354] lower testcase key --- ate/context.py | 8 -------- ate/runner.py | 5 ++++- ate/utils.py | 17 +++++++++++++++++ tests/test_context.py | 28 ++++------------------------ tests/test_utils.py | 20 ++++++++++++++++++++ 5 files changed, 45 insertions(+), 33 deletions(-) diff --git a/ate/context.py b/ate/context.py index 0b3fb5080..0a82cda7e 100644 --- a/ate/context.py +++ b/ate/context.py @@ -5,7 +5,6 @@ from collections import OrderedDict from ate import utils -from ate.exception import ParamsError from ate.testcase import TestcaseParser @@ -135,13 +134,6 @@ def get_parsed_request(self, request_dict, level="testcase"): @param request_dict: request config mapping @param level: testset or testcase """ - if "headers" in request_dict: - # convert keys in request headers to lowercase - headers = request_dict.pop("headers") - if not isinstance(headers, dict): - raise ParamsError("HTTP Request Headers invalid!") - request_dict["headers"] = {key.lower(): headers[key] for key in headers} - if level == "testset": request_dict = self.testcase_parser.parse_content_with_bindings( request_dict diff --git a/ate/runner.py b/ate/runner.py index c24a29cf2..43ba88929 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,4 +1,4 @@ -from ate import exception, response +from ate import exception, response, utils from ate.client import HttpSession from ate.context import Context @@ -48,6 +48,9 @@ def init_config(self, config_dict, level): } @param (str) context level, testcase or testset """ + # convert keys in request headers to lowercase + config_dict = utils.lower_dict_key(config_dict) + self.context.init_context(level) self.context.config_context(config_dict, level) diff --git a/ate/utils.py b/ate/utils.py index 02ad10586..74ef7be80 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -329,3 +329,20 @@ def search_conf_item(start_path, item_type, item_name): raise exception.VariableNotFound(err_msg) return search_conf_item(dir_path, item_type, item_name) + +def lower_dict_key(origin_dict, depth=1): + """ convert dict key to lower case, with depth control supported. + """ + new_dict = {} + + for key, value in origin_dict.items(): + if depth > 2: + new_dict[key] = value + continue + + if isinstance(value, dict): + value = lower_dict_key(value, depth+1) + + new_dict[key.lower()] = value + + return new_dict diff --git a/tests/test_context.py b/tests/test_context.py index 0b40d6f5e..ef276be4b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -178,26 +178,6 @@ def test_import_module_items(self): SECRET_KEY = context_variables["SECRET_KEY"] self.assertEqual(SECRET_KEY, "DebugTalk") - def test_parse_request(self): - request_dict = { - "url": "http://debugtalk.com", - "method": "GET", - "headers": { - "Content-Type": "application/json", - "USER-AGENT": "ios/10.3" - } - } - - parsed_request = self.context.get_parsed_request(request_dict) - self.assertIn("content-type", parsed_request["headers"]) - self.assertIn("user-agent", parsed_request["headers"]) - - request_dict = { - "headers": "invalid headers" - } - with self.assertRaises(ParamsError): - self.context.get_parsed_request(request_dict) - def test_get_parsed_request(self): test_runner = runner.Runner() testcase = { @@ -210,14 +190,14 @@ def test_get_parsed_request(self): ], "request": { "url": "http://127.0.0.1:5000/api/users/1000", - "method": "POST", - "headers": { + "METHOD": "POST", + "Headers": { "Content-Type": "application/json", "authorization": "$authorization", "random": "$random", "SECRET_KEY": "$SECRET_KEY" }, - "data": "$data" + "Data": "$data" } } parsed_request = test_runner.init_config(testcase, level="testcase") @@ -227,7 +207,7 @@ def test_get_parsed_request(self): self.assertEqual(len(parsed_request["headers"]["random"]), 5) self.assertIn("data", parsed_request) self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) - self.assertEqual(parsed_request["headers"]["secret_key"], "DebugTalk") + self.assertEqual(parsed_request["headers"]["SECRET_KEY"], "DebugTalk") def test_exec_content_functions(self): test_runner = runner.Runner() diff --git a/tests/test_utils.py b/tests/test_utils.py index 51babe001..92e871032 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -293,3 +293,23 @@ def test_is_variable(self): self.assertFalse(utils.is_variable(("os", os))) self.assertFalse(utils.is_variable(("utils", utils))) + + def test_lower_dict_key(self): + origin_dict = { + "Name": "test", + "Request": { + "url": "http://127.0.0.1:5000", + "METHOD": "POST", + "Headers": { + "Accept": "application/json", + "User-Agent": "ios/9.3" + } + } + } + new_dict = utils.lower_dict_key(origin_dict) + self.assertIn("name", new_dict) + self.assertIn("request", new_dict) + self.assertIn("method", new_dict["request"]) + self.assertIn("headers", new_dict["request"]) + self.assertIn("Accept", new_dict["request"]["headers"]) + self.assertIn("User-Agent", new_dict["request"]["headers"]) From f3c6617385bbdba6569971a24bf4c14160d9a7ac Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 14 Sep 2017 10:50:11 +0800 Subject: [PATCH 241/354] bugfix: HttpNtlmAuth should be lower case --- ate/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/client.py b/ate/client.py index a56c9fc94..de2685ca5 100644 --- a/ate/client.py +++ b/ate/client.py @@ -103,9 +103,9 @@ def request(self, method, url, **kwargs): request_meta["method"] = method request_meta["start_time"] = time.time() - if "HttpNtlmAuth" in kwargs: + if "httpntlmauth" in kwargs: from requests_ntlm import HttpNtlmAuth - auth_account = kwargs.pop("HttpNtlmAuth") + auth_account = kwargs.pop("httpntlmauth") kwargs["auth"] = HttpNtlmAuth( auth_account["username"], auth_account["password"]) From 62fbe7759fac07a727fcae1d0b1a26a752d62690 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 14 Sep 2017 20:54:19 +0800 Subject: [PATCH 242/354] strip from extracted function --- ate/testcase.py | 21 +++++++++++---------- tests/test_testcase.py | 28 ++++++++++++++-------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index d192e45b8..33a93967e 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -5,8 +5,8 @@ from ate import exception, utils variable_regexp = r"\$([\w_]+)" -function_regexp = r"\$\{[\w_]+\([\$\w_ =,]*\)\}" -function_regexp_compile = re.compile(r"^\$\{([\w_]+)\(([\$\w_ =,]*)\)\}$") +function_regexp = r"\$\{([\w_]+\([\$\w_ =,]*\))\}" +function_regexp_compile = re.compile(r"^([\w_]+)\(([\$\w_ =,]*)\)$") def extract_variables(content): @@ -29,11 +29,11 @@ def extract_functions(content): @param (str) content @return (list) functions list - e.g. ${func(5)} => ["${func(5)}"] - ${func(a=1, b=2)} => ["${func(a=1, b=2)}"] + e.g. ${func(5)} => ["func(5)"] + ${func(a=1, b=2)} => ["func(a=1, b=2)"] /api/1000?_t=${get_timestamp()} => ["get_timestamp()"] /api/${add(1, 2)} => ["add(1, 2)"] - "/api/${add(1, 2)}?_t=${get_timestamp()}" => ["${add(1, 2)}", "${get_timestamp()}"] + "/api/${add(1, 2)}?_t=${get_timestamp()}" => ["add(1, 2)", "get_timestamp()"] """ try: return re.findall(function_regexp, content) @@ -60,11 +60,11 @@ def parse_function(content): @param (str) content @return (dict) function name and args - e.g. ${func()} => {'func_name': 'func', 'args': [], 'kwargs': {}} - ${func(5)} => {'func_name': 'func', 'args': [5], 'kwargs': {}} - ${func(1, 2)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} - ${func(a=1, b=2)} => {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} - ${func(1, 2, a=3, b=4)} => {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4}} + e.g. func() => {'func_name': 'func', 'args': [], 'kwargs': {}} + func(5) => {'func_name': 'func', 'args': [5], 'kwargs': {}} + func(1, 2) => {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} + func(a=1, b=2) => {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} + func(1, 2, a=3, b=4) => {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4}} """ function_meta = { "args": [], @@ -147,6 +147,7 @@ def eval_content_functions(self, content): kwargs = self.parse_content_with_bindings(kwargs) eval_value = func(*args, **kwargs) + func_content = "${" + func_content + "}" if func_content == content: # content is a variable content = eval_value diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 9cdee695b..1f6af65b7 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -131,27 +131,27 @@ def test_parse_string_value(self): def test_parse_function(self): self.assertEqual( - testcase.parse_function("${func()}"), + testcase.parse_function("func()"), {'func_name': 'func', 'args': [], 'kwargs': {}} ) self.assertEqual( - testcase.parse_function("${func(5)}"), + testcase.parse_function("func(5)"), {'func_name': 'func', 'args': [5], 'kwargs': {}} ) self.assertEqual( - testcase.parse_function("${func(1, 2)}"), + testcase.parse_function("func(1, 2)"), {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} ) self.assertEqual( - testcase.parse_function("${func(a=1, b=2)}"), + testcase.parse_function("func(a=1, b=2)"), {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} ) self.assertEqual( - testcase.parse_function("${func(a= 1, b =2)}"), + testcase.parse_function("func(a= 1, b =2)"), {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} ) self.assertEqual( - testcase.parse_function("${func(1, 2, a=3, b=4)}"), + testcase.parse_function("func(1, 2, a=3, b=4)"), {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a': 3, 'b': 4}} ) @@ -232,35 +232,35 @@ def test_parse_content_with_bindings_functions(self): def test_extract_functions(self): self.assertEqual( testcase.extract_functions("${func()}"), - ["${func()}"] + ["func()"] ) self.assertEqual( testcase.extract_functions("${func(5)}"), - ["${func(5)}"] + ["func(5)"] ) self.assertEqual( testcase.extract_functions("${func(a=1, b=2)}"), - ["${func(a=1, b=2)}"] + ["func(a=1, b=2)"] ) self.assertEqual( testcase.extract_functions("${func(1, $b, c=$x, d=4)}"), - ["${func(1, $b, c=$x, d=4)}"] + ["func(1, $b, c=$x, d=4)"] ) self.assertEqual( testcase.extract_functions("/api/1000?_t=${get_timestamp()}"), - ["${get_timestamp()}"] + ["get_timestamp()"] ) self.assertEqual( testcase.extract_functions("/api/${add(1, 2)}"), - ["${add(1, 2)}"] + ["add(1, 2)"] ) self.assertEqual( testcase.extract_functions("/api/${add(1, 2)}?_t=${get_timestamp()}"), - ["${add(1, 2)}", "${get_timestamp()}"] + ["add(1, 2)", "get_timestamp()"] ) self.assertEqual( testcase.extract_functions("abc${func(1, 2, a=3, b=4)}def"), - ["${func(1, 2, a=3, b=4)}"] + ["func(1, 2, a=3, b=4)"] ) def test_eval_content_functions(self): From 999625635d8061da84a53fa1db5ff93aba11b6d2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 18 Sep 2017 11:45:12 +0800 Subject: [PATCH 243/354] refactor load_folder_files: change filter behavior --- ate/utils.py | 24 +++++++++++++++++++----- tests/test_utils.py | 10 ++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 74ef7be80..69ec09b53 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,5 +1,4 @@ import codecs -import fnmatch import hashlib import hmac import imp @@ -57,20 +56,35 @@ def load_testcases(testcase_file_path): # '' or other suffix return [] -def load_folder_files(folder_path, match_filter_list=["*"]): +def load_folder_files(folder_path, file_type, recursive=False): """ load folder path, return all files in list format. + @param + folder_path: specified folder path to load + file_type: "test" or "api" + recursive: if True, will load files recursively """ file_list = [] for dirpath, dirnames, filenames in os.walk(folder_path): filenames_list = [] - for match_filter in match_filter_list: - filenames_list.extend(fnmatch.filter(filenames, match_filter)) + + for filename in filenames: + + if not filename.endswith(('.yml', '.yaml', '.json')): + continue + + if file_type == "api" and not filename.startswith(('api.', 'api-')): + continue + + filenames_list.append(filename) for filename in filenames_list: file_path = os.path.join(dirpath, filename) file_list.append(file_path) + if not recursive: + break + return file_list def load_testcases_by_path(path): @@ -99,7 +113,7 @@ def load_testcases_by_path(path): path = os.path.join(os.getcwd(), path) if os.path.isdir(path): - files_list = load_folder_files(path, ["*.yml", "*.yaml", "*.json"]) + files_list = load_folder_files(path, file_type="test", recursive=True) return load_testcases_by_path(files_list) elif os.path.isfile(path): diff --git a/tests/test_utils.py b/tests/test_utils.py index 92e871032..8496affb7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -36,10 +36,16 @@ def test_load_folder_files(self): file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') file2 = os.path.join(os.getcwd(), 'tests', 'data', 'demo_binds.yml') - files = utils.load_folder_files(folder, ["*.py"]) - self.assertIn(file1, files) + files = utils.load_folder_files(folder, file_type="test", recursive=False) self.assertNotIn(file2, files) + files = utils.load_folder_files(folder, file_type="test", recursive=True) + self.assertIn(file2, files) + self.assertNotIn(file1, files) + + files = utils.load_folder_files(folder, file_type="api", recursive=True) + self.assertEqual(files, []) + def test_load_testcases_by_path_files(self): testsets_list = [] From cb2555291cd6d32bb79b8d96dec2c77438ee74ed Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 18 Sep 2017 15:21:50 +0800 Subject: [PATCH 244/354] new feature: testcases can be layered, now we can define interface in api block individually --- README.md | 4 +- ate/__init__.py | 2 +- ate/exception.py | 3 ++ ate/utils.py | 69 ++++++++++++++++++++++++++++++- tests/data/api.yml | 26 ++++++++++++ tests/data/demo_testset_layer.yml | 40 ++++++++++++++++++ tests/test_runner.py | 8 ++++ tests/test_utils.py | 33 ++++++++++++++- 8 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 tests/data/api.yml create mode 100644 tests/data/demo_testset_layer.yml diff --git a/README.md b/README.md index bc1d2b9ff..d42e2fa2d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.6.0 +ApiTestEngine version: 0.7.0 ``` Execute the command `ate -h` to view command help. @@ -75,7 +75,7 @@ To install mail helper, run this command in your terminal: $ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py $ ate -V jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.6.0 +ApiTestEngine version: 0.7.0 ``` With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. diff --git a/ate/__init__.py b/ate/__init__.py index de5901e2a..19442947c 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.6.3' \ No newline at end of file +__version__ = '0.7.0' \ No newline at end of file diff --git a/ate/exception.py b/ate/exception.py index fa1cc24f3..028ec98b7 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -24,3 +24,6 @@ class FunctionNotFound(NameError): class VariableNotFound(NameError): pass + +class ApiNotFound(NameError): + pass diff --git a/ate/utils.py b/ate/utils.py index 69ec09b53..0a5cbfced 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -11,7 +11,7 @@ import types import yaml -from ate import exception +from ate import exception, testcase from requests.structures import CaseInsensitiveDict try: @@ -24,6 +24,7 @@ PYTHON_VERSION = 3 SECRET_KEY = "DebugTalk" +api_overall_dict = {} def gen_random_string(str_len): return ''.join( @@ -125,6 +126,7 @@ def load_testcases_by_path(path): "testcases": [] } testcases_list = load_testcases(path) + dir_path = os.path.dirname(os.path.abspath(path)) for item in testcases_list: for key in item: @@ -132,13 +134,76 @@ def load_testcases_by_path(path): testset["config"].update(item["config"]) testset["name"] = item["config"].get("name", "") elif key == "test": - testset["testcases"].append(item["test"]) + test_dict = item["test"] + if "api" in test_dict: + update_test_info(test_dict, dir_path) + + testset["testcases"].append(test_dict) return [testset] if testset["testcases"] else [] else: return [] +def update_test_info(test_dict, dir_path): + api_call = test_dict["api"] + function_meta = testcase.parse_function(api_call) + func_name = function_meta["func_name"] + api_info = get_api_definition(func_name, dir_path) + test_dict.update(api_info) + +def get_api_definition(name, dir_path): + """ get expected api from dir_path upward recursively + @param + name: api name + dir_path: start search dir path + @return + expected api info if found, otherwise raise ApiNotFound exception + """ + api_dir_dict = api_overall_dict.get(dir_path) + if not api_dir_dict: + api_dir_dict = load_api_definition(dir_path) + api_overall_dict[dir_path] = api_dir_dict + + api_info = api_dir_dict.get(name) + if api_info: + return api_info + + parent_dir_path = os.path.dirname(dir_path) + if dir_path == parent_dir_path: + # system root path + err_msg = "{} not found in recursive upward path!".format(name) + raise exception.ApiNotFound(err_msg) + + return get_api_definition(name, parent_dir_path) + +def load_api_definition(dir_path): + """ load all api definitions in specified dir path + @param (str) dir_path + @return (dict) all api definitions in dir_path merged in one dict + """ + api_files = load_folder_files(dir_path, file_type="api", recursive=False) + + api_def_list = [] + for api_file in api_files: + api_def_list.extend(load_testcases(api_file)) + + api_dir_dict = {} + + for item in api_def_list: + for key in item: + if key == "api": + api_def = item["api"].pop("def") + function_meta = testcase.parse_function(api_def) + func_name = function_meta["func_name"] + + api_info = {} + api_info["function_meta"] = function_meta + api_info.update(item["api"]) + api_dir_dict[func_name] = api_info + + return api_dir_dict + def query_json(json_content, query, delimiter='.'): """ Do an xpath-like query with json_content. @param (json_content) json_content diff --git a/tests/data/api.yml b/tests/data/api.yml new file mode 100644 index 000000000..0143e550f --- /dev/null +++ b/tests/data/api.yml @@ -0,0 +1,26 @@ +- api: + def: get_token($user_name, $device_sn, $os_platform, $app_version) + request: + url: /api/get-token + method: POST + headers: + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + validators: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + +- api: + def: create_user($uid, $user_name, $user_password, $token) + request: + url: /api/users/$uid + method: POST + headers: + token: $token + json: + name: $user_name + password: $user_password diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml new file mode 100644 index 000000000..0ed32bc36 --- /dev/null +++ b/tests/data/demo_testset_layer.yml @@ -0,0 +1,40 @@ +- config: + name: "create user testsets." + variable_binds: + - 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 + api: get_token($user_agent, $device_sn, $os_platform, $app_version) + extract_binds: + - token: content.token + +- test: + name: create user which does not exist + variable_binds: + - uid: 1000 + - user_name: "user1" + - user_password: "123456" + api: create_user($uid, $user_name, $user_password, $token) + validators: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +- test: + name: create user which does not exist + variable_binds: + - uid: 1000 + - user_name: "user1" + - user_password: "123456" + api: create_user($uid, $user_name, $user_password, $token) + validators: + - {"check": "status_code", "comparator": "eq", "expected": 500} + - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/test_runner.py b/tests/test_runner.py index c28872c01..58430d654 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -107,3 +107,11 @@ def test_run_testsets_template_lambda_functions(self): results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results, [[True] * 3]) + + def test_run_testset_layered(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_layer.yml') + testsets = utils.load_testcases_by_path(testcase_file_path) + results = self.test_runner.run_testsets(testsets) + self.assertEqual(len(results), 1) + self.assertEqual(results, [[True] * 3]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8496affb7..8e330e042 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -44,7 +44,8 @@ def test_load_folder_files(self): self.assertNotIn(file1, files) files = utils.load_folder_files(folder, file_type="api", recursive=True) - self.assertEqual(files, []) + api_file = os.path.join(os.getcwd(), 'tests', 'data', 'api.yml') + self.assertEqual(files[0], api_file) def test_load_testcases_by_path_files(self): testsets_list = [] @@ -125,6 +126,36 @@ def test_load_testcases_by_path_not_exist(self): testset_list_3 = utils.load_testcases_by_path(path) self.assertEqual(testset_list_3, []) + def test_load_testcases_by_path_layered(self): + path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_layer.yml') + testsets_list = utils.load_testcases_by_path(path) + self.assertIn("variable_binds", testsets_list[0]["config"]) + self.assertIn("request", testsets_list[0]["config"]) + print(testsets_list[0]["testcases"][0]) + self.assertIn("request", testsets_list[0]["testcases"][0]) + self.assertIn("url", testsets_list[0]["testcases"][0]["request"]) + self.assertIn("validators", testsets_list[0]["testcases"][0]) + + def test_load_api_definition(self): + path = os.path.join( + os.getcwd(), 'tests/data') + api_dir_dict = utils.load_api_definition(path) + self.assertIn("get_token", api_dir_dict) + self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) + self.assertIn("$user_name", api_dir_dict["get_token"]["function_meta"]["args"]) + self.assertIn("create_user", api_dir_dict) + + def test_get_api_definition(self): + path = os.path.join( + os.getcwd(), 'tests/data') + api_info = utils.get_api_definition("get_token", path) + self.assertEqual("/api/get-token", api_info["request"]["url"]) + self.assertIn(path, utils.api_overall_dict) + + with self.assertRaises(exception.ApiNotFound): + utils.get_api_definition("api_not_exist", path) + def test_query_json(self): json_content = { "ids": [1, 2, 3, 4], From 8c05afaed58b1c02b83b9f825b466db2ab775af2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 18 Sep 2017 16:01:19 +0800 Subject: [PATCH 245/354] bugfix: adjust functions location to avoid cross reference --- ate/locustfile_template | 4 +- ate/locusts.py | 2 +- ate/task.py | 4 +- ate/testcase.py | 117 +++++++++++++++++++++++++++++++++++++++ ate/utils.py | 120 +--------------------------------------- tests/test_runner.py | 19 ++++--- tests/test_task.py | 7 ++- tests/test_testcase.py | 112 ++++++++++++++++++++++++++++++++++++- tests/test_utils.py | 109 ------------------------------------ 9 files changed, 251 insertions(+), 243 deletions(-) diff --git a/ate/locustfile_template b/ate/locustfile_template index 11a7c478c..235c21a7d 100644 --- a/ate/locustfile_template +++ b/ate/locustfile_template @@ -2,7 +2,7 @@ import zmq import os from locust import HttpLocust, TaskSet, task -from ate import utils, runner, exception +from ate import testcase, runner, exception class WebPageTasks(TaskSet): def on_start(self): @@ -22,5 +22,5 @@ class WebPageUser(HttpLocust): min_wait = 1000 max_wait = 5000 - testsets = utils.load_testcases_by_path("$TESTCASE_FILE") + testsets = testcase.load_testcases_by_path("$TESTCASE_FILE") testset = testsets[0] diff --git a/ate/locusts.py b/ate/locusts.py index c3299b0d3..7bc5a129b 100644 --- a/ate/locusts.py +++ b/ate/locusts.py @@ -3,7 +3,7 @@ import os import sys -from ate.utils import load_testcases_by_path +from ate.testcase import load_testcases_by_path from locust.main import main diff --git a/ate/task.py b/ate/task.py index 5ae997abc..be29358e6 100644 --- a/ate/task.py +++ b/ate/task.py @@ -1,6 +1,6 @@ import unittest -from ate import runner, utils +from ate import runner, testcase, utils class ApiTestCase(unittest.TestCase): @@ -43,7 +43,7 @@ def create_task(testcase_path): each task suite may include one or several test suite. """ task_suite = unittest.TestSuite() - testsets = utils.load_testcases_by_path(testcase_path) + testsets = testcase.load_testcases_by_path(testcase_path) for testset in testsets: suite = create_suite(testset) diff --git a/ate/testcase.py b/ate/testcase.py index 33a93967e..17337ff04 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -7,6 +7,7 @@ variable_regexp = r"\$([\w_]+)" function_regexp = r"\$\{([\w_]+\([\$\w_ =,]*\))\}" function_regexp_compile = re.compile(r"^([\w_]+)\(([\$\w_ =,]*)\)$") +api_overall_dict = {} def extract_variables(content): @@ -87,6 +88,122 @@ def parse_function(content): return function_meta +def load_testcases_by_path(path): + """ load testcases from file path + @param path + path could be in several type: + - absolute/relative file path + - absolute/relative folder path + - list/set container with file(s) and/or folder(s) + @return testcase sets list, each testset is corresponding to a file + [ + {"name": "desc1", "config": {}, "testcases": [testcase11, testcase12]}, + {"name": "desc2", "config": {}, "testcases": [testcase21, testcase22, testcase23]}, + ] + """ + if isinstance(path, (list, set)): + testsets_list = [] + + for file_path in set(path): + _testsets_list = load_testcases_by_path(file_path) + testsets_list.extend(_testsets_list) + + return testsets_list + + if not os.path.isabs(path): + path = os.path.join(os.getcwd(), path) + + if os.path.isdir(path): + files_list = utils.load_folder_files(path, file_type="test", recursive=True) + return load_testcases_by_path(files_list) + + elif os.path.isfile(path): + testset = { + "name": "", + "config": { + "path": path + }, + "testcases": [] + } + testcases_list = utils.load_testcases(path) + dir_path = os.path.dirname(os.path.abspath(path)) + + for item in testcases_list: + for key in item: + if key == "config": + testset["config"].update(item["config"]) + testset["name"] = item["config"].get("name", "") + elif key == "test": + test_dict = item["test"] + if "api" in test_dict: + update_test_info(test_dict, dir_path) + + testset["testcases"].append(test_dict) + + return [testset] if testset["testcases"] else [] + + else: + return [] + +def update_test_info(test_dict, dir_path): + api_call = test_dict["api"] + function_meta = parse_function(api_call) + func_name = function_meta["func_name"] + api_info = get_api_definition(func_name, dir_path) + test_dict.update(api_info) + +def get_api_definition(name, dir_path): + """ get expected api from dir_path upward recursively + @param + name: api name + dir_path: start search dir path + @return + expected api info if found, otherwise raise ApiNotFound exception + """ + api_dir_dict = api_overall_dict.get(dir_path) + if not api_dir_dict: + api_dir_dict = load_api_definition(dir_path) + api_overall_dict[dir_path] = api_dir_dict + + api_info = api_dir_dict.get(name) + if api_info: + return api_info + + parent_dir_path = os.path.dirname(dir_path) + if dir_path == parent_dir_path: + # system root path + err_msg = "{} not found in recursive upward path!".format(name) + raise exception.ApiNotFound(err_msg) + + return get_api_definition(name, parent_dir_path) + +def load_api_definition(dir_path): + """ load all api definitions in specified dir path + @param (str) dir_path + @return (dict) all api definitions in dir_path merged in one dict + """ + api_files = utils.load_folder_files(dir_path, file_type="api", recursive=False) + + api_def_list = [] + for api_file in api_files: + api_def_list.extend(utils.load_testcases(api_file)) + + api_dir_dict = {} + + for item in api_def_list: + for key in item: + if key == "api": + api_def = item["api"].pop("def") + function_meta = parse_function(api_def) + func_name = function_meta["func_name"] + + api_info = {} + api_info["function_meta"] = function_meta + api_info.update(item["api"]) + api_dir_dict[func_name] = api_info + + return api_dir_dict + class TestcaseParser(object): diff --git a/ate/utils.py b/ate/utils.py index 0a5cbfced..428ef9ec0 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -11,7 +11,7 @@ import types import yaml -from ate import exception, testcase +from ate import exception from requests.structures import CaseInsensitiveDict try: @@ -24,7 +24,7 @@ PYTHON_VERSION = 3 SECRET_KEY = "DebugTalk" -api_overall_dict = {} + def gen_random_string(str_len): return ''.join( @@ -88,122 +88,6 @@ def load_folder_files(folder_path, file_type, recursive=False): return file_list -def load_testcases_by_path(path): - """ load testcases from file path - @param path - path could be in several type: - - absolute/relative file path - - absolute/relative folder path - - list/set container with file(s) and/or folder(s) - @return testcase sets list, each testset is corresponding to a file - [ - {"name": "desc1", "config": {}, "testcases": [testcase11, testcase12]}, - {"name": "desc2", "config": {}, "testcases": [testcase21, testcase22, testcase23]}, - ] - """ - if isinstance(path, (list, set)): - testsets_list = [] - - for file_path in set(path): - _testsets_list = load_testcases_by_path(file_path) - testsets_list.extend(_testsets_list) - - return testsets_list - - if not os.path.isabs(path): - path = os.path.join(os.getcwd(), path) - - if os.path.isdir(path): - files_list = load_folder_files(path, file_type="test", recursive=True) - return load_testcases_by_path(files_list) - - elif os.path.isfile(path): - testset = { - "name": "", - "config": { - "path": path - }, - "testcases": [] - } - testcases_list = load_testcases(path) - dir_path = os.path.dirname(os.path.abspath(path)) - - for item in testcases_list: - for key in item: - if key == "config": - testset["config"].update(item["config"]) - testset["name"] = item["config"].get("name", "") - elif key == "test": - test_dict = item["test"] - if "api" in test_dict: - update_test_info(test_dict, dir_path) - - testset["testcases"].append(test_dict) - - return [testset] if testset["testcases"] else [] - - else: - return [] - -def update_test_info(test_dict, dir_path): - api_call = test_dict["api"] - function_meta = testcase.parse_function(api_call) - func_name = function_meta["func_name"] - api_info = get_api_definition(func_name, dir_path) - test_dict.update(api_info) - -def get_api_definition(name, dir_path): - """ get expected api from dir_path upward recursively - @param - name: api name - dir_path: start search dir path - @return - expected api info if found, otherwise raise ApiNotFound exception - """ - api_dir_dict = api_overall_dict.get(dir_path) - if not api_dir_dict: - api_dir_dict = load_api_definition(dir_path) - api_overall_dict[dir_path] = api_dir_dict - - api_info = api_dir_dict.get(name) - if api_info: - return api_info - - parent_dir_path = os.path.dirname(dir_path) - if dir_path == parent_dir_path: - # system root path - err_msg = "{} not found in recursive upward path!".format(name) - raise exception.ApiNotFound(err_msg) - - return get_api_definition(name, parent_dir_path) - -def load_api_definition(dir_path): - """ load all api definitions in specified dir path - @param (str) dir_path - @return (dict) all api definitions in dir_path merged in one dict - """ - api_files = load_folder_files(dir_path, file_type="api", recursive=False) - - api_def_list = [] - for api_file in api_files: - api_def_list.extend(load_testcases(api_file)) - - api_dir_dict = {} - - for item in api_def_list: - for key in item: - if key == "api": - api_def = item["api"].pop("def") - function_meta = testcase.parse_function(api_def) - func_name = function_meta["func_name"] - - api_info = {} - api_info["function_meta"] = function_meta - api_info.update(item["api"]) - api_dir_dict[func_name] = api_info - - return api_dir_dict - def query_json(json_content, query, delimiter='.'): """ Do an xpath-like query with json_content. @param (json_content) json_content diff --git a/tests/test_runner.py b/tests/test_runner.py index 58430d654..0f30d28ed 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,8 +1,11 @@ import os + import requests -from ate import runner, exception, utils +from ate import exception, runner, utils +from ate.testcase import load_testcases_by_path from tests.base import ApiServerUnittest + class TestRunner(ApiServerUnittest): def setUp(self): @@ -64,14 +67,14 @@ def test_run_single_testcase_fail(self): def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 3) self.assertEqual(results, [True] * 3) def test_run_testsets_hardcode(self): for testcase_file_path in self.testcase_file_path_list: - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results, [[True] * 3]) @@ -79,7 +82,7 @@ def test_run_testsets_hardcode(self): def test_run_testset_template_variables(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_variables.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 3) self.assertEqual(results, [True] * 3) @@ -87,7 +90,7 @@ def test_run_testset_template_variables(self): def test_run_testset_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testset(testsets[0]) self.assertEqual(len(results), 3) self.assertEqual(results, [True] * 3) @@ -95,7 +98,7 @@ def test_run_testset_template_import_functions(self): def test_run_testsets_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results, [[True] * 3]) @@ -103,7 +106,7 @@ def test_run_testsets_template_import_functions(self): def test_run_testsets_template_lambda_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_lambda_functions.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results, [[True] * 3]) @@ -111,7 +114,7 @@ def test_run_testsets_template_lambda_functions(self): def test_run_testset_layered(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) results = self.test_runner.run_testsets(testsets) self.assertEqual(len(results), 1) self.assertEqual(results, [[True] * 3]) diff --git a/tests/test_task.py b/tests/test_task.py index ffecc6fce..d2082827e 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,6 +1,9 @@ import os + +from ate import task +from ate.testcase import load_testcases_by_path from tests.base import ApiServerUnittest -from ate import task, utils + class TestTask(ApiServerUnittest): @@ -14,7 +17,7 @@ def reset_all(self): def test_create_suite(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_testset_variables.yml') - testsets = utils.load_testcases_by_path(testcase_file_path) + testsets = load_testcases_by_path(testcase_file_path) suite = task.create_suite(testsets[0]) self.assertEqual(suite.countTestCases(), 3) for testcase in suite: diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 1f6af65b7..674abed08 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -1,8 +1,9 @@ +import os import time import unittest from ate import testcase -from ate.exception import ParamsError +from ate.exception import ParamsError, ApiNotFound class TestcaseParserUnittest(unittest.TestCase): @@ -332,3 +333,112 @@ def test_parse_content_with_bindings_testcase(self): parsed_testcase["headers"]["sum"], 3 ) + + def test_load_testcases_by_path_files(self): + testsets_list = [] + + # absolute file path + path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_hardcode.json') + testset_list = testcase.load_testcases_by_path(path) + self.assertEqual(len(testset_list), 1) + self.assertIn("path", testset_list[0]["config"]) + self.assertEqual(testset_list[0]["config"]["path"], path) + self.assertEqual(len(testset_list[0]["testcases"]), 3) + testsets_list.extend(testset_list) + + # relative file path + path = 'tests/data/demo_testset_hardcode.yml' + testset_list = testcase.load_testcases_by_path(path) + self.assertEqual(len(testset_list), 1) + self.assertIn("path", testset_list[0]["config"]) + self.assertIn(path, testset_list[0]["config"]["path"]) + self.assertEqual(len(testset_list[0]["testcases"]), 3) + testsets_list.extend(testset_list) + + # list/set container with file(s) + path = [ + os.path.join(os.getcwd(), 'tests/data/demo_testset_hardcode.json'), + 'tests/data/demo_testset_hardcode.yml' + ] + testset_list = testcase.load_testcases_by_path(path) + self.assertEqual(len(testset_list), 2) + self.assertEqual(len(testset_list[0]["testcases"]), 3) + self.assertEqual(len(testset_list[1]["testcases"]), 3) + testsets_list.extend(testset_list) + self.assertEqual(len(testsets_list), 4) + + for testset in testsets_list: + for test in testset["testcases"]: + self.assertIn('name', test) + self.assertIn('request', test) + self.assertIn('url', test['request']) + self.assertIn('method', test['request']) + + def test_load_testcases_by_path_folder(self): + # absolute folder path + path = os.path.join(os.getcwd(), 'tests/data') + testset_list_1 = testcase.load_testcases_by_path(path) + self.assertGreater(len(testset_list_1), 4) + + # relative folder path + path = 'tests/data/' + testset_list_2 = testcase.load_testcases_by_path(path) + self.assertEqual(len(testset_list_1), len(testset_list_2)) + + # list/set container with file(s) + path = [ + os.path.join(os.getcwd(), 'tests/data'), + 'tests/data/' + ] + testset_list_3 = testcase.load_testcases_by_path(path) + self.assertEqual(len(testset_list_3), 2 * len(testset_list_1)) + + def test_load_testcases_by_path_not_exist(self): + # absolute folder path + path = os.path.join(os.getcwd(), 'tests/data_not_exist') + testset_list_1 = testcase.load_testcases_by_path(path) + self.assertEqual(testset_list_1, []) + + # relative folder path + path = 'tests/data_not_exist' + testset_list_2 = testcase.load_testcases_by_path(path) + self.assertEqual(testset_list_2, []) + + # list/set container with file(s) + path = [ + os.path.join(os.getcwd(), 'tests/data_not_exist'), + 'tests/data_not_exist/' + ] + testset_list_3 = testcase.load_testcases_by_path(path) + self.assertEqual(testset_list_3, []) + + def test_load_testcases_by_path_layered(self): + path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_layer.yml') + testsets_list = testcase.load_testcases_by_path(path) + self.assertIn("variable_binds", testsets_list[0]["config"]) + self.assertIn("request", testsets_list[0]["config"]) + print(testsets_list[0]["testcases"][0]) + self.assertIn("request", testsets_list[0]["testcases"][0]) + self.assertIn("url", testsets_list[0]["testcases"][0]["request"]) + self.assertIn("validators", testsets_list[0]["testcases"][0]) + + def test_load_api_definition(self): + path = os.path.join( + os.getcwd(), 'tests/data') + api_dir_dict = testcase.load_api_definition(path) + self.assertIn("get_token", api_dir_dict) + self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) + self.assertIn("$user_name", api_dir_dict["get_token"]["function_meta"]["args"]) + self.assertIn("create_user", api_dir_dict) + + def test_get_api_definition(self): + path = os.path.join( + os.getcwd(), 'tests/data') + api_info = testcase.get_api_definition("get_token", path) + self.assertEqual("/api/get-token", api_info["request"]["url"]) + self.assertIn(path, testcase.api_overall_dict) + + with self.assertRaises(ApiNotFound): + testcase.get_api_definition("api_not_exist", path) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8e330e042..d535cd55d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -47,115 +47,6 @@ def test_load_folder_files(self): api_file = os.path.join(os.getcwd(), 'tests', 'data', 'api.yml') self.assertEqual(files[0], api_file) - def test_load_testcases_by_path_files(self): - testsets_list = [] - - # absolute file path - path = os.path.join( - os.getcwd(), 'tests/data/demo_testset_hardcode.json') - testset_list = utils.load_testcases_by_path(path) - self.assertEqual(len(testset_list), 1) - self.assertIn("path", testset_list[0]["config"]) - self.assertEqual(testset_list[0]["config"]["path"], path) - self.assertEqual(len(testset_list[0]["testcases"]), 3) - testsets_list.extend(testset_list) - - # relative file path - path = 'tests/data/demo_testset_hardcode.yml' - testset_list = utils.load_testcases_by_path(path) - self.assertEqual(len(testset_list), 1) - self.assertIn("path", testset_list[0]["config"]) - self.assertIn(path, testset_list[0]["config"]["path"]) - self.assertEqual(len(testset_list[0]["testcases"]), 3) - testsets_list.extend(testset_list) - - # list/set container with file(s) - path = [ - os.path.join(os.getcwd(), 'tests/data/demo_testset_hardcode.json'), - 'tests/data/demo_testset_hardcode.yml' - ] - testset_list = utils.load_testcases_by_path(path) - self.assertEqual(len(testset_list), 2) - self.assertEqual(len(testset_list[0]["testcases"]), 3) - self.assertEqual(len(testset_list[1]["testcases"]), 3) - testsets_list.extend(testset_list) - self.assertEqual(len(testsets_list), 4) - - for testset in testsets_list: - for testcase in testset["testcases"]: - self.assertIn('name', testcase) - self.assertIn('request', testcase) - self.assertIn('url', testcase['request']) - self.assertIn('method', testcase['request']) - - def test_load_testcases_by_path_folder(self): - # absolute folder path - path = os.path.join(os.getcwd(), 'tests/data') - testset_list_1 = utils.load_testcases_by_path(path) - self.assertGreater(len(testset_list_1), 4) - - # relative folder path - path = 'tests/data/' - testset_list_2 = utils.load_testcases_by_path(path) - self.assertEqual(len(testset_list_1), len(testset_list_2)) - - # list/set container with file(s) - path = [ - os.path.join(os.getcwd(), 'tests/data'), - 'tests/data/' - ] - testset_list_3 = utils.load_testcases_by_path(path) - self.assertEqual(len(testset_list_3), 2 * len(testset_list_1)) - - def test_load_testcases_by_path_not_exist(self): - # absolute folder path - path = os.path.join(os.getcwd(), 'tests/data_not_exist') - testset_list_1 = utils.load_testcases_by_path(path) - self.assertEqual(testset_list_1, []) - - # relative folder path - path = 'tests/data_not_exist' - testset_list_2 = utils.load_testcases_by_path(path) - self.assertEqual(testset_list_2, []) - - # list/set container with file(s) - path = [ - os.path.join(os.getcwd(), 'tests/data_not_exist'), - 'tests/data_not_exist/' - ] - testset_list_3 = utils.load_testcases_by_path(path) - self.assertEqual(testset_list_3, []) - - def test_load_testcases_by_path_layered(self): - path = os.path.join( - os.getcwd(), 'tests/data/demo_testset_layer.yml') - testsets_list = utils.load_testcases_by_path(path) - self.assertIn("variable_binds", testsets_list[0]["config"]) - self.assertIn("request", testsets_list[0]["config"]) - print(testsets_list[0]["testcases"][0]) - self.assertIn("request", testsets_list[0]["testcases"][0]) - self.assertIn("url", testsets_list[0]["testcases"][0]["request"]) - self.assertIn("validators", testsets_list[0]["testcases"][0]) - - def test_load_api_definition(self): - path = os.path.join( - os.getcwd(), 'tests/data') - api_dir_dict = utils.load_api_definition(path) - self.assertIn("get_token", api_dir_dict) - self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) - self.assertIn("$user_name", api_dir_dict["get_token"]["function_meta"]["args"]) - self.assertIn("create_user", api_dir_dict) - - def test_get_api_definition(self): - path = os.path.join( - os.getcwd(), 'tests/data') - api_info = utils.get_api_definition("get_token", path) - self.assertEqual("/api/get-token", api_info["request"]["url"]) - self.assertIn(path, utils.api_overall_dict) - - with self.assertRaises(exception.ApiNotFound): - utils.get_api_definition("api_not_exist", path) - def test_query_json(self): json_content = { "ids": [1, 2, 3, 4], From 1262d92816a1ee71fd21526a07b60e2c92b100c6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 18 Sep 2017 21:51:46 +0800 Subject: [PATCH 246/354] subsititue api call args --- ate/__init__.py | 2 +- ate/testcase.py | 63 +++++++++++++++++++++++++++++++ tests/data/api.yml | 2 +- tests/data/demo_testset_layer.yml | 6 +-- tests/test_testcase.py | 20 +++++++++- 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 19442947c..7320e64e1 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.0' \ No newline at end of file +__version__ = '0.7.1' \ No newline at end of file diff --git a/ate/testcase.py b/ate/testcase.py index 17337ff04..b8fbfccb6 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -149,9 +149,72 @@ def update_test_info(test_dict, dir_path): api_call = test_dict["api"] function_meta = parse_function(api_call) func_name = function_meta["func_name"] + api_call_args = function_meta["args"] api_info = get_api_definition(func_name, dir_path) + api_def_args = api_info.get("function_meta").get("args", []) + + if len(api_call_args) != len(api_def_args): + raise exception.ParamsError("api call args invalid!") + + args_mapping = {} + for index, item in enumerate(api_def_args): + if api_call_args[index] == item: + continue + + args_mapping[item] = api_call_args[index] + + if args_mapping: + api_info = substitute_variables_with_mapping(api_info, args_mapping) + test_dict.update(api_info) +def substitute_variables_with_mapping(content, mapping): + """ substitute variables in content with mapping + e.g. + @params + content = { + 'request': { + 'url': '/api/users/$uid', + 'headers': {'token': '$token'} + } + } + mapping = {"$uid": 1000} + @return + { + 'request': { + 'url': '/api/users/1000', + 'headers': {'token': '$token'} + } + } + """ + if isinstance(content, (list, tuple)): + return [ + substitute_variables_with_mapping(item, mapping) + for item in content + ] + + if isinstance(content, dict): + substituted_data = {} + for key, value in content.items(): + eval_key = substitute_variables_with_mapping(key, mapping) + eval_value = substitute_variables_with_mapping(value, mapping) + substituted_data[eval_key] = eval_value + + return substituted_data + + if isinstance(content, (int, utils.long_type, float, complex)): + return content + + # content is in string format here + for var, value in mapping.items(): + if content == var: + # content is a variable + content = value + else: + content = content.replace(var, str(value)) + + return content + def get_api_definition(name, dir_path): """ get expected api from dir_path upward recursively @param diff --git a/tests/data/api.yml b/tests/data/api.yml index 0143e550f..1e641a542 100644 --- a/tests/data/api.yml +++ b/tests/data/api.yml @@ -1,5 +1,5 @@ - api: - def: get_token($user_name, $device_sn, $os_platform, $app_version) + def: get_token($user_agent, $device_sn, $os_platform, $app_version) request: url: /api/get-token method: POST diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index 0ed32bc36..ed107f939 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -20,10 +20,9 @@ - test: name: create user which does not exist variable_binds: - - uid: 1000 - user_name: "user1" - user_password: "123456" - api: create_user($uid, $user_name, $user_password, $token) + api: create_user(1000, $user_name, $user_password, $token) validators: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -31,10 +30,9 @@ - test: name: create user which does not exist variable_binds: - - uid: 1000 - user_name: "user1" - user_password: "123456" - api: create_user($uid, $user_name, $user_password, $token) + api: create_user(1000, $user_name, $user_password, $token) validators: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 674abed08..0a19345e7 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -419,18 +419,34 @@ def test_load_testcases_by_path_layered(self): testsets_list = testcase.load_testcases_by_path(path) self.assertIn("variable_binds", testsets_list[0]["config"]) self.assertIn("request", testsets_list[0]["config"]) - print(testsets_list[0]["testcases"][0]) self.assertIn("request", testsets_list[0]["testcases"][0]) self.assertIn("url", testsets_list[0]["testcases"][0]["request"]) self.assertIn("validators", testsets_list[0]["testcases"][0]) + def test_substitute_variables_with_mapping(self): + content = { + 'request': { + 'url': '/api/users/$uid', + 'method': "$method", + 'headers': {'token': '$token'} + } + } + mapping = { + "$uid": 1000, + "$method": "POST" + } + result = testcase.substitute_variables_with_mapping(content, mapping) + self.assertEqual("/api/users/1000", result["request"]["url"]) + self.assertEqual("$token", result["request"]["headers"]["token"]) + self.assertEqual("POST", result["request"]["method"]) + def test_load_api_definition(self): path = os.path.join( os.getcwd(), 'tests/data') api_dir_dict = testcase.load_api_definition(path) self.assertIn("get_token", api_dir_dict) self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) - self.assertIn("$user_name", api_dir_dict["get_token"]["function_meta"]["args"]) + self.assertIn("$user_agent", api_dir_dict["get_token"]["function_meta"]["args"]) self.assertIn("create_user", api_dir_dict) def test_get_api_definition(self): From f19bfad1282dcd6e6c9ade9642e13551f1b6b42c Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 18 Sep 2017 22:51:58 +0800 Subject: [PATCH 247/354] adjust function name and variable name --- ate/testcase.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index b8fbfccb6..0a358d7f6 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -134,19 +134,21 @@ def load_testcases_by_path(path): testset["config"].update(item["config"]) testset["name"] = item["config"].get("name", "") elif key == "test": - test_dict = item["test"] - if "api" in test_dict: - update_test_info(test_dict, dir_path) + test_block_dict = item["test"] + if "api" in test_block_dict: + testcase_list = load_testcases_by_call(test_block_dict, dir_path, "api") + else: + testcase_list = [test_block_dict] - testset["testcases"].append(test_dict) + testset["testcases"].extend(testcase_list) return [testset] if testset["testcases"] else [] else: return [] -def update_test_info(test_dict, dir_path): - api_call = test_dict["api"] +def load_testcases_by_call(test_block_dict, dir_path, call_type): + api_call = test_block_dict[call_type] function_meta = parse_function(api_call) func_name = function_meta["func_name"] api_call_args = function_meta["args"] @@ -166,7 +168,9 @@ def update_test_info(test_dict, dir_path): if args_mapping: api_info = substitute_variables_with_mapping(api_info, args_mapping) - test_dict.update(api_info) + test_block_dict.update(api_info) + + return [test_block_dict] def substitute_variables_with_mapping(content, mapping): """ substitute variables in content with mapping From 6b3da70e0605091e2bf4adbea195a7ccbb12b57c Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 11:34:52 +0800 Subject: [PATCH 248/354] load_testcases_by_path: add file_type parameter --- ate/testcase.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 0a358d7f6..408d0c2b6 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -88,13 +88,14 @@ def parse_function(content): return function_meta -def load_testcases_by_path(path): +def load_testcases_by_path(path, file_type="test"): """ load testcases from file path - @param path - path could be in several type: + @param + path: path could be in several type - absolute/relative file path - absolute/relative folder path - list/set container with file(s) and/or folder(s) + file_type: "test" or "suite" @return testcase sets list, each testset is corresponding to a file [ {"name": "desc1", "config": {}, "testcases": [testcase11, testcase12]}, @@ -105,7 +106,7 @@ def load_testcases_by_path(path): testsets_list = [] for file_path in set(path): - _testsets_list = load_testcases_by_path(file_path) + _testsets_list = load_testcases_by_path(file_path, file_type) testsets_list.extend(_testsets_list) return testsets_list @@ -114,8 +115,8 @@ def load_testcases_by_path(path): path = os.path.join(os.getcwd(), path) if os.path.isdir(path): - files_list = utils.load_folder_files(path, file_type="test", recursive=True) - return load_testcases_by_path(files_list) + files_list = utils.load_folder_files(path, file_type=file_type, recursive=True) + return load_testcases_by_path(files_list, file_type) elif os.path.isfile(path): testset = { From 4197ed26caa461f0cf9b36eb81d653ea80a6e4ee Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 15:24:11 +0800 Subject: [PATCH 249/354] add output: 1, change return value of running testset; 2, print extracted variables at the end of testset. --- ate/runner.py | 70 +++++++++++++++++++++---------- tests/data/demo_testset_layer.yml | 2 + tests/test_runner.py | 43 +++++++++---------- 3 files changed, 72 insertions(+), 43 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 43ba88929..25c1330eb 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -144,22 +144,30 @@ def run_testset(self, testset): testcase12 ] } - @return (list) test results of testcases - [ - True, # testcase11 - True # testcase12 - ] + @return (dict) test result of testcases + { + "success": True, + "output": {} # variables mapping + } """ - results = [] - + success = True config_dict = testset.get("config", {}) self.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: - result = self.run_test(testcase) - results.append(result) + try: + assert self.run_test(testcase) + except AssertionError: + success = False - return results + output_variables_list = config_dict.get("output", []) + output = self.generate_output(output_variables_list) + self.print_output(output) + + return { + "success": success, + "output": output + } def run_testsets(self, testsets): """ run testsets, including one or several testsets. @@ -168,16 +176,34 @@ def run_testsets(self, testsets): testset1, testset2, ] - @return (list) test results of testsets - [ - [ # testset1 - True, # testcase11 - True # testcase12 - ], - [ # testset2 - True, # testcase21 - True # testcase22 - ] - ] + @return (bool) test result of testsets """ - return [self.run_testset(testset) for testset in testsets] + success = True + for testset in testsets: + try: + result = self.run_testset(testset) + assert result["success"] + except AssertionError: + success = False + + return success + + def generate_output(self, output_variables_list): + variables_mapping = self.context.get_testcase_variables_mapping() + return { + variable: variables_mapping[variable] + for variable in output_variables_list + } + + def print_output(self, output): + if not output: + return + + print("\n================== Output ==================") + print('{:<10}: {:<}'.format("Variable", "Value")) + print('{:<10}: {:<}'.format("--------", "-----")) + + for variable, value in output.items(): + print('{:<10}: {:<}'.format(variable, value)) + + print("============================================\n") diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index ed107f939..87758abf7 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -10,6 +10,8 @@ headers: Content-Type: application/json device_sn: $device_sn + output: + - token - test: name: get token diff --git a/tests/test_runner.py b/tests/test_runner.py index 0f30d28ed..be8ceea2a 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -68,53 +68,54 @@ def test_run_single_testcase_fail(self): def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 3) - self.assertEqual(results, [True] * 3) + result = self.test_runner.run_testset(testsets[0]) + self.assertTrue(result["success"]) def test_run_testsets_hardcode(self): for testcase_file_path in self.testcase_file_path_list: testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results, [[True] * 3]) + result = self.test_runner.run_testsets(testsets) + self.assertTrue(result) def test_run_testset_template_variables(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_variables.yml') testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 3) - self.assertEqual(results, [True] * 3) + result = self.test_runner.run_testset(testsets[0]) + self.assertTrue(result["success"]) def test_run_testset_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testset(testsets[0]) - self.assertEqual(len(results), 3) - self.assertEqual(results, [True] * 3) + result = self.test_runner.run_testset(testsets[0]) + self.assertTrue(result["success"]) def test_run_testsets_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results, [[True] * 3]) + result = self.test_runner.run_testsets(testsets) + self.assertTrue(result) def test_run_testsets_template_lambda_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_lambda_functions.yml') testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results, [[True] * 3]) + result = self.test_runner.run_testsets(testsets) + self.assertTrue(result) def test_run_testset_layered(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') testsets = load_testcases_by_path(testcase_file_path) - results = self.test_runner.run_testsets(testsets) - self.assertEqual(len(results), 1) - self.assertEqual(results, [[True] * 3]) + result = self.test_runner.run_testsets(testsets) + self.assertTrue(result) + + def test_run_testset_output(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_layer.yml') + testsets = load_testcases_by_path(testcase_file_path) + result = self.test_runner.run_testset(testsets[0]) + self.assertTrue(result["success"]) + self.assertIn("token", result["output"]) From 1b847d562dcc5e11afeb407eb3b795cac87b3ce1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 16:14:55 +0800 Subject: [PATCH 250/354] fix doc string --- ate/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 25c1330eb..e5cb3d205 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -70,7 +70,7 @@ def run_test(self, testcase): "times": 3, "requires": [], # optional, override "function_binds": {}, # optional, override - "variable_binds": {}, # optional, override + "variable_binds": [], # optional, override "request": { "url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", @@ -136,7 +136,7 @@ def run_testset(self, testset): "testcases": [ { "name": "testcase description", - "variable_binds": {}, # optional, override + "variable_binds": [], # optional, override "request": {}, "extract_binds": {}, # optional "validators": {} # optional From 041152c00fcd2a552955e63f7baa670324ee9902 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 17:07:07 +0800 Subject: [PATCH 251/354] run_testset: add variables_mapping parameter --- ate/runner.py | 61 +++++++++++++++++++++++++++++--------------- tests/test_runner.py | 11 ++++++++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index e5cb3d205..5128c43c9 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + from ate import exception, response, utils from ate.client import HttpSession from ate.context import Context @@ -121,29 +123,33 @@ def setup_teardown(actions): return True - def run_testset(self, testset): + def run_testset(self, testset, variables_mapping=None): """ run single testset, including one or several testcases. - @param (dict) testset - { - "name": "testset description", - "config": { + @param + (dict) testset + { "name": "testset description", - "requires": [], - "function_binds": {}, - "variable_binds": [], - "request": {} - }, - "testcases": [ - { - "name": "testcase description", - "variable_binds": [], # optional, override - "request": {}, - "extract_binds": {}, # optional - "validators": {} # optional + "config": { + "name": "testset description", + "requires": [], + "function_binds": {}, + "variable_binds": [], + "request": {} }, - testcase12 - ] - } + "testcases": [ + { + "name": "testcase description", + "variable_binds": [], # optional, override + "request": {}, + "extract_binds": {}, # optional + "validators": {} # optional + }, + testcase12 + ] + } + (dict) variables_mapping: + passed in variables mapping, it will override variable_binds in config block + @return (dict) test result of testcases { "success": True, @@ -152,6 +158,21 @@ def run_testset(self, testset): """ success = True config_dict = testset.get("config", {}) + + def merge_variable_binds(variable_binds, variables_mapping): + variables_dict = OrderedDict() + for variable_dict in variable_binds: + variables_dict.update(variable_dict) + + for var, value in variables_mapping.items(): + variables_dict.update({var: value}) + + return [{var: value} for var, value in variables_dict.items()] + + variable_binds = config_dict.get("variable_binds", []) + variables_mapping = variables_mapping or {} + config_dict["variable_binds"] = merge_variable_binds(variable_binds, variables_mapping) + self.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) for testcase in testcases: diff --git a/tests/test_runner.py b/tests/test_runner.py index be8ceea2a..185e43f29 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -119,3 +119,14 @@ def test_run_testset_output(self): result = self.test_runner.run_testset(testsets[0]) self.assertTrue(result["success"]) self.assertIn("token", result["output"]) + + def test_run_testset_with_variables_mapping(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_layer.yml') + testsets = load_testcases_by_path(testcase_file_path) + variables_mapping = { + "app_version": '2.9.7' + } + result = self.test_runner.run_testset(testsets[0], variables_mapping) + self.assertTrue(result["success"]) + self.assertIn("token", result["output"]) From c53ff6992b6703525fe8e0558fb3fa931e11c828 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 18:05:12 +0800 Subject: [PATCH 252/354] make extract_response return as ordered dict --- ate/response.py | 14 +++++++------- tests/test_response.py | 24 ++++++++++++------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ate/response.py b/ate/response.py index d97e4a890..9b7041377 100644 --- a/ate/response.py +++ b/ate/response.py @@ -1,4 +1,6 @@ -from ate import utils, exception +from collections import OrderedDict + +from ate import exception, utils class ResponseObject(object): @@ -63,20 +65,18 @@ def extract_response(self, extract_binds): {"resp_content": "content"}, {"resp_content_person_first_name": "content.person.name.first_name"} ] - @return (list) variable binds list + @return (OrderDict) variable binds ordered dict """ - extracted_variables_mapping_list = [] + extracted_variables_mapping = OrderedDict() for extract_bind in extract_binds: for key, field in extract_bind.items(): if not isinstance(field, utils.string_type): raise exception.ParamsError("invalid extract_binds in testcase extract_binds!") - extracted_variables_mapping_list.append( - {key: self.extract_field(field)} - ) + extracted_variables_mapping[key] = self.extract_field(field) - return extracted_variables_mapping_list + return extracted_variables_mapping def validate(self, validators, variables_mapping): """ Bind named validators to value within the context. diff --git a/tests/test_response.py b/tests/test_response.py index 8ad281bdb..948745713 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -59,34 +59,34 @@ def test_extract_response_json(self): {"resp_content_cities_1": "content.person.cities.1"} ] resp_obj = response.ResponseObject(resp) - extract_binds_dict_list = resp_obj.extract_response(extract_binds_list) + extract_binds_dict = resp_obj.extract_response(extract_binds_list) self.assertEqual( - extract_binds_dict_list[0]["resp_status_code"], + extract_binds_dict["resp_status_code"], 200 ) self.assertEqual( - extract_binds_dict_list[1]["resp_headers_content_type"], + extract_binds_dict["resp_headers_content_type"], "application/json" ) self.assertEqual( - extract_binds_dict_list[2]["resp_content_body_success"], + extract_binds_dict["resp_content_body_success"], False ) self.assertEqual( - extract_binds_dict_list[3]["resp_content_content_success"], + extract_binds_dict["resp_content_content_success"], False ) self.assertEqual( - extract_binds_dict_list[4]["resp_content_text_success"], + extract_binds_dict["resp_content_text_success"], False ) self.assertEqual( - extract_binds_dict_list[5]["resp_content_person_first_name"], + extract_binds_dict["resp_content_person_first_name"], "Leo" ) self.assertEqual( - extract_binds_dict_list[6]["resp_content_cities_1"], + extract_binds_dict["resp_content_cities_1"], "Shenzhen" ) @@ -143,9 +143,9 @@ def test_extract_response_json_string(self): ] resp_obj = response.ResponseObject(resp) - extract_binds_dict_list = resp_obj.extract_response(extract_binds_list) + extract_binds_dict = resp_obj.extract_response(extract_binds_list) self.assertEqual( - extract_binds_dict_list[0]["resp_content_body"], + extract_binds_dict["resp_content_body"], "abc" ) @@ -164,9 +164,9 @@ def test_extract_response_empty(self): {"resp_content_body": "content"} ] resp_obj = response.ResponseObject(resp) - extract_binds_dict_list = resp_obj.extract_response(extract_binds_list) + extract_binds_dict = resp_obj.extract_response(extract_binds_list) self.assertEqual( - extract_binds_dict_list[0]["resp_content_body"], + extract_binds_dict["resp_content_body"], "" ) From 6085dd407dacfdd1c1c1a5e280413390ea133b51 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 18:10:54 +0800 Subject: [PATCH 253/354] refactor handling binds data structure --- ate/response.py | 10 ++++----- ate/utils.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 30 +++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/ate/response.py b/ate/response.py index 9b7041377..0b4c17a95 100644 --- a/ate/response.py +++ b/ate/response.py @@ -68,13 +68,13 @@ def extract_response(self, extract_binds): @return (OrderDict) variable binds ordered dict """ extracted_variables_mapping = OrderedDict() + extract_binds_order_dict = utils.convert_to_order_dict(extract_binds) - for extract_bind in extract_binds: - for key, field in extract_bind.items(): - if not isinstance(field, utils.string_type): - raise exception.ParamsError("invalid extract_binds in testcase extract_binds!") + for key, field in extract_binds_order_dict.items(): + if not isinstance(field, utils.string_type): + raise exception.ParamsError("invalid extract_binds in testcase extract_binds!") - extracted_variables_mapping[key] = self.extract_field(field) + extracted_variables_mapping[key] = self.extract_field(field) return extracted_variables_mapping diff --git a/ate/utils.py b/ate/utils.py index 428ef9ec0..1b8db2021 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -9,6 +9,7 @@ import re import string import types +from collections import OrderedDict import yaml from ate import exception @@ -309,3 +310,52 @@ def lower_dict_key(origin_dict, depth=1): new_dict[key.lower()] = value return new_dict + +def convert_to_order_dict(map_list): + """ convert mapping in list to ordered dict + @param (list) map_list + [ + {"a": 1}, + {"b": 2} + ] + @return (OrderDict) + OrderDict({ + "a": 1, + "b": 2 + }) + """ + ordered_dict = OrderedDict() + for map_dict in map_list: + ordered_dict.update(map_dict) + + return ordered_dict + +def update_ordered_dict(ordered_dict, override_mapping): + """ override ordered_dict with new mapping + @param + (OrderDict) ordered_dict + OrderDict({ + "a": 1, + "b": 2 + }) + (dict) override_mapping + {"a": 3, "c": 4} + @return (OrderDict) + OrderDict({ + "a": 3, + "b": 2, + "c": 4 + }) + """ + for var, value in override_mapping.items(): + ordered_dict.update({var: value}) + + return ordered_dict + +def override_variables_binds(variable_binds, new_mapping): + """ convert variable_binds in testcase to ordered mapping, with new_mapping overrided + """ + return update_ordered_dict( + convert_to_order_dict(variable_binds), + new_mapping + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index d535cd55d..8a591e220 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -241,3 +241,33 @@ def test_lower_dict_key(self): self.assertIn("headers", new_dict["request"]) self.assertIn("Accept", new_dict["request"]["headers"]) self.assertIn("User-Agent", new_dict["request"]["headers"]) + + def test_convert_to_order_dict(self): + map_list = [ + {"a": 1}, + {"b": 2} + ] + ordered_dict = utils.convert_to_order_dict(map_list) + self.assertIsInstance(ordered_dict, dict) + self.assertIn("a", ordered_dict) + + def test_update_ordered_dict(self): + map_list = [ + {"a": 1}, + {"b": 2} + ] + ordered_dict = utils.convert_to_order_dict(map_list) + override_mapping = {"a": 3, "c": 4} + new_dict = utils.update_ordered_dict(ordered_dict, override_mapping) + self.assertEqual(3, new_dict["a"]) + self.assertEqual(4, new_dict["c"]) + + def test_override_variables_binds(self): + map_list = [ + {"a": 1}, + {"b": 2} + ] + override_mapping = {"a": 3, "c": 4} + new_dict = utils.override_variables_binds(map_list, override_mapping) + self.assertEqual(3, new_dict["a"]) + self.assertEqual(4, new_dict["c"]) From ccce1357c4dfe1b43c940ed1dc9a499b4a734831 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 18:33:27 +0800 Subject: [PATCH 254/354] bind_variables: variable_binds now can be ordered dict --- ate/context.py | 35 ++++++++++++++++++----------------- ate/runner.py | 16 +++------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/ate/context.py b/ate/context.py index 0a82cda7e..60af1d521 100644 --- a/ate/context.py +++ b/ate/context.py @@ -54,7 +54,7 @@ def config_context(self, config_dict, level): or config_dict.get('import_module_functions', []) self.import_module_items(module_items, level) - variable_binds = config_dict.get('variable_binds', []) + variable_binds = config_dict.get('variable_binds', OrderedDict()) self.bind_variables(variable_binds, level) def import_requires(self, modules): @@ -90,32 +90,33 @@ def import_module_items(self, modules, level="testcase"): self.__update_context_functions_config(level, imported_functions_dict) imported_variables_dict = utils.filter_module(imported_module, "variable") - variable_binds = [{key: value} for key, value in imported_variables_dict.items()] - self.bind_variables(variable_binds, level) + self.bind_variables(imported_variables_dict, level) def bind_variables(self, variable_binds, level="testcase"): """ bind variables to testset context or current testcase context. variables in testset context can be used in all testcases of current test suite. - @param (list) variable_binds, variable can be value or custom function. + @param (list or OrderDict) variable_binds, variable can be value or custom function. if value is function, it will be called and bind result to variable. e.g. - [ - {"TOKEN": "debugtalk"}, - {"random": "${gen_random_string(5)}"}, - {"json": {'name': 'user', 'password': '123456'}}, - {"md5": "${gen_md5($TOKEN, $json, $random)}"} - ] + OrderDict({ + "TOKEN": "debugtalk", + "random": "${gen_random_string(5)}", + "json": {'name': 'user', 'password': '123456'}, + "md5": "${gen_md5($TOKEN, $json, $random)}" + }) """ - for variable_bind in variable_binds: - for variable_name, value in variable_bind.items(): - variable_evale_value = self.testcase_parser.parse_content_with_bindings(value) + if isinstance(variable_binds, list): + variable_binds = utils.convert_to_order_dict(variable_binds) - if level == "testset": - self.testset_shared_variables_mapping[variable_name] = variable_evale_value + for variable_name, value in variable_binds.items(): + variable_evale_value = self.testcase_parser.parse_content_with_bindings(value) - self.testcase_variables_mapping[variable_name] = variable_evale_value - self.testcase_parser.bind_variables(self.testcase_variables_mapping) + if level == "testset": + self.testset_shared_variables_mapping[variable_name] = variable_evale_value + + self.testcase_variables_mapping[variable_name] = variable_evale_value + self.testcase_parser.bind_variables(self.testcase_variables_mapping) def __update_context_functions_config(self, level, config_mapping): """ diff --git a/ate/runner.py b/ate/runner.py index 5128c43c9..18c296317 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -114,8 +114,8 @@ def setup_teardown(actions): resp = self.http_client_session.request(url=url, method=method, **parsed_request) resp_obj = response.ResponseObject(resp) - extracted_variables_mapping_list = resp_obj.extract_response(extract_binds) - self.context.bind_variables(extracted_variables_mapping_list, level="testset") + extracted_variables_mapping = resp_obj.extract_response(extract_binds) + self.context.bind_variables(extracted_variables_mapping, level="testset") resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) @@ -159,19 +159,9 @@ def run_testset(self, testset, variables_mapping=None): success = True config_dict = testset.get("config", {}) - def merge_variable_binds(variable_binds, variables_mapping): - variables_dict = OrderedDict() - for variable_dict in variable_binds: - variables_dict.update(variable_dict) - - for var, value in variables_mapping.items(): - variables_dict.update({var: value}) - - return [{var: value} for var, value in variables_dict.items()] - variable_binds = config_dict.get("variable_binds", []) variables_mapping = variables_mapping or {} - config_dict["variable_binds"] = merge_variable_binds(variable_binds, variables_mapping) + config_dict["variable_binds"] = utils.override_variables_binds(variable_binds, variables_mapping) self.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) From 9161e48844070b2d3df928e8e9689292186895c8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 23:27:38 +0800 Subject: [PATCH 255/354] move print_output function from runner.py to utils.py --- ate/runner.py | 22 ++++++---------------- ate/utils.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 18c296317..35c5b43bd 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -172,12 +172,10 @@ def run_testset(self, testset, variables_mapping=None): success = False output_variables_list = config_dict.get("output", []) - output = self.generate_output(output_variables_list) - self.print_output(output) return { "success": success, - "output": output + "output": self.generate_output(output_variables_list) } def run_testsets(self, testsets): @@ -200,21 +198,13 @@ def run_testsets(self, testsets): return success def generate_output(self, output_variables_list): + """ generate and print output + """ variables_mapping = self.context.get_testcase_variables_mapping() - return { + output = { variable: variables_mapping[variable] for variable in output_variables_list } + utils.print_output(output) - def print_output(self, output): - if not output: - return - - print("\n================== Output ==================") - print('{:<10}: {:<}'.format("Variable", "Value")) - print('{:<10}: {:<}'.format("--------", "-----")) - - for variable, value in output.items(): - print('{:<10}: {:<}'.format(variable, value)) - - print("============================================\n") + return output diff --git a/ate/utils.py b/ate/utils.py index 1b8db2021..b7c665087 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -359,3 +359,16 @@ def override_variables_binds(variable_binds, new_mapping): convert_to_order_dict(variable_binds), new_mapping ) + +def print_output(output): + if not output: + return + + print("\n================== Output ==================") + print('{:<10}: {:<}'.format("Variable", "Value")) + print('{:<10}: {:<}'.format("--------", "-----")) + + for variable, value in output.items(): + print('{:<10}: {:<}'.format(variable, value)) + + print("============================================\n") From b679a1514d23ef8e4f5dd67cd4ae11c7bbffb51f Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 23:33:09 +0800 Subject: [PATCH 256/354] make ApiTestSuite class to hold ApiTestCase --- ate/__init__.py | 2 +- ate/task.py | 35 ++++++++++++++++++++--------------- tests/test_task.py | 2 +- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 7320e64e1..285367e4d 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.1' \ No newline at end of file +__version__ = '0.7.2' \ No newline at end of file diff --git a/ate/task.py b/ate/task.py index be29358e6..aa30fb865 100644 --- a/ate/task.py +++ b/ate/task.py @@ -16,27 +16,32 @@ def runTest(self): """ self.assertTrue(self.test_runner.run_test(self.testcase)) -def create_suite(testset): +class ApiTestSuite(unittest.TestSuite): """ create test suite with a testset, it may include one or several testcases. each suite should initialize a separate Runner() with testset config. """ - suite = unittest.TestSuite() + def __init__(self, testset): + super(ApiTestSuite, self).__init__() + self.test_runner = runner.Runner() + self.config_dict = testset.get("config", {}) + self.test_runner.init_config(self.config_dict, level="testset") + testcases = testset.get("testcases", []) + self._add_tests_to_suite(testcases) - test_runner = runner.Runner() - config_dict = testset.get("config", {}) - test_runner.init_config(config_dict, level="testset") - testcases = testset.get("testcases", []) + def _add_tests_to_suite(self, testcases): + for testcase in testcases: + if utils.PYTHON_VERSION == 3: + ApiTestCase.runTest.__doc__ = testcase['name'] + else: + ApiTestCase.runTest.__func__.__doc__ = testcase['name'] - for testcase in testcases: - if utils.PYTHON_VERSION == 3: - ApiTestCase.runTest.__doc__ = testcase['name'] - else: - ApiTestCase.runTest.__func__.__doc__ = testcase['name'] + test = ApiTestCase(self.test_runner, testcase) + self.addTest(test) - test = ApiTestCase(test_runner, testcase) - suite.addTest(test) + def print_output(self): + output_variables_list = self.config_dict.get("output", []) + self.test_runner.generate_output(output_variables_list) - return suite def create_task(testcase_path): """ create test task suite with specified testcase path. @@ -46,7 +51,7 @@ def create_task(testcase_path): testsets = testcase.load_testcases_by_path(testcase_path) for testset in testsets: - suite = create_suite(testset) + suite = ApiTestSuite(testset) task_suite.addTest(suite) return task_suite diff --git a/tests/test_task.py b/tests/test_task.py index d2082827e..d62f369ef 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -18,7 +18,7 @@ def reset_all(self): def test_create_suite(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_testset_variables.yml') testsets = load_testcases_by_path(testcase_file_path) - suite = task.create_suite(testsets[0]) + suite = task.ApiTestSuite(testsets[0]) self.assertEqual(suite.countTestCases(), 3) for testcase in suite: self.assertIsInstance(testcase, task.ApiTestCase) From 960213a7bd8173af98dfcbb1a37cb7254fe2cb63 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 19 Sep 2017 23:34:16 +0800 Subject: [PATCH 257/354] ate: print output after tests --- ate/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ate/cli.py b/ate/cli.py index d851b76a2..e8247fd82 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -78,6 +78,9 @@ def main_ate(): if len(result.successes) != result.testsRun: subject = "FAILED" + for task in task_suite: + task.print_output() + flag_code = 0 if subject == "SUCCESS" else 1 if mailer and mailer.config_ready: mailer.send_mail(subject, results, flag_code) From 5ee65776a029ab23737b72e20ac517473b496f92 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 14:57:03 +0800 Subject: [PATCH 258/354] bugfix: once task_suite been executed, task in task_suite will be None, and can not call print_output function --- ate/cli.py | 6 +++--- ate/task.py | 24 ++++++++++++++---------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index e8247fd82..0bf17b864 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -5,7 +5,7 @@ from collections import OrderedDict from ate import __version__ -from ate.task import create_task +from ate.task import TaskSuite import PyUnitReport @@ -58,7 +58,7 @@ def main_ate(): for testset_path in set(args.testset_paths): testset_path = testset_path.rstrip('/') - task_suite = create_task(testset_path) + task_suite = TaskSuite(testset_path) output_folder_name = os.path.basename(os.path.splitext(testset_path)[0]) kwargs = { @@ -78,7 +78,7 @@ def main_ate(): if len(result.successes) != result.testsRun: subject = "FAILED" - for task in task_suite: + for task in task_suite.tasks: task.print_output() flag_code = 0 if subject == "SUCCESS" else 1 diff --git a/ate/task.py b/ate/task.py index aa30fb865..a653dbff4 100644 --- a/ate/task.py +++ b/ate/task.py @@ -42,16 +42,20 @@ def print_output(self): output_variables_list = self.config_dict.get("output", []) self.test_runner.generate_output(output_variables_list) - -def create_task(testcase_path): +class TaskSuite(unittest.TestSuite): """ create test task suite with specified testcase path. each task suite may include one or several test suite. """ - task_suite = unittest.TestSuite() - testsets = testcase.load_testcases_by_path(testcase_path) - - for testset in testsets: - suite = ApiTestSuite(testset) - task_suite.addTest(suite) - - return task_suite + def __init__(self, testcase_path): + super(TaskSuite, self).__init__() + self.suite_list = [] + testsets = testcase.load_testcases_by_path(testcase_path) + + for testset in testsets: + suite = ApiTestSuite(testset) + self.addTest(suite) + self.suite_list.append(suite) + + @property + def tasks(self): + return self.suite_list From 07daa2b7812ab403600dcd66ddbe45440ee0fa98 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 14:59:32 +0800 Subject: [PATCH 259/354] bugfix: UnicodeEncodeError when value is in Chinese --- ate/utils.py | 7 ++++--- tests/test_task.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index b7c665087..9b732c3f8 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -365,10 +365,11 @@ def print_output(output): return print("\n================== Output ==================") - print('{:<10}: {:<}'.format("Variable", "Value")) - print('{:<10}: {:<}'.format("--------", "-----")) + print('{:<16}: {:<}'.format("Variable", "Value")) + print('{:<16}: {:<}'.format("--------", "-----")) for variable, value in output.items(): - print('{:<10}: {:<}'.format(variable, value)) + print('{:<16}: {:<}'.format( + variable.encode("utf-8"), value.encode("utf-8"))) print("============================================\n") diff --git a/tests/test_task.py b/tests/test_task.py index d62f369ef..c30a01d21 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -25,7 +25,7 @@ def test_create_suite(self): def test_create_task(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_testset_variables.yml') - task_suite = task.create_task(testcase_file_path) + task_suite = task.TaskSuite(testcase_file_path) self.assertEqual(task_suite.countTestCases(), 3) for suite in task_suite: for testcase in suite: From c212c0853cbed2b0549fa2ef319caac6a0db9383 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 15:49:30 +0800 Subject: [PATCH 260/354] bugfix: UnicodeEncodeError in 3.4+ --- ate/utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 9b732c3f8..f8145757b 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -369,7 +369,13 @@ def print_output(output): print('{:<16}: {:<}'.format("--------", "-----")) for variable, value in output.items(): - print('{:<16}: {:<}'.format( - variable.encode("utf-8"), value.encode("utf-8"))) + + if PYTHON_VERSION == 2: + if isinstance(variable, unicode): + variable = variable.encode("utf-8") + if isinstance(value, unicode): + value = value.encode("utf-8") + + print('{:<16}: {:<}'.format(variable, value)) print("============================================\n") From dc673a0d192b880db5a3fea65f9dafc3b97d0f97 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 19:55:25 +0800 Subject: [PATCH 261/354] bugfix: substitute_variables_with_mapping, handle when value is None, bool, empty string --- ate/testcase.py | 14 ++++++++++---- tests/test_testcase.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 408d0c2b6..0a8ff18e5 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -192,7 +192,16 @@ def substitute_variables_with_mapping(content, mapping): } } """ - if isinstance(content, (list, tuple)): + if isinstance(content, bool): + return content + + if isinstance(content, (int, utils.long_type, float, complex)): + return content + + if not content: + return content + + if isinstance(content, (list, set, tuple)): return [ substitute_variables_with_mapping(item, mapping) for item in content @@ -207,9 +216,6 @@ def substitute_variables_with_mapping(content, mapping): return substituted_data - if isinstance(content, (int, utils.long_type, float, complex)): - return content - # content is in string format here for var, value in mapping.items(): if content == var: diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 0a19345e7..c6919b858 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -428,7 +428,13 @@ def test_substitute_variables_with_mapping(self): 'request': { 'url': '/api/users/$uid', 'method': "$method", - 'headers': {'token': '$token'} + 'headers': {'token': '$token'}, + 'data': { + "null": None, + "true": True, + "false": False, + "empty_str": "" + } } } mapping = { @@ -439,6 +445,10 @@ def test_substitute_variables_with_mapping(self): self.assertEqual("/api/users/1000", result["request"]["url"]) self.assertEqual("$token", result["request"]["headers"]["token"]) self.assertEqual("POST", result["request"]["method"]) + self.assertIsNone(result["request"]["data"]["null"]) + self.assertTrue(result["request"]["data"]["true"]) + self.assertFalse(result["request"]["data"]["false"]) + self.assertEqual("", result["request"]["data"]["empty_str"]) def test_load_api_definition(self): path = os.path.join( From cf6c7e1a767386f8ff3ec13d42ab3450daedbf53 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 21:06:32 +0800 Subject: [PATCH 262/354] bugfix: keys that are not in 'request' of config shall not be lower cased --- ate/runner.py | 2 +- ate/utils.py | 13 ++++++++++++- tests/test_utils.py | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 35c5b43bd..2aa7a5546 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -51,7 +51,7 @@ def init_config(self, config_dict, level): @param (str) context level, testcase or testset """ # convert keys in request headers to lowercase - config_dict = utils.lower_dict_key(config_dict) + config_dict = utils.lower_config_dict_key(config_dict) self.context.init_context(level) self.context.config_context(config_dict, level) diff --git a/ate/utils.py b/ate/utils.py index f8145757b..aea20f3fd 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -300,7 +300,7 @@ def lower_dict_key(origin_dict, depth=1): new_dict = {} for key, value in origin_dict.items(): - if depth > 2: + if depth >= 2: new_dict[key] = value continue @@ -311,6 +311,17 @@ def lower_dict_key(origin_dict, depth=1): return new_dict +def lower_config_dict_key(config_dict): + """ convert key in config dict to lower case, convertion will occur in two places: + 1, all keys in config dict; + 2, all keys in config["request"] + """ + config_dict = lower_dict_key(config_dict) + if "request" in config_dict and isinstance(config_dict["request"], dict): + config_dict["request"] = lower_dict_key(config_dict["request"]) + + return config_dict + def convert_to_order_dict(map_list): """ convert mapping in list to ordered dict @param (list) map_list diff --git a/tests/test_utils.py b/tests/test_utils.py index 8a591e220..37d7faa5a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -222,7 +222,7 @@ def test_is_variable(self): self.assertFalse(utils.is_variable(("os", os))) self.assertFalse(utils.is_variable(("utils", utils))) - def test_lower_dict_key(self): + def test_handle_config_key_case(self): origin_dict = { "Name": "test", "Request": { @@ -234,7 +234,7 @@ def test_lower_dict_key(self): } } } - new_dict = utils.lower_dict_key(origin_dict) + new_dict = utils.lower_config_dict_key(origin_dict) self.assertIn("name", new_dict) self.assertIn("request", new_dict) self.assertIn("method", new_dict["request"]) From ffed8c38a2dc47936883c3913704d6682f21c780 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 21:17:20 +0800 Subject: [PATCH 263/354] lower_config_dict_key: add test for config['request'] is not dict --- tests/test_utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index 37d7faa5a..0c1d9ec1f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -242,6 +242,13 @@ def test_handle_config_key_case(self): self.assertIn("Accept", new_dict["request"]["headers"]) self.assertIn("User-Agent", new_dict["request"]["headers"]) + origin_dict = { + "Name": "test", + "Request": "$default_request" + } + new_dict = utils.lower_config_dict_key(origin_dict) + self.assertIn("$default_request", new_dict["request"]) + def test_convert_to_order_dict(self): map_list = [ {"a": 1}, From 0ac4ed4bc815bbd67142f5ab7f00425a92d4e6ab Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 20 Sep 2017 22:14:52 +0800 Subject: [PATCH 264/354] fix: handle the case when variable_binds is OrderedDict or some other data structure --- ate/utils.py | 9 ++++++++- tests/test_utils.py | 22 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index aea20f3fd..835335c11 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -366,8 +366,15 @@ def update_ordered_dict(ordered_dict, override_mapping): def override_variables_binds(variable_binds, new_mapping): """ convert variable_binds in testcase to ordered mapping, with new_mapping overrided """ + if isinstance(variable_binds, list): + variable_binds_ordered_dict = convert_to_order_dict(variable_binds) + elif isinstance(variable_binds, OrderedDict): + variable_binds_ordered_dict = variable_binds + else: + raise exception.ParamsError("variable_binds error!") + return update_ordered_dict( - convert_to_order_dict(variable_binds), + variable_binds_ordered_dict, new_mapping ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0c1d9ec1f..9cde84679 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,10 @@ import os -from ate import utils -from ate import exception +from collections import OrderedDict + +from ate import exception, utils from tests.base import ApiServerUnittest + class TestUtils(ApiServerUnittest): def test_load_testcases_bad_filepath(self): @@ -278,3 +280,19 @@ def test_override_variables_binds(self): new_dict = utils.override_variables_binds(map_list, override_mapping) self.assertEqual(3, new_dict["a"]) self.assertEqual(4, new_dict["c"]) + + map_list = OrderedDict( + { + "a": 1, + "b": 2 + } + ) + override_mapping = {"a": 3, "c": 4} + new_dict = utils.override_variables_binds(map_list, override_mapping) + self.assertEqual(3, new_dict["a"]) + self.assertEqual(4, new_dict["c"]) + + map_list = "invalid" + override_mapping = {"a": 3, "c": 4} + with self.assertRaises(exception.ParamsError): + utils.override_variables_binds(map_list, override_mapping) From 54286500eeade29d3b3587d63dc9713bcaca6c73 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 21 Sep 2017 15:59:13 +0800 Subject: [PATCH 265/354] print_output defaults to debug level --- ate/utils.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 835335c11..202c5465f 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -4,6 +4,7 @@ import imp import importlib import json +import logging import os.path import random import re @@ -382,9 +383,9 @@ def print_output(output): if not output: return - print("\n================== Output ==================") - print('{:<16}: {:<}'.format("Variable", "Value")) - print('{:<16}: {:<}'.format("--------", "-----")) + content = "\n================== Output ==================\n" + content += '{:<16}: {:<}\n'.format("Variable", "Value") + content += '{:<16}: {:<}\n'.format("--------", "-----") for variable, value in output.items(): @@ -394,6 +395,8 @@ def print_output(output): if isinstance(value, unicode): value = value.encode("utf-8") - print('{:<16}: {:<}'.format(variable, value)) + content += '{:<16}: {:<}\n'.format(variable, value) - print("============================================\n") + content += "============================================\n" + + logging.debug(content) From 6ef46224f6247e9e80607e15149ef633c80cca9f Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 21 Sep 2017 20:54:06 +0800 Subject: [PATCH 266/354] refactor api loading behavior: api should be located in /tests/api/ folder --- ate/testcase.py | 68 +++++++++++++--------------- ate/utils.py | 7 +-- tests/{data/api.yml => api/demo.yml} | 0 tests/test_testcase.py | 6 +-- tests/test_utils.py | 8 ++-- 5 files changed, 38 insertions(+), 51 deletions(-) rename tests/{data/api.yml => api/demo.yml} (100%) diff --git a/ate/testcase.py b/ate/testcase.py index 0a8ff18e5..6f6be6438 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -88,14 +88,12 @@ def parse_function(content): return function_meta -def load_testcases_by_path(path, file_type="test"): +def load_testcases_by_path(path): """ load testcases from file path - @param - path: path could be in several type - - absolute/relative file path - - absolute/relative folder path - - list/set container with file(s) and/or folder(s) - file_type: "test" or "suite" + @param path: path could be in several type + - absolute/relative file path + - absolute/relative folder path + - list/set container with file(s) and/or folder(s) @return testcase sets list, each testset is corresponding to a file [ {"name": "desc1", "config": {}, "testcases": [testcase11, testcase12]}, @@ -106,7 +104,7 @@ def load_testcases_by_path(path, file_type="test"): testsets_list = [] for file_path in set(path): - _testsets_list = load_testcases_by_path(file_path, file_type) + _testsets_list = load_testcases_by_path(file_path) testsets_list.extend(_testsets_list) return testsets_list @@ -115,8 +113,8 @@ def load_testcases_by_path(path, file_type="test"): path = os.path.join(os.getcwd(), path) if os.path.isdir(path): - files_list = utils.load_folder_files(path, file_type=file_type, recursive=True) - return load_testcases_by_path(files_list, file_type) + files_list = utils.load_folder_files(path) + return load_testcases_by_path(files_list) elif os.path.isfile(path): testset = { @@ -127,7 +125,6 @@ def load_testcases_by_path(path, file_type="test"): "testcases": [] } testcases_list = utils.load_testcases(path) - dir_path = os.path.dirname(os.path.abspath(path)) for item in testcases_list: for key in item: @@ -137,7 +134,7 @@ def load_testcases_by_path(path, file_type="test"): elif key == "test": test_block_dict = item["test"] if "api" in test_block_dict: - testcase_list = load_testcases_by_call(test_block_dict, dir_path, "api") + testcase_list = load_testcases_by_call(test_block_dict, "api") else: testcase_list = [test_block_dict] @@ -148,12 +145,12 @@ def load_testcases_by_path(path, file_type="test"): else: return [] -def load_testcases_by_call(test_block_dict, dir_path, call_type): +def load_testcases_by_call(test_block_dict, call_type): api_call = test_block_dict[call_type] function_meta = parse_function(api_call) func_name = function_meta["func_name"] api_call_args = function_meta["args"] - api_info = get_api_definition(func_name, dir_path) + api_info = get_api_definition(func_name) api_def_args = api_info.get("function_meta").get("args", []) if len(api_call_args) != len(api_def_args): @@ -226,43 +223,40 @@ def substitute_variables_with_mapping(content, mapping): return content -def get_api_definition(name, dir_path): - """ get expected api from dir_path upward recursively +def get_api_definition(name, dir_path=None): + """ get expected api from dir_path. + By default, dir_path is "$CWD/tests/api/" @param name: api name - dir_path: start search dir path + dir_path: specified api dir path @return expected api info if found, otherwise raise ApiNotFound exception """ - api_dir_dict = api_overall_dict.get(dir_path) - if not api_dir_dict: - api_dir_dict = load_api_definition(dir_path) - api_overall_dict[dir_path] = api_dir_dict - - api_info = api_dir_dict.get(name) - if api_info: - return api_info - - parent_dir_path = os.path.dirname(dir_path) - if dir_path == parent_dir_path: - # system root path - err_msg = "{} not found in recursive upward path!".format(name) + global api_overall_dict + if not api_overall_dict: + api_overall_dict.update(load_api_definition(dir_path)) + + api_info = api_overall_dict.get(name) + if not api_info: + err_msg = "API {} not found!".format(name) raise exception.ApiNotFound(err_msg) - return get_api_definition(name, parent_dir_path) + return api_info -def load_api_definition(dir_path): - """ load all api definitions in specified dir path +def load_api_definition(dir_path=None): + """ load all api definitions in specified dir path. + By default, dir_path is "$CWD/tests/api/" @param (str) dir_path @return (dict) all api definitions in dir_path merged in one dict """ - api_files = utils.load_folder_files(dir_path, file_type="api", recursive=False) + api_dir_path = dir_path or os.path.join(os.getcwd(), "tests", "api") + api_files = utils.load_folder_files(api_dir_path) api_def_list = [] for api_file in api_files: api_def_list.extend(utils.load_testcases(api_file)) - api_dir_dict = {} + api_dict = {} for item in api_def_list: for key in item: @@ -274,9 +268,9 @@ def load_api_definition(dir_path): api_info = {} api_info["function_meta"] = function_meta api_info.update(item["api"]) - api_dir_dict[func_name] = api_info + api_dict[func_name] = api_info - return api_dir_dict + return api_dict class TestcaseParser(object): diff --git a/ate/utils.py b/ate/utils.py index 202c5465f..c27e4f4d6 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -59,11 +59,10 @@ def load_testcases(testcase_file_path): # '' or other suffix return [] -def load_folder_files(folder_path, file_type, recursive=False): +def load_folder_files(folder_path, recursive=True): """ load folder path, return all files in list format. @param folder_path: specified folder path to load - file_type: "test" or "api" recursive: if True, will load files recursively """ file_list = [] @@ -72,13 +71,9 @@ def load_folder_files(folder_path, file_type, recursive=False): filenames_list = [] for filename in filenames: - if not filename.endswith(('.yml', '.yaml', '.json')): continue - if file_type == "api" and not filename.startswith(('api.', 'api-')): - continue - filenames_list.append(filename) for filename in filenames_list: diff --git a/tests/data/api.yml b/tests/api/demo.yml similarity index 100% rename from tests/data/api.yml rename to tests/api/demo.yml diff --git a/tests/test_testcase.py b/tests/test_testcase.py index c6919b858..524697140 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -451,9 +451,7 @@ def test_substitute_variables_with_mapping(self): self.assertEqual("", result["request"]["data"]["empty_str"]) def test_load_api_definition(self): - path = os.path.join( - os.getcwd(), 'tests/data') - api_dir_dict = testcase.load_api_definition(path) + api_dir_dict = testcase.load_api_definition() self.assertIn("get_token", api_dir_dict) self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) self.assertIn("$user_agent", api_dir_dict["get_token"]["function_meta"]["args"]) @@ -464,7 +462,7 @@ def test_get_api_definition(self): os.getcwd(), 'tests/data') api_info = testcase.get_api_definition("get_token", path) self.assertEqual("/api/get-token", api_info["request"]["url"]) - self.assertIn(path, testcase.api_overall_dict) + self.assertIn("get_token", testcase.api_overall_dict) with self.assertRaises(ApiNotFound): testcase.get_api_definition("api_not_exist", path) diff --git a/tests/test_utils.py b/tests/test_utils.py index 9cde84679..221ee09e4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -38,15 +38,15 @@ def test_load_folder_files(self): file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') file2 = os.path.join(os.getcwd(), 'tests', 'data', 'demo_binds.yml') - files = utils.load_folder_files(folder, file_type="test", recursive=False) + files = utils.load_folder_files(folder, recursive=False) self.assertNotIn(file2, files) - files = utils.load_folder_files(folder, file_type="test", recursive=True) + files = utils.load_folder_files(folder) self.assertIn(file2, files) self.assertNotIn(file1, files) - files = utils.load_folder_files(folder, file_type="api", recursive=True) - api_file = os.path.join(os.getcwd(), 'tests', 'data', 'api.yml') + files = utils.load_folder_files(folder) + api_file = os.path.join(os.getcwd(), 'tests', 'api', 'demo.yml') self.assertEqual(files[0], api_file) def test_query_json(self): From 834600bb4109b9ffd9d2f3c3071df994c7d6aba2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 22 Sep 2017 11:48:29 +0800 Subject: [PATCH 267/354] adjust code structure --- ate/exception.py | 3 + ate/testcase.py | 202 +++++++++++++++++++++++++---------------- tests/test_testcase.py | 8 +- 3 files changed, 130 insertions(+), 83 deletions(-) diff --git a/ate/exception.py b/ate/exception.py index 028ec98b7..38460efc6 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -27,3 +27,6 @@ class VariableNotFound(NameError): class ApiNotFound(NameError): pass + +class SuiteNotFound(NameError): + pass diff --git a/ate/testcase.py b/ate/testcase.py index 6f6be6438..97aa6c235 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -7,7 +7,7 @@ variable_regexp = r"\$([\w_]+)" function_regexp = r"\$\{([\w_]+\([\$\w_ =,]*\))\}" function_regexp_compile = re.compile(r"^([\w_]+)\(([\$\w_ =,]*)\)$") -api_overall_dict = {} +test_def_overall_dict = {} def extract_variables(content): @@ -96,8 +96,8 @@ def load_testcases_by_path(path): - list/set container with file(s) and/or folder(s) @return testcase sets list, each testset is corresponding to a file [ - {"name": "desc1", "config": {}, "testcases": [testcase11, testcase12]}, - {"name": "desc2", "config": {}, "testcases": [testcase21, testcase22, testcase23]}, + testset_dict_1, + testset_dict_2 ] """ if isinstance(path, (list, set)): @@ -117,41 +117,71 @@ def load_testcases_by_path(path): return load_testcases_by_path(files_list) elif os.path.isfile(path): - testset = { - "name": "", - "config": { - "path": path - }, - "testcases": [] - } - testcases_list = utils.load_testcases(path) + return load_test_file(path) - for item in testcases_list: - for key in item: - if key == "config": - testset["config"].update(item["config"]) - testset["name"] = item["config"].get("name", "") - elif key == "test": - test_block_dict = item["test"] - if "api" in test_block_dict: - testcase_list = load_testcases_by_call(test_block_dict, "api") - else: - testcase_list = [test_block_dict] + else: + return [] - testset["testcases"].extend(testcase_list) +def load_test_file(file_path): + """ load testset file, get testset data structure. + @param file_path: absolute valid testset file path + @return testset dict + { + "name": "desc1", + "config": {}, + "api": {}, + "testcases": [testcase11, testcase12] + } + """ + testset = { + "name": "", + "config": { + "path": file_path + }, + "api": {}, + "testcases": [] + } + testcases_list = utils.load_testcases(file_path) - return [testset] if testset["testcases"] else [] + for item in testcases_list: + for key in item: + if key == "config": + testset["config"].update(item["config"]) + testset["name"] = item["config"].get("name", "") + + elif key == "test": + test_block_dict = item["test"] + if "api" in test_block_dict: + testcase_list = load_testcases_by_call(test_block_dict, "api") + elif "suite" in test_block_dict: + testcase_list = load_testcases_by_call(test_block_dict, "suite") + else: + testcase_list = [test_block_dict] + + testset["testcases"].extend(testcase_list) + + elif key == "api": + api_def = item["api"].pop("def") + function_meta = parse_function(api_def) + func_name = function_meta["func_name"] + + api_info = {} + api_info["function_meta"] = function_meta + api_info.update(item["api"]) + testset["api"][func_name] = api_info + if testset["testcases"] or testset["api"]: + return [testset] else: return [] def load_testcases_by_call(test_block_dict, call_type): - api_call = test_block_dict[call_type] - function_meta = parse_function(api_call) + call_func = test_block_dict[call_type] + function_meta = parse_function(call_func) func_name = function_meta["func_name"] api_call_args = function_meta["args"] - api_info = get_api_definition(func_name) - api_def_args = api_info.get("function_meta").get("args", []) + test_info = get_test_definition(func_name, call_type) + api_def_args = test_info.get("function_meta").get("args", []) if len(api_call_args) != len(api_def_args): raise exception.ParamsError("api call args invalid!") @@ -164,12 +194,75 @@ def load_testcases_by_call(test_block_dict, call_type): args_mapping[item] = api_call_args[index] if args_mapping: - api_info = substitute_variables_with_mapping(api_info, args_mapping) + test_info = substitute_variables_with_mapping(test_info, args_mapping) - test_block_dict.update(api_info) + test_block_dict.update(test_info) return [test_block_dict] +def get_test_definition(name, call_type, dir_path=None): + """ get expected api or suite from dir_path. + @params: + name: api name + call_type: "api" or "suite" + dir_path: specified api dir path, default is "$CWD/tests/api/" + @return + expected api info if found, otherwise raise ApiNotFound exception + """ + global test_def_overall_dict + if call_type not in test_def_overall_dict: + test_def_overall_dict[call_type] = {} + + test_def_overall_dict[call_type].update(load_test_definition(call_type, dir_path)) + test_info = test_def_overall_dict[call_type].get(name) + if not test_info: + err_msg = "{} {} not found!".format(call_type, name) + if call_type == "api": + raise exception.ApiNotFound(err_msg) + elif call_type == "suite": + raise exception.SuiteNotFound(err_msg) + else: + raise exception.ParamsError("call_type can only be api or suite!") + + return test_info + +def load_test_definition(call_type, dir_path=None): + """ load all api or suite definitions in specified dir path. + @params: + call_type: "api" or "suite" + dir_path: specified api dir path, default is "$CWD/tests/api/" + @return (dict) all api definitions in dir_path merged in one dict + """ + dir_path = dir_path or os.path.join(os.getcwd(), "tests", call_type) + api_files = utils.load_folder_files(dir_path) + + test_def_dict = {} + for test_file in api_files: + testset = load_testcases_by_path(test_file) + if not testset: + continue + + suite = testset[0] + + if call_type == "api": + test_dict = suite["api"] + + elif call_type == "suite": + if "def" not in suite["config"]: + continue + + call_func = suite["config"]["def"] + function_meta = parse_function(call_func) + suite["function_meta"] = function_meta + + test_dict = { + function_meta["func_name"]: suite + } + + test_def_dict.update(test_dict) + + return test_def_dict + def substitute_variables_with_mapping(content, mapping): """ substitute variables in content with mapping e.g. @@ -223,55 +316,6 @@ def substitute_variables_with_mapping(content, mapping): return content -def get_api_definition(name, dir_path=None): - """ get expected api from dir_path. - By default, dir_path is "$CWD/tests/api/" - @param - name: api name - dir_path: specified api dir path - @return - expected api info if found, otherwise raise ApiNotFound exception - """ - global api_overall_dict - if not api_overall_dict: - api_overall_dict.update(load_api_definition(dir_path)) - - api_info = api_overall_dict.get(name) - if not api_info: - err_msg = "API {} not found!".format(name) - raise exception.ApiNotFound(err_msg) - - return api_info - -def load_api_definition(dir_path=None): - """ load all api definitions in specified dir path. - By default, dir_path is "$CWD/tests/api/" - @param (str) dir_path - @return (dict) all api definitions in dir_path merged in one dict - """ - api_dir_path = dir_path or os.path.join(os.getcwd(), "tests", "api") - api_files = utils.load_folder_files(api_dir_path) - - api_def_list = [] - for api_file in api_files: - api_def_list.extend(utils.load_testcases(api_file)) - - api_dict = {} - - for item in api_def_list: - for key in item: - if key == "api": - api_def = item["api"].pop("def") - function_meta = parse_function(api_def) - func_name = function_meta["func_name"] - - api_info = {} - api_info["function_meta"] = function_meta - api_info.update(item["api"]) - api_dict[func_name] = api_info - - return api_dict - class TestcaseParser(object): diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 524697140..88bfa790c 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -451,7 +451,7 @@ def test_substitute_variables_with_mapping(self): self.assertEqual("", result["request"]["data"]["empty_str"]) def test_load_api_definition(self): - api_dir_dict = testcase.load_api_definition() + api_dir_dict = testcase.load_test_definition("api") self.assertIn("get_token", api_dir_dict) self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) self.assertIn("$user_agent", api_dir_dict["get_token"]["function_meta"]["args"]) @@ -460,9 +460,9 @@ def test_load_api_definition(self): def test_get_api_definition(self): path = os.path.join( os.getcwd(), 'tests/data') - api_info = testcase.get_api_definition("get_token", path) + api_info = testcase.get_test_definition("get_token", "api", path) self.assertEqual("/api/get-token", api_info["request"]["url"]) - self.assertIn("get_token", testcase.api_overall_dict) + self.assertIn("get_token", testcase.test_def_overall_dict["api"]) with self.assertRaises(ApiNotFound): - testcase.get_api_definition("api_not_exist", path) + testcase.get_test_definition("api_not_exist", "api", path) From 103dc8d724f12cbb667c09f6653605625c181ca9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 22 Sep 2017 14:31:23 +0800 Subject: [PATCH 268/354] rename functions --- ate/locustfile_template | 3 +-- ate/locusts.py | 6 +++--- ate/testcase.py | 16 ++++++++-------- ate/utils.py | 2 +- tests/test_context.py | 2 +- tests/test_runner.py | 34 +++++++++++++++++----------------- tests/test_task.py | 6 +++--- tests/test_utils.py | 6 +++--- 8 files changed, 37 insertions(+), 38 deletions(-) diff --git a/ate/locustfile_template b/ate/locustfile_template index 235c21a7d..3a3bcd1c2 100644 --- a/ate/locustfile_template +++ b/ate/locustfile_template @@ -22,5 +22,4 @@ class WebPageUser(HttpLocust): min_wait = 1000 max_wait = 5000 - testsets = testcase.load_testcases_by_path("$TESTCASE_FILE") - testset = testsets[0] + testset = testcase.load_test_file("$TESTCASE_FILE") diff --git a/ate/locusts.py b/ate/locusts.py index 7bc5a129b..1f808d2f6 100644 --- a/ate/locusts.py +++ b/ate/locusts.py @@ -3,7 +3,7 @@ import os import sys -from ate.testcase import load_testcases_by_path +from ate.testcase import load_test_file from locust.main import main @@ -36,8 +36,8 @@ def gen_locustfile(testcase_file_path): os.path.dirname(os.path.realpath(__file__)), 'locustfile_template' ) - testsets = load_testcases_by_path(testcase_file_path) - host = testsets[0].get("config", {}).get("request", {}).get("base_url", "") + testset = load_test_file(testcase_file_path) + host = testset.get("config", {}).get("request", {}).get("base_url", "") with codecs.open(template_path, encoding='utf-8') as template: with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: diff --git a/ate/testcase.py b/ate/testcase.py index 97aa6c235..ea7b5a624 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -117,8 +117,11 @@ def load_testcases_by_path(path): return load_testcases_by_path(files_list) elif os.path.isfile(path): - return load_test_file(path) - + testset = load_test_file(path) + if testset["testcases"] or testset["api"]: + return [testset] + else: + return [] else: return [] @@ -141,9 +144,9 @@ def load_test_file(file_path): "api": {}, "testcases": [] } - testcases_list = utils.load_testcases(file_path) + tests_list = utils.load_tests(file_path) - for item in testcases_list: + for item in tests_list: for key in item: if key == "config": testset["config"].update(item["config"]) @@ -170,10 +173,7 @@ def load_test_file(file_path): api_info.update(item["api"]) testset["api"][func_name] = api_info - if testset["testcases"] or testset["api"]: - return [testset] - else: - return [] + return testset def load_testcases_by_call(test_block_dict, call_type): call_func = test_block_dict[call_type] diff --git a/ate/utils.py b/ate/utils.py index c27e4f4d6..9a82b0687 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -49,7 +49,7 @@ def load_json_file(json_file): with codecs.open(json_file, encoding='utf-8') as data_file: return json.load(data_file) -def load_testcases(testcase_file_path): +def load_tests(testcase_file_path): file_suffix = os.path.splitext(testcase_file_path)[1] if file_suffix == '.json': return load_json_file(testcase_file_path) diff --git a/tests/test_context.py b/tests/test_context.py index ef276be4b..a1fc774fe 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -12,7 +12,7 @@ class VariableBindsUnittest(unittest.TestCase): def setUp(self): self.context = Context() testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_binds.yml') - self.testcases = utils.load_testcases(testcase_file_path) + self.testcases = utils.load_tests(testcase_file_path) def test_context_init_functions(self): self.assertIn("get_timestamp", self.context.testset_functions_config) diff --git a/tests/test_runner.py b/tests/test_runner.py index 185e43f29..97da810b9 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,8 +1,8 @@ import os import requests -from ate import exception, runner, utils -from ate.testcase import load_testcases_by_path +from ate import exception, runner, testcase, utils + from tests.base import ApiServerUnittest @@ -26,7 +26,7 @@ def reset_all(self): def test_run_single_testcase(self): for testcase_file_path in self.testcase_file_path_list: - testcases = utils.load_testcases(testcase_file_path) + testcases = utils.load_tests(testcase_file_path) testcase = testcases[0]["test"] self.assertTrue(self.test_runner.run_test(testcase)) @@ -67,66 +67,66 @@ def test_run_single_testcase_fail(self): def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: - testsets = load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testset(testsets[0]) + testset = testcase.load_test_file(testcase_file_path) + result = self.test_runner.run_testset(testset) self.assertTrue(result["success"]) def test_run_testsets_hardcode(self): for testcase_file_path in self.testcase_file_path_list: - testsets = load_testcases_by_path(testcase_file_path) + testsets = testcase.load_testcases_by_path(testcase_file_path) result = self.test_runner.run_testsets(testsets) self.assertTrue(result) def test_run_testset_template_variables(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_variables.yml') - testsets = load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testset(testsets[0]) + testset = testcase.load_test_file(testcase_file_path) + result = self.test_runner.run_testset(testset) self.assertTrue(result["success"]) def test_run_testset_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') - testsets = load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testset(testsets[0]) + testset = testcase.load_test_file(testcase_file_path) + result = self.test_runner.run_testset(testset) self.assertTrue(result["success"]) def test_run_testsets_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') - testsets = load_testcases_by_path(testcase_file_path) + testsets = testcase.load_testcases_by_path(testcase_file_path) result = self.test_runner.run_testsets(testsets) self.assertTrue(result) def test_run_testsets_template_lambda_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_lambda_functions.yml') - testsets = load_testcases_by_path(testcase_file_path) + testsets = testcase.load_testcases_by_path(testcase_file_path) result = self.test_runner.run_testsets(testsets) self.assertTrue(result) def test_run_testset_layered(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testsets = load_testcases_by_path(testcase_file_path) + testsets = testcase.load_testcases_by_path(testcase_file_path) result = self.test_runner.run_testsets(testsets) self.assertTrue(result) def test_run_testset_output(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testsets = load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testset(testsets[0]) + testset = testcase.load_test_file(testcase_file_path) + result = self.test_runner.run_testset(testset) self.assertTrue(result["success"]) self.assertIn("token", result["output"]) def test_run_testset_with_variables_mapping(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testsets = load_testcases_by_path(testcase_file_path) + testset = testcase.load_test_file(testcase_file_path) variables_mapping = { "app_version": '2.9.7' } - result = self.test_runner.run_testset(testsets[0], variables_mapping) + result = self.test_runner.run_testset(testset, variables_mapping) self.assertTrue(result["success"]) self.assertIn("token", result["output"]) diff --git a/tests/test_task.py b/tests/test_task.py index c30a01d21..61fe7f9e5 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,7 +1,7 @@ import os from ate import task -from ate.testcase import load_testcases_by_path +from ate.testcase import load_test_file from tests.base import ApiServerUnittest @@ -17,8 +17,8 @@ def reset_all(self): def test_create_suite(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_testset_variables.yml') - testsets = load_testcases_by_path(testcase_file_path) - suite = task.ApiTestSuite(testsets[0]) + testset = load_test_file(testcase_file_path) + suite = task.ApiTestSuite(testset) self.assertEqual(suite.countTestCases(), 3) for testcase in suite: self.assertIsInstance(testcase, task.ApiTestCase) diff --git a/tests/test_utils.py b/tests/test_utils.py index 221ee09e4..9acd913e9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,12 +9,12 @@ class TestUtils(ApiServerUnittest): def test_load_testcases_bad_filepath(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo') - self.assertEqual(utils.load_testcases(testcase_file_path), []) + self.assertEqual(utils.load_tests(testcase_file_path), []) def test_load_json_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_hardcode.json') - testcases = utils.load_testcases(testcase_file_path) + testcases = utils.load_tests(testcase_file_path) self.assertEqual(len(testcases), 3) testcase = testcases[0]["test"] self.assertIn('name', testcase) @@ -25,7 +25,7 @@ def test_load_json_testcases(self): def test_load_yaml_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_hardcode.yml') - testcases = utils.load_testcases(testcase_file_path) + testcases = utils.load_tests(testcase_file_path) self.assertEqual(len(testcases), 3) testcase = testcases[0]["test"] self.assertIn('name', testcase) From 2513886863a1f424aa09dc59258135d0447399f0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 22 Sep 2017 19:04:29 +0800 Subject: [PATCH 269/354] load_folder_files: support passing in folder list --- ate/utils.py | 10 ++++++++++ tests/test_utils.py | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 9a82b0687..637466211 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -65,6 +65,16 @@ def load_folder_files(folder_path, recursive=True): folder_path: specified folder path to load recursive: if True, will load files recursively """ + if isinstance(folder_path, (list, set)): + files = [] + for path in set(folder_path): + files.extend(load_folder_files(path, recursive)) + + return files + + if not os.path.exists(folder_path): + return [] + file_list = [] for dirpath, dirnames, filenames in os.walk(folder_path): diff --git a/tests/test_utils.py b/tests/test_utils.py index 9acd913e9..80d424674 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -45,9 +45,21 @@ def test_load_folder_files(self): self.assertIn(file2, files) self.assertNotIn(file1, files) - files = utils.load_folder_files(folder) + files_1 = utils.load_folder_files(folder) + api_file = os.path.join(os.getcwd(), 'tests', 'api', 'demo.yml') + self.assertEqual(files_1[0], api_file) + + folder_list = [folder, folder] + files_2 = utils.load_folder_files(folder) api_file = os.path.join(os.getcwd(), 'tests', 'api', 'demo.yml') - self.assertEqual(files[0], api_file) + self.assertEqual(files_2[0], api_file) + self.assertEqual(len(files_1), len(files_2)) + + files = utils.load_folder_files("not_existed_foulder", recursive=False) + self.assertEqual([], files) + + files = utils.load_folder_files(file2, recursive=False) + self.assertEqual([], files) def test_query_json(self): json_content = { From 8fb3e0b73a326c0c979bcdf5ce42edeef740d99f Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 24 Sep 2017 14:09:25 +0800 Subject: [PATCH 270/354] support run test suites set file --- ate/__init__.py | 2 +- ate/runner.py | 3 +- ate/testcase.py | 158 ++++++++++++++++++++--------------------- tests/test_testcase.py | 21 +++--- 4 files changed, 93 insertions(+), 91 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 285367e4d..0ffab71e2 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.2' \ No newline at end of file +__version__ = '0.7.3' \ No newline at end of file diff --git a/ate/runner.py b/ate/runner.py index 2aa7a5546..2582842cc 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,6 +1,6 @@ from collections import OrderedDict -from ate import exception, response, utils +from ate import exception, response, testcase, utils from ate.client import HttpSession from ate.context import Context @@ -10,6 +10,7 @@ class Runner(object): def __init__(self, http_client_session=None): self.http_client_session = http_client_session self.context = Context() + testcase.load_test_dependencies() def init_config(self, config_dict, level): """ create/update context variables binds diff --git a/ate/testcase.py b/ate/testcase.py index ea7b5a624..fd4dd829f 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -7,7 +7,11 @@ variable_regexp = r"\$([\w_]+)" function_regexp = r"\$\{([\w_]+\([\$\w_ =,]*\))\}" function_regexp_compile = re.compile(r"^([\w_]+)\(([\$\w_ =,]*)\)$") -test_def_overall_dict = {} +test_def_overall_dict = { + "loaded": False, + "api": {}, + "suite": {} +} def extract_variables(content): @@ -88,6 +92,37 @@ def parse_function(content): return function_meta +def load_test_dependencies(): + """ load all api and suite definitions. + default api folder is "$CWD/tests/api/". + default suite folder is "$CWD/tests/suite/". + """ + test_def_overall_dict["loaded"] = True + test_def_overall_dict["api"] = {} + test_def_overall_dict["suite"] = {} + + # load api definitions + api_def_folder = os.path.join(os.getcwd(), "tests", "api") + api_files = utils.load_folder_files(api_def_folder) + + for test_file in api_files: + testset = load_test_file(test_file) + test_def_overall_dict["api"].update(testset["api"]) + + # load suite definitions + suite_def_folder = os.path.join(os.getcwd(), "tests", "suite") + suite_files = utils.load_folder_files(suite_def_folder) + + for suite_file in suite_files: + suite = load_test_file(suite_file) + if "def" not in suite["config"]: + raise exception.ParamsError("def missed in suite file: {}!".format(suite_file)) + + call_func = suite["config"]["def"] + function_meta = parse_function(call_func) + suite["function_meta"] = function_meta + test_def_overall_dict["suite"][function_meta["func_name"]] = suite + def load_testcases_by_path(path): """ load testcases from file path @param path: path could be in several type @@ -101,13 +136,15 @@ def load_testcases_by_path(path): ] """ if isinstance(path, (list, set)): - testsets_list = [] + testsets = [] for file_path in set(path): - _testsets_list = load_testcases_by_path(file_path) - testsets_list.extend(_testsets_list) + testset = load_testcases_by_path(file_path) + if not testset: + continue + testsets.extend(testset) - return testsets_list + return testsets if not os.path.isabs(path): path = os.path.join(os.getcwd(), path) @@ -120,10 +157,8 @@ def load_testcases_by_path(path): testset = load_test_file(path) if testset["testcases"] or testset["api"]: return [testset] - else: - return [] - else: - return [] + + return [] def load_test_file(file_path): """ load testset file, get testset data structure. @@ -155,13 +190,16 @@ def load_test_file(file_path): elif key == "test": test_block_dict = item["test"] if "api" in test_block_dict: - testcase_list = load_testcases_by_call(test_block_dict, "api") + ref_name = test_block_dict["api"] + test_info = get_testinfo_by_reference(ref_name, "api") + test_block_dict.update(test_info) + testset["testcases"].append(test_block_dict) elif "suite" in test_block_dict: - testcase_list = load_testcases_by_call(test_block_dict, "suite") + ref_name = test_block_dict["suite"] + test_info = get_testinfo_by_reference(ref_name, "suite") + testset["testcases"].extend(test_info["testcases"]) else: - testcase_list = [test_block_dict] - - testset["testcases"].extend(testcase_list) + testset["testcases"].append(test_block_dict) elif key == "api": api_def = item["api"].pop("def") @@ -175,94 +213,56 @@ def load_test_file(file_path): return testset -def load_testcases_by_call(test_block_dict, call_type): - call_func = test_block_dict[call_type] - function_meta = parse_function(call_func) +def get_testinfo_by_reference(ref_name, ref_type): + """ get test content by reference name + @params: + ref_name: reference name, e.g. api_v1_Account_Login_POST($UserName, $Password) + ref_type: "api" or "suite" + """ + function_meta = parse_function(ref_name) func_name = function_meta["func_name"] - api_call_args = function_meta["args"] - test_info = get_test_definition(func_name, call_type) - api_def_args = test_info.get("function_meta").get("args", []) + call_args = function_meta["args"] + test_info = get_test_definition(func_name, ref_type) + def_args = test_info.get("function_meta").get("args", []) - if len(api_call_args) != len(api_def_args): - raise exception.ParamsError("api call args invalid!") + if len(call_args) != len(def_args): + raise exception.ParamsError("call args mismatch defined args!") args_mapping = {} - for index, item in enumerate(api_def_args): - if api_call_args[index] == item: + for index, item in enumerate(def_args): + if call_args[index] == item: continue - args_mapping[item] = api_call_args[index] + args_mapping[item] = call_args[index] if args_mapping: test_info = substitute_variables_with_mapping(test_info, args_mapping) - test_block_dict.update(test_info) - - return [test_block_dict] + return test_info -def get_test_definition(name, call_type, dir_path=None): - """ get expected api or suite from dir_path. +def get_test_definition(name, ref_type): + """ get expected api or suite. @params: - name: api name - call_type: "api" or "suite" - dir_path: specified api dir path, default is "$CWD/tests/api/" + name: api or suite name + ref_type: "api" or "suite" @return expected api info if found, otherwise raise ApiNotFound exception """ - global test_def_overall_dict - if call_type not in test_def_overall_dict: - test_def_overall_dict[call_type] = {} + if not test_def_overall_dict.get("loaded", False): + load_test_dependencies() - test_def_overall_dict[call_type].update(load_test_definition(call_type, dir_path)) - test_info = test_def_overall_dict[call_type].get(name) + test_info = test_def_overall_dict.get(ref_type, {}).get(name) if not test_info: - err_msg = "{} {} not found!".format(call_type, name) - if call_type == "api": + err_msg = "{} {} not found!".format(ref_type, name) + if ref_type == "api": raise exception.ApiNotFound(err_msg) - elif call_type == "suite": + elif ref_type == "suite": raise exception.SuiteNotFound(err_msg) else: - raise exception.ParamsError("call_type can only be api or suite!") + raise exception.ParamsError("ref_type can only be api or suite!") return test_info -def load_test_definition(call_type, dir_path=None): - """ load all api or suite definitions in specified dir path. - @params: - call_type: "api" or "suite" - dir_path: specified api dir path, default is "$CWD/tests/api/" - @return (dict) all api definitions in dir_path merged in one dict - """ - dir_path = dir_path or os.path.join(os.getcwd(), "tests", call_type) - api_files = utils.load_folder_files(dir_path) - - test_def_dict = {} - for test_file in api_files: - testset = load_testcases_by_path(test_file) - if not testset: - continue - - suite = testset[0] - - if call_type == "api": - test_dict = suite["api"] - - elif call_type == "suite": - if "def" not in suite["config"]: - continue - - call_func = suite["config"]["def"] - function_meta = parse_function(call_func) - suite["function_meta"] = function_meta - - test_dict = { - function_meta["func_name"]: suite - } - - test_def_dict.update(test_dict) - - return test_def_dict - def substitute_variables_with_mapping(content, mapping): """ substitute variables in content with mapping e.g. diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 88bfa790c..a94d2d932 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -450,19 +450,20 @@ def test_substitute_variables_with_mapping(self): self.assertFalse(result["request"]["data"]["false"]) self.assertEqual("", result["request"]["data"]["empty_str"]) - def test_load_api_definition(self): - api_dir_dict = testcase.load_test_definition("api") - self.assertIn("get_token", api_dir_dict) - self.assertEqual("/api/get-token", api_dir_dict["get_token"]["request"]["url"]) - self.assertIn("$user_agent", api_dir_dict["get_token"]["function_meta"]["args"]) - self.assertIn("create_user", api_dir_dict) + def test_load_test_dependencies(self): + testcase.test_def_overall_dict = {} + testcase.load_test_dependencies() + self.assertTrue(testcase.test_def_overall_dict["loaded"]) + api_dict = testcase.test_def_overall_dict["api"] + self.assertIn("get_token", api_dict) + self.assertEqual("/api/get-token", api_dict["get_token"]["request"]["url"]) + self.assertIn("$user_agent", api_dict["get_token"]["function_meta"]["args"]) + self.assertIn("create_user", api_dict) def test_get_api_definition(self): - path = os.path.join( - os.getcwd(), 'tests/data') - api_info = testcase.get_test_definition("get_token", "api", path) + api_info = testcase.get_test_definition("get_token", "api") self.assertEqual("/api/get-token", api_info["request"]["url"]) self.assertIn("get_token", testcase.test_def_overall_dict["api"]) with self.assertRaises(ApiNotFound): - testcase.get_test_definition("api_not_exist", "api", path) + testcase.get_test_definition("api_not_exist", "api") From 94b6202d2deed871d575fe2019ae0429784f6eb4 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 25 Sep 2017 15:32:42 +0800 Subject: [PATCH 271/354] runner: replace run_testsets method --- ate/runner.py | 31 ++++++++++++++++++++----------- tests/test_runner.py | 20 ++++++++------------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 2582842cc..fadc59c5d 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -151,7 +151,7 @@ def run_testset(self, testset, variables_mapping=None): (dict) variables_mapping: passed in variables mapping, it will override variable_binds in config block - @return (dict) test result of testcases + @return (dict) test result of testset { "success": True, "output": {} # variables mapping @@ -179,24 +179,33 @@ def run_testset(self, testset, variables_mapping=None): "output": self.generate_output(output_variables_list) } - def run_testsets(self, testsets): - """ run testsets, including one or several testsets. - @param testsets - [ - testset1, - testset2, - ] - @return (bool) test result of testsets + def run(self, path, mapping=None): + """ run specified testset path or folder path. + @param + path: path could be in several type + - absolute/relative file path + - absolute/relative folder path + - list/set container with file(s) and/or folder(s) + (dict) mapping: + passed in variables mapping, it will override variable_binds in config block """ success = True + mapping = mapping or {} + output = {} + testsets = testcase.load_testcases_by_path(path) for testset in testsets: try: - result = self.run_testset(testset) + result = self.run_testset(testset, mapping) assert result["success"] except AssertionError: success = False + finally: + output.update(result["output"]) - return success + return { + "success": success, + "output": output + } def generate_output(self, output_variables_list): """ generate and print output diff --git a/tests/test_runner.py b/tests/test_runner.py index 97da810b9..73003f27d 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -73,9 +73,8 @@ def test_run_testset_hardcode(self): def test_run_testsets_hardcode(self): for testcase_file_path in self.testcase_file_path_list: - testsets = testcase.load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testsets(testsets) - self.assertTrue(result) + result = self.test_runner.run(testcase_file_path) + self.assertTrue(result["success"]) def test_run_testset_template_variables(self): testcase_file_path = os.path.join( @@ -94,23 +93,20 @@ def test_run_testset_template_import_functions(self): def test_run_testsets_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') - testsets = testcase.load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testsets(testsets) - self.assertTrue(result) + result = self.test_runner.run(testcase_file_path) + self.assertTrue(result["success"]) def test_run_testsets_template_lambda_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_lambda_functions.yml') - testsets = testcase.load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testsets(testsets) - self.assertTrue(result) + result = self.test_runner.run(testcase_file_path) + self.assertTrue(result["success"]) def test_run_testset_layered(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testsets = testcase.load_testcases_by_path(testcase_file_path) - result = self.test_runner.run_testsets(testsets) - self.assertTrue(result) + result = self.test_runner.run(testcase_file_path) + self.assertTrue(result["success"]) def test_run_testset_output(self): testcase_file_path = os.path.join( From 102af87e61576d99993f6552eb910ffda81a5120 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 25 Sep 2017 15:47:47 +0800 Subject: [PATCH 272/354] runner: rename method name --- ate/locustfile_template | 7 +++---- ate/runner.py | 8 ++++---- ate/task.py | 2 +- tests/test_runner.py | 23 +++++++++-------------- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/ate/locustfile_template b/ate/locustfile_template index 3a3bcd1c2..10f53d430 100644 --- a/ate/locustfile_template +++ b/ate/locustfile_template @@ -2,17 +2,16 @@ import zmq import os from locust import HttpLocust, TaskSet, task -from ate import testcase, runner, exception +from ate import runner, exception class WebPageTasks(TaskSet): def on_start(self): self.test_runner = runner.Runner(self.client) - self.testset = self.locust.testset @task def test_specified_scenario(self): try: - self.test_runner.run_testset(self.testset) + self.test_runner.run(self.locust.file_path) except exception.ValidationError: pass @@ -22,4 +21,4 @@ class WebPageUser(HttpLocust): min_wait = 1000 max_wait = 5000 - testset = testcase.load_test_file("$TESTCASE_FILE") + file_path = "$TESTCASE_FILE" diff --git a/ate/runner.py b/ate/runner.py index fadc59c5d..298b9372b 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -65,7 +65,7 @@ def init_config(self, config_dict, level): return parsed_request - def run_test(self, testcase): + def _run_test(self, testcase): """ run single testcase. @param (dict) testcase { @@ -124,7 +124,7 @@ def setup_teardown(actions): return True - def run_testset(self, testset, variables_mapping=None): + def _run_testset(self, testset, variables_mapping=None): """ run single testset, including one or several testcases. @param (dict) testset @@ -168,7 +168,7 @@ def run_testset(self, testset, variables_mapping=None): testcases = testset.get("testcases", []) for testcase in testcases: try: - assert self.run_test(testcase) + assert self._run_test(testcase) except AssertionError: success = False @@ -195,7 +195,7 @@ def run(self, path, mapping=None): testsets = testcase.load_testcases_by_path(path) for testset in testsets: try: - result = self.run_testset(testset, mapping) + result = self._run_testset(testset, mapping) assert result["success"] except AssertionError: success = False diff --git a/ate/task.py b/ate/task.py index a653dbff4..572192f5d 100644 --- a/ate/task.py +++ b/ate/task.py @@ -14,7 +14,7 @@ def __init__(self, test_runner, testcase): def runTest(self): """ run testcase and check result. """ - self.assertTrue(self.test_runner.run_test(self.testcase)) + self.assertTrue(self.test_runner._run_test(self.testcase)) class ApiTestSuite(unittest.TestSuite): """ create test suite with a testset, it may include one or several testcases. diff --git a/tests/test_runner.py b/tests/test_runner.py index 73003f27d..dc8495ebc 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -28,13 +28,13 @@ def test_run_single_testcase(self): for testcase_file_path in self.testcase_file_path_list: testcases = utils.load_tests(testcase_file_path) testcase = testcases[0]["test"] - self.assertTrue(self.test_runner.run_test(testcase)) + self.assertTrue(self.test_runner._run_test(testcase)) testcase = testcases[1]["test"] - self.assertTrue(self.test_runner.run_test(testcase)) + self.assertTrue(self.test_runner._run_test(testcase)) testcase = testcases[2]["test"] - self.assertTrue(self.test_runner.run_test(testcase)) + self.assertTrue(self.test_runner._run_test(testcase)) def test_run_single_testcase_fail(self): testcase = { @@ -63,12 +63,11 @@ def test_run_single_testcase_fail(self): } with self.assertRaises(exception.ValidationError): - self.test_runner.run_test(testcase) + self.test_runner._run_test(testcase) def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: - testset = testcase.load_test_file(testcase_file_path) - result = self.test_runner.run_testset(testset) + result = self.test_runner.run(testcase_file_path) self.assertTrue(result["success"]) def test_run_testsets_hardcode(self): @@ -79,15 +78,13 @@ def test_run_testsets_hardcode(self): def test_run_testset_template_variables(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_variables.yml') - testset = testcase.load_test_file(testcase_file_path) - result = self.test_runner.run_testset(testset) + result = self.test_runner.run(testcase_file_path) self.assertTrue(result["success"]) def test_run_testset_template_import_functions(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_template_import_functions.yml') - testset = testcase.load_test_file(testcase_file_path) - result = self.test_runner.run_testset(testset) + result = self.test_runner.run(testcase_file_path) self.assertTrue(result["success"]) def test_run_testsets_template_import_functions(self): @@ -111,18 +108,16 @@ def test_run_testset_layered(self): def test_run_testset_output(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testset = testcase.load_test_file(testcase_file_path) - result = self.test_runner.run_testset(testset) + result = self.test_runner.run(testcase_file_path) self.assertTrue(result["success"]) self.assertIn("token", result["output"]) def test_run_testset_with_variables_mapping(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') - testset = testcase.load_test_file(testcase_file_path) variables_mapping = { "app_version": '2.9.7' } - result = self.test_runner.run_testset(testset, variables_mapping) + result = self.test_runner.run(testcase_file_path, variables_mapping) self.assertTrue(result["success"]) self.assertIn("token", result["output"]) From c7df184af27bf4886059af246cde24ec5b6db110 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 25 Sep 2017 19:33:49 +0800 Subject: [PATCH 273/354] create scaffold --- ate/__init__.py | 2 +- ate/cli.py | 10 ++++++++++ ate/utils.py | 26 ++++++++++++++++++++++++++ tests/test_utils.py | 11 +++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ate/__init__.py b/ate/__init__.py index 0ffab71e2..6fb2e7103 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.3' \ No newline at end of file +__version__ = '0.7.4' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index 0bf17b864..92971fe08 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -6,6 +6,7 @@ from ate import __version__ from ate.task import TaskSuite +from ate.utils import create_scaffold import PyUnitReport @@ -30,6 +31,9 @@ def main_ate(): parser.add_argument( '--failfast', action='store_true', default=False, help="Stop the test run on the first error or failure.") + parser.add_argument( + '--startproject', + help="Specify new project name.") try: from jenkins_mail_py import MailgunHelper @@ -46,6 +50,12 @@ def main_ate(): log_level = getattr(logging, args.log_level.upper()) logging.basicConfig(level=log_level) + project_name = args.startproject + if project_name: + project_path = os.path.join(os.getcwd(), project_name) + create_scaffold(project_path) + exit(0) + report_name = args.report_name if report_name and len(args.testset_paths) > 1: report_name = None diff --git a/ate/utils.py b/ate/utils.py index 637466211..33f8c694d 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -405,3 +405,29 @@ def print_output(output): content += "============================================\n" logging.debug(content) + +def create_scaffold(project_path): + logging.info(" Start to create new project: {}".format(project_path)) + + if os.path.isdir(project_path): + folder_name = os.path.basename(project_path) + logging.warning(" Folder {} exists, please specify a new folder name.".format(folder_name)) + return + + def create_path(path, ptype): + if ptype == "folder": + os.makedirs(path) + elif ptype == "file": + open(path, 'w').close() + + logging.info("\tcreated {}: {}".format(ptype, path)) + + path_list = [ + (project_path, "folder"), + (os.path.join(project_path, "tests"), "folder"), + (os.path.join(project_path, "tests", "api"), "folder"), + (os.path.join(project_path, "tests", "suite"), "folder"), + (os.path.join(project_path, "tests", "testcases"), "folder"), + (os.path.join(project_path, "tests", "debugtalk.py"), "file") + ] + [create_path(p[0], p[1]) for p in path_list] diff --git a/tests/test_utils.py b/tests/test_utils.py index 80d424674..eb274b846 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import os +import shutil from collections import OrderedDict from ate import exception, utils @@ -308,3 +309,13 @@ def test_override_variables_binds(self): override_mapping = {"a": 3, "c": 4} with self.assertRaises(exception.ParamsError): utils.override_variables_binds(map_list, override_mapping) + + def test_create_scaffold(self): + project_path = os.path.join(os.getcwd(), "projectABC") + utils.create_scaffold(project_path) + self.assertTrue(os.path.isdir(os.path.join(project_path, "tests"))) + self.assertTrue(os.path.isdir(os.path.join(project_path, "tests", "api"))) + self.assertTrue(os.path.isdir(os.path.join(project_path, "tests", "suite"))) + self.assertTrue(os.path.isdir(os.path.join(project_path, "tests", "testcases"))) + self.assertTrue(os.path.isfile(os.path.join(project_path, "tests", "debugtalk.py"))) + shutil.rmtree(project_path) From 675ffc63fe59054b99b3a4f51ec83b1f025cada1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 26 Sep 2017 11:32:32 +0800 Subject: [PATCH 274/354] add testcases cache in load_testcases_by_path --- ate/testcase.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index fd4dd829f..dcd4c51ef 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -12,6 +12,7 @@ "api": {}, "suite": {} } +testcases_cache_mapping = {} def extract_variables(content): @@ -149,16 +150,25 @@ def load_testcases_by_path(path): if not os.path.isabs(path): path = os.path.join(os.getcwd(), path) + if path in testcases_cache_mapping: + return testcases_cache_mapping[path] + if os.path.isdir(path): files_list = utils.load_folder_files(path) - return load_testcases_by_path(files_list) + testcases_list = load_testcases_by_path(files_list) elif os.path.isfile(path): testset = load_test_file(path) if testset["testcases"] or testset["api"]: - return [testset] + testcases_list = [testset] + else: + testcases_list = [] + + else: + testcases_list = [] - return [] + testcases_cache_mapping[path] = testcases_list + return testcases_list def load_test_file(file_path): """ load testset file, get testset data structure. From 702a60bb84f6f84619eac3105d11d3b3f9860427 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 26 Sep 2017 15:26:17 +0800 Subject: [PATCH 275/354] update README --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d42e2fa2d..0c3ecda68 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.7.0 +ApiTestEngine version: 0.7.4 ``` Execute the command `ate -h` to view command help. @@ -75,7 +75,7 @@ To install mail helper, run this command in your terminal: $ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py $ ate -V jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.7.0 +ApiTestEngine version: 0.7.4 ``` With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. @@ -283,7 +283,8 @@ $ python main-locust -h - [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) - [《ApiTestEngine 演进之路(3)测试用例中实现 Python 函数的定义》](http://debugtalk.com/post/ApiTestEngine-3-define-functions-in-yaml-testcases/) - [《ApiTestEngine 演进之路(4)测试用例中实现 Python 函数的调用》](http://debugtalk.com/post/ApiTestEngine-4-call-functions-in-yaml-testcases/) - +- [《ApiTestEngine 集成 Locust 实现更好的性能测试体验》](http://debugtalk.com/post/apitestengine-supersede-locust/) +- [《约定大于配置:ApiTestEngine实现热加载机制》](http://debugtalk.com/post/apitestengine-hot-plugin/) [requests]: http://docs.python-requests.org/en/master/ [unittest]: https://docs.python.org/3/library/unittest.html From 1c548542d1df6858f7c4b8173772f606638b95fc Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 28 Sep 2017 12:02:27 +0800 Subject: [PATCH 276/354] bugfix: api server update user --- tests/api_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api_server.py b/tests/api_server.py index 98a090327..f5f51b631 100644 --- a/tests/api_server.py +++ b/tests/api_server.py @@ -184,6 +184,7 @@ def update_user(uid): user = request.get_json() success = True status_code = 200 + users_dict[uid] = user else: success = False status_code = 404 From 4a36781b65acc0ce439c1f924dc0e8bfe6c03129 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 28 Sep 2017 14:21:54 +0800 Subject: [PATCH 277/354] bugfix: UnboundLocalError occured when _run_testset failed --- ate/runner.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 298b9372b..a19a5a444 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -197,10 +197,9 @@ def run(self, path, mapping=None): try: result = self._run_testset(testset, mapping) assert result["success"] + output.update(result["output"]) except AssertionError: success = False - finally: - output.update(result["output"]) return { "success": success, From 6fec2ca29b44631337902bd9e1c0e93e512e3640 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 28 Sep 2017 14:29:23 +0800 Subject: [PATCH 278/354] update tests --- tests/api/demo.yml | 43 +++++++++++++++ tests/api_server.py | 1 - tests/data/demo_testset_layer.yml | 88 +++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 6 deletions(-) diff --git a/tests/api/demo.yml b/tests/api/demo.yml index 1e641a542..ee752e393 100644 --- a/tests/api/demo.yml +++ b/tests/api/demo.yml @@ -24,3 +24,46 @@ json: name: $user_name password: $user_password + +- api: + def: get_user($uid, $token) + request: + url: /api/users/$uid + method: GET + headers: + token: $token + +- api: + def: update_user($uid, $user_name, $user_password, $token) + request: + url: /api/users/$uid + method: PUT + headers: + token: $token + json: + name: $user_name + password: $user_password + +- api: + def: delete_user($uid, $token) + request: + url: /api/users/$uid + method: DELETE + headers: + token: $token + +- api: + def: get_users($token) + request: + url: /api/users + method: GET + headers: + token: $token + +- api: + def: reset_all($token) + request: + url: /api/reset-all + method: GET + headers: + token: $token diff --git a/tests/api_server.py b/tests/api_server.py index f5f51b631..8517dc705 100644 --- a/tests/api_server.py +++ b/tests/api_server.py @@ -1,5 +1,4 @@ import hashlib -import hmac import json from functools import wraps diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index 87758abf7..d731c18a4 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -19,6 +19,20 @@ extract_binds: - token: content.token +- test: + name: reset all users + api: reset_all($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + +- test: + name: get user that does not exist + api: get_user(1000, $token) + validators: + - {"check": "status_code", "expected": 404} + - {"check": "content.success", "expected": false} + - test: name: create user which does not exist variable_binds: @@ -26,15 +40,79 @@ - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "expected": 201} + - {"check": "content.success", "expected": true} - test: - name: create user which does not exist + name: get user that has been created + api: get_user(1000, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + - {"check": "content.data.password", "expected": "123456"} + +- test: + name: create user which exists variable_binds: - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "status_code", "expected": 500} + - {"check": "content.success", "expected": false} + +- test: + name: update user which exists + variable_binds: + - user_name: "user1" + - user_password: "654321" + api: update_user(1000, $user_name, $user_password, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + +- test: + name: get user that has been created + api: get_user(1000, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + - {"check": "content.data.password", "expected": "654321"} + +- test: + name: get users + api: get_users($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.count", "expected": 1} + +- test: + name: delete user that exists + api: delete_user(1000, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + +- test: + name: get users + api: get_users($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.count", "expected": 0} + +- test: + name: create user which has been deleted + variable_binds: + - user_name: "user1" + - user_password: "123456" + api: create_user(1000, $user_name, $user_password, $token) + validators: + - {"check": "status_code", "expected": 201} + - {"check": "content.success", "expected": true} + +- test: + name: get users + api: get_users($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.count", "expected": 1} From 890bc2035c5c69d658f36cf931d43338ce4ab046 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 28 Sep 2017 14:29:23 +0800 Subject: [PATCH 279/354] update tests --- tests/api/demo.yml | 43 +++++++++++++++ tests/api_server.py | 1 - tests/data/demo_testset_layer.yml | 90 ++++++++++++++++++++++++++++--- 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/tests/api/demo.yml b/tests/api/demo.yml index 1e641a542..ee752e393 100644 --- a/tests/api/demo.yml +++ b/tests/api/demo.yml @@ -24,3 +24,46 @@ json: name: $user_name password: $user_password + +- api: + def: get_user($uid, $token) + request: + url: /api/users/$uid + method: GET + headers: + token: $token + +- api: + def: update_user($uid, $user_name, $user_password, $token) + request: + url: /api/users/$uid + method: PUT + headers: + token: $token + json: + name: $user_name + password: $user_password + +- api: + def: delete_user($uid, $token) + request: + url: /api/users/$uid + method: DELETE + headers: + token: $token + +- api: + def: get_users($token) + request: + url: /api/users + method: GET + headers: + token: $token + +- api: + def: reset_all($token) + request: + url: /api/reset-all + method: GET + headers: + token: $token diff --git a/tests/api_server.py b/tests/api_server.py index f5f51b631..8517dc705 100644 --- a/tests/api_server.py +++ b/tests/api_server.py @@ -1,5 +1,4 @@ import hashlib -import hmac import json from functools import wraps diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index 87758abf7..f47a31cf0 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -1,5 +1,5 @@ - config: - name: "create user testsets." + name: "user management testset." variable_binds: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} @@ -19,6 +19,20 @@ extract_binds: - token: content.token +- test: + name: reset all users + api: reset_all($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + +- test: + name: get user that does not exist + api: get_user(1000, $token) + validators: + - {"check": "status_code", "expected": 404} + - {"check": "content.success", "expected": false} + - test: name: create user which does not exist variable_binds: @@ -26,15 +40,79 @@ - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validators: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "expected": 201} + - {"check": "content.success", "expected": true} - test: - name: create user which does not exist + name: get user that has been created + api: get_user(1000, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + - {"check": "content.data.password", "expected": "123456"} + +- test: + name: create user which exists variable_binds: - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validators: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "status_code", "expected": 500} + - {"check": "content.success", "expected": false} + +- test: + name: update user which exists + variable_binds: + - user_name: "user1" + - user_password: "654321" + api: update_user(1000, $user_name, $user_password, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + +- test: + name: get user that has been updated + api: get_user(1000, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + - {"check": "content.data.password", "expected": "654321"} + +- test: + name: get users + api: get_users($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.count", "expected": 1} + +- test: + name: delete user that exists + api: delete_user(1000, $token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.success", "expected": true} + +- test: + name: get users + api: get_users($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.count", "expected": 0} + +- test: + name: create user which has been deleted + variable_binds: + - user_name: "user1" + - user_password: "123456" + api: create_user(1000, $user_name, $user_password, $token) + validators: + - {"check": "status_code", "expected": 201} + - {"check": "content.success", "expected": true} + +- test: + name: get users + api: get_users($token) + validators: + - {"check": "status_code", "expected": 200} + - {"check": "content.count", "expected": 1} From 6ff8efe61950997ecb8d614e09992aac8b81a286 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 13 Oct 2017 10:44:02 +0800 Subject: [PATCH 280/354] udpate --- README.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0c3ecda68..de1640405 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,13 @@ With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional a ```text $ ate -h usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] - [--failfast] [--mailgun-api-id MAILGUN_API_ID] - [--mailgun-api-key MAILGUN_API_KEY] [--email-sender EMAIL_SENDER] - [--email-recepients EMAIL_RECEPIENTS] [--mail-subject MAIL_SUBJECT] - [--mail-content MAIL_CONTENT] [--jenkins-job-name JENKINS_JOB_NAME] + [--failfast] [--startproject STARTPROJECT] + [--mailgun-smtp-username MAILGUN_SMTP_USERNAME] + [--mailgun-smtp-password MAILGUN_SMTP_PASSWORD] + [--mail-sender MAIL_SENDER] + [--mail-recepients [MAIL_RECEPIENTS [MAIL_RECEPIENTS ...]]] + [--mail-subject MAIL_SUBJECT] [--mail-content MAIL_CONTENT] + [--jenkins-job-name JENKINS_JOB_NAME] [--jenkins-job-url JENKINS_JOB_URL] [--jenkins-build-number JENKINS_BUILD_NUMBER] [testset_paths [testset_paths ...]] @@ -104,13 +107,15 @@ optional arguments: --report-name REPORT_NAME Specify report name, default is generated time. --failfast Stop the test run on the first error or failure. - --mailgun-api-id MAILGUN_API_ID - Specify mailgun api id. - --mailgun-api-key MAILGUN_API_KEY - Specify mailgun api key. - --email-sender EMAIL_SENDER + --startproject STARTPROJECT + Specify new project name. + --mailgun-smtp-username MAILGUN_SMTP_USERNAME + Specify mailgun smtp username. + --mailgun-smtp-password MAILGUN_SMTP_PASSWORD + Specify mailgun smtp password. + --mail-sender MAIL_SENDER Specify email sender. - --email-recepients EMAIL_RECEPIENTS + --mail-recepients [MAIL_RECEPIENTS [MAIL_RECEPIENTS ...]] Specify email recepients. --mail-subject MAIL_SUBJECT Specify email subject. @@ -207,8 +212,8 @@ When you do continuous integration test or production environment monitoring wit ```text $ ate filepath/testcase.yml --report-name ${BUILD_NUMBER} \ - --mailgun-api-id samples.mailgun.org \ - --mailgun-api-key key-3ax6xnjp29jd6fds4gc373sgvjxteol0 \ + --mailgun-smtp-username "qa@debugtalk.com" \ + --mailgun-smtp-password "12345678" \ --email-sender excited@samples.mailgun.org \ --email-recepients ${MAIL_RECEPIENTS} \ --jenkins-job-name ${JOB_NAME} \ From 9ec09f62565885a53b2745369958b3462f00ddf7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 15:11:06 +0800 Subject: [PATCH 281/354] remove jenkins-mail-py plugin --- README.md | 66 +----------------------------------------------------- ate/cli.py | 16 +++---------- 2 files changed, 4 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index de1640405..f5aa3fbe4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], - With `debugtalk.py` plugin, module functions can be auto-discovered in recursive upward directories. - Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. - Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. -- Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. Send mail notification with [`jenkins-mail-py`][jenkins-mail-py]. +- Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. - With reuse of [`Locust`][Locust], you can run performance test without extra work. - It is extensible to facilitate the implementation of web platform with [`Flask`][flask] framework. @@ -65,70 +65,6 @@ optional arguments: --failfast Stop the test run on the first error or failure. ``` -### use `jenkins-mail-py` plugin - -If you want to use `ApiTestEngine` with Jenkins, you may need to send mail notification, and [`jenkins-mail-py`][jenkins-mail-py] will be of great help. - -To install mail helper, run this command in your terminal: - -```text -$ pip install -U git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py -$ ate -V -jenkins-mail-py version: 0.2.5 -ApiTestEngine version: 0.7.4 -``` - -With [`jenkins-mail-py`][jenkins-mail-py] installed, you can see more optional arguments. - -```text -$ ate -h -usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] - [--failfast] [--startproject STARTPROJECT] - [--mailgun-smtp-username MAILGUN_SMTP_USERNAME] - [--mailgun-smtp-password MAILGUN_SMTP_PASSWORD] - [--mail-sender MAIL_SENDER] - [--mail-recepients [MAIL_RECEPIENTS [MAIL_RECEPIENTS ...]]] - [--mail-subject MAIL_SUBJECT] [--mail-content MAIL_CONTENT] - [--jenkins-job-name JENKINS_JOB_NAME] - [--jenkins-job-url JENKINS_JOB_URL] - [--jenkins-build-number JENKINS_BUILD_NUMBER] - [testset_paths [testset_paths ...]] - -Api Test Engine. - -positional arguments: - testset_paths testset file path - -optional arguments: - -h, --help show this help message and exit - -V, --version show version - --log-level LOG_LEVEL - Specify logging level, default is INFO. - --report-name REPORT_NAME - Specify report name, default is generated time. - --failfast Stop the test run on the first error or failure. - --startproject STARTPROJECT - Specify new project name. - --mailgun-smtp-username MAILGUN_SMTP_USERNAME - Specify mailgun smtp username. - --mailgun-smtp-password MAILGUN_SMTP_PASSWORD - Specify mailgun smtp password. - --mail-sender MAIL_SENDER - Specify email sender. - --mail-recepients [MAIL_RECEPIENTS [MAIL_RECEPIENTS ...]] - Specify email recepients. - --mail-subject MAIL_SUBJECT - Specify email subject. - --mail-content MAIL_CONTENT - Specify email content. - --jenkins-job-name JENKINS_JOB_NAME - Specify jenkins job name. - --jenkins-job-url JENKINS_JOB_URL - Specify jenkins job url. - --jenkins-build-number JENKINS_BUILD_NUMBER - Specify jenkins build number. -``` - ## Write testcases It is recommended to write testcases in `YAML` format. diff --git a/ate/cli.py b/ate/cli.py index 92971fe08..2d04409cb 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -35,12 +35,6 @@ def main_ate(): '--startproject', help="Specify new project name.") - try: - from jenkins_mail_py import MailgunHelper - mailer = MailgunHelper(parser) - except ImportError: - mailer = None - args = parser.parse_args() if args.version: @@ -63,7 +57,7 @@ def main_ate(): report name is ignored, use generated time instead.") results = {} - subject = "SUCCESS" + success = True for testset_path in set(args.testset_paths): @@ -86,16 +80,12 @@ def main_ate(): }) if len(result.successes) != result.testsRun: - subject = "FAILED" + success = False for task in task_suite.tasks: task.print_output() - flag_code = 0 if subject == "SUCCESS" else 1 - if mailer and mailer.config_ready: - mailer.send_mail(subject, results, flag_code) - - return flag_code + return 0 if success is True else 1 def main_locust(): """ Performance test with locust: parse command line options and run commands. From 4dda121776cf580b83c1b59309ee191631212ae3 Mon Sep 17 00:00:00 2001 From: firefoxwang <1228137800@qq.com> Date: Tue, 24 Oct 2017 15:16:18 +0800 Subject: [PATCH 282/354] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E6=8A=A5=E9=94=99bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/base.py b/tests/base.py index 947c4663b..faa15ef0a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -6,6 +6,8 @@ from ate import utils from tests import api_server +def apprun(): + _ = api_server.app.run() class ApiServerUnittest(unittest.TestCase): """ Test case class that sets up an HTTP server which can be used within the tests @@ -15,7 +17,7 @@ class ApiServerUnittest(unittest.TestCase): def setUpClass(cls): cls.host = "http://127.0.0.1:5000" cls.api_server_process = multiprocessing.Process( - target=api_server.app.run + target=apprun ) cls.api_server_process.start() time.sleep(0.1) From 57f6b9b2ea74f79ccd9addab04d7bc3ad0c2aaeb Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 15:21:29 +0800 Subject: [PATCH 283/354] update README --- README.md | 14 +++++++------- ate/__init__.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f5aa3fbe4..95b812d59 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Design Philosophy -Take full reuse of Python's existing powerful libraries: [`Requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. +Take full reuse of Python's existing powerful libraries: [`Requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. ## Key Features @@ -15,9 +15,8 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], - With `debugtalk.py` plugin, module functions can be auto-discovered in recursive upward directories. - Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. - Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. -- Perfect combination with [Jenkins][Jenkins], running continuous integration test and production environment monitoring. - With reuse of [`Locust`][Locust], you can run performance test without extra work. -- It is extensible to facilitate the implementation of web platform with [`Flask`][flask] framework. +- CLI command supported, perfect combination with [Jenkins][Jenkins]. [*`Background Introduction (中文版)`*](docs/background-CN.md) | [*`Feature Descriptions (中文版)`*](docs/feature-descriptions-CN.md) @@ -39,7 +38,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.7.4 +ApiTestEngine version: 0.7.5 ``` Execute the command `ate -h` to view command help. @@ -47,10 +46,10 @@ Execute the command `ate -h` to view command help. ```text $ ate -h usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] - [--failfast] + [--failfast] [--startproject STARTPROJECT] [testset_paths [testset_paths ...]] -Api Test Engine. +ApiTestEngine. positional arguments: testset_paths testset file path @@ -63,6 +62,8 @@ optional arguments: --report-name REPORT_NAME Specify report name, default is generated time. --failfast Stop the test run on the first error or failure. + --startproject STARTPROJECT + Specify new project name. ``` ## Write testcases @@ -233,5 +234,4 @@ $ python main-locust -h [flask]: http://flask.pocoo.org/ [PyUnitReport]: https://github.com/debugtalk/PyUnitReport [Jenkins]: https://jenkins.io/index.html -[jenkins-mail-py]: https://github.com/debugtalk/jenkins-mail-py.git [quickstart]: docs/quickstart.md \ No newline at end of file diff --git a/ate/__init__.py b/ate/__init__.py index 6fb2e7103..ed4e820fe 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.4' \ No newline at end of file +__version__ = '0.7.5' \ No newline at end of file From 05d390169ac6795b41864766f8e0134aa8600591 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 15:40:59 +0800 Subject: [PATCH 284/354] rename keyword: extract_binds => extractors --- README.md | 2 +- ate/response.py | 8 ++++---- ate/runner.py | 10 ++++++---- docs/extraction-and-validation.md | 4 ++-- docs/quickstart.md | 8 ++++---- examples/quickstart-demo-rev-1.yml | 2 +- examples/quickstart-demo-rev-2.yml | 2 +- examples/quickstart-demo-rev-3.yml | 2 +- tests/data/demo_testset_hardcode.json | 2 +- tests/data/demo_testset_hardcode.yml | 2 +- tests/data/demo_testset_layer.yml | 2 +- tests/data/demo_testset_template_import_functions.yml | 2 +- tests/data/demo_testset_template_lambda_functions.yml | 2 +- tests/data/demo_testset_variables.yml | 2 +- tests/test_runner.py | 2 +- 15 files changed, 27 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 95b812d59..9a8f7d8b8 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ And here is testset example of typical scenario: get `token` at the beginning, a app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/ate/response.py b/ate/response.py index 0b4c17a95..933ae25b7 100644 --- a/ate/response.py +++ b/ate/response.py @@ -56,9 +56,9 @@ def extract_field(self, field, delimiter='.'): except AttributeError: raise exception.ParseResponseError("failed to extract bind variable in response!") - def extract_response(self, extract_binds): + def extract_response(self, extractors): """ extract content from requests.Response - @param (list) extract_binds + @param (list) extractors [ {"resp_status_code": "status_code"}, {"resp_headers_content_type": "headers.content-type"}, @@ -68,11 +68,11 @@ def extract_response(self, extract_binds): @return (OrderDict) variable binds ordered dict """ extracted_variables_mapping = OrderedDict() - extract_binds_order_dict = utils.convert_to_order_dict(extract_binds) + extract_binds_order_dict = utils.convert_to_order_dict(extractors) for key, field in extract_binds_order_dict.items(): if not isinstance(field, utils.string_type): - raise exception.ParamsError("invalid extract_binds in testcase extract_binds!") + raise exception.ParamsError("invalid extractors in testcase!") extracted_variables_mapping[key] = self.extract_field(field) diff --git a/ate/runner.py b/ate/runner.py index a19a5a444..bd44e62f3 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -84,7 +84,7 @@ def _run_test(self, testcase): }, "body": '{"name": "user", "password": "123456"}' }, - "extract_binds": [], # optional + "extractors": [], # optional "validators": [], # optional "setup": [], # optional "teardown": [] # optional @@ -100,7 +100,9 @@ def _run_test(self, testcase): raise exception.ParamsError("URL or METHOD missed!") run_times = int(testcase.get("times", 1)) - extract_binds = testcase.get("extract_binds", []) + extractors = testcase.get("extractors") \ + or testcase.get("extractor") \ + or testcase.get("extract_binds", []) validators = testcase.get("validators", []) setup_actions = testcase.get("setup", []) teardown_actions = testcase.get("teardown", []) @@ -115,7 +117,7 @@ def setup_teardown(actions): resp = self.http_client_session.request(url=url, method=method, **parsed_request) resp_obj = response.ResponseObject(resp) - extracted_variables_mapping = resp_obj.extract_response(extract_binds) + extracted_variables_mapping = resp_obj.extract_response(extractors) self.context.bind_variables(extracted_variables_mapping, level="testset") resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) @@ -142,7 +144,7 @@ def _run_testset(self, testset, variables_mapping=None): "name": "testcase description", "variable_binds": [], # optional, override "request": {}, - "extract_binds": {}, # optional + "extractors": {}, # optional "validators": {} # optional }, testcase12 diff --git a/docs/extraction-and-validation.md b/docs/extraction-and-validation.md index 798ed9c45..0cb76fc0c 100644 --- a/docs/extraction-and-validation.md +++ b/docs/extraction-and-validation.md @@ -24,7 +24,7 @@ Suppose we get the following HTTP response. } ``` -In `extract_binds` and `validators`, we can do chain operation to extract data field in HTTP response. +In `extractors` and `validators`, we can do chain operation to extract data field in HTTP response. For instance, if we want to get `Content-Type` in response headers, then we can specify `headers.content-type`; if we want to get `first_name` in response content, we can specify `content.person.name.first_name`. @@ -46,7 +46,7 @@ content.person.cities.1 ``` ```yaml -extract_binds: +extractors: - content_type: headers.content-type - first_name: content.person.name.first_name validators: diff --git a/docs/quickstart.md b/docs/quickstart.md index 59f58cd27..500e74964 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -120,7 +120,7 @@ To fix this problem, we should correlate `token` field in the second API test ca app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} @@ -142,7 +142,7 @@ To fix this problem, we should correlate `token` field in the second API test ca - {"check": "content.success", "comparator": "eq", "expected": true} ``` -As you see, the `token` field is no longer hardcoded, instead it is extracted from the first API request with `extract_binds` mechanism. In the meanwhile, it is assigned to `token` variable, which can be referenced by the subsequent API requests. +As you see, the `token` field is no longer hardcoded, instead it is extracted from the first API request with `extractors` mechanism. In the meanwhile, it is assigned to `token` variable, which can be referenced by the subsequent API requests. Now we save the test cases to [`quickstart-demo-rev-1.yml`][quickstart-demo-rev-1] and rerun it, and we will find that both API requests to be successful. @@ -202,7 +202,7 @@ And then, we can revise our demo test case and reference the functions. Suppose app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} @@ -266,7 +266,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/examples/quickstart-demo-rev-1.yml b/examples/quickstart-demo-rev-1.yml index 017a20158..d203cf8ae 100644 --- a/examples/quickstart-demo-rev-1.yml +++ b/examples/quickstart-demo-rev-1.yml @@ -10,7 +10,7 @@ app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index 100bf9426..540a4ad34 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -15,7 +15,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index 30064ed55..44c3bbbf6 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -22,7 +22,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index 75c84f283..bd1549330 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -16,7 +16,7 @@ "sign": "f1219719911caae89ccc301679857ebfda115ca2" } }, - "extract_binds": [ + "extractors": [ { "token": "content.token" } diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index 204fab590..2b112e364 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -11,7 +11,7 @@ app_version: '2.8.6' json: sign: f1219719911caae89ccc301679857ebfda115ca2 - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index f47a31cf0..ba22272fe 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -16,7 +16,7 @@ - test: name: get token api: get_token($user_agent, $device_sn, $os_platform, $app_version) - extract_binds: + extractors: - token: content.token - test: diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index 6531a497e..28ffdbb7b 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -25,7 +25,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index df2e98aab..26d5eecf8 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -35,7 +35,7 @@ app_version: $app_version json: sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index 7a62e6ab0..98dd3d16f 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -26,7 +26,7 @@ app_version: $app_version json: sign: $sign - extract_binds: + extractors: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/test_runner.py b/tests/test_runner.py index dc8495ebc..9996d25aa 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -53,7 +53,7 @@ def test_run_single_testcase_fail(self): "sign": "f1219719911caae89ccc301679857ebfda115ca2" } }, - "extract_binds": [ + "extractors": [ {"token": "content.token"} ], "validators": [ From fa82c26e9f4738b82a06176ce6e9c206e5784ffe Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 15:59:09 +0800 Subject: [PATCH 285/354] rename keyword: variable_binds => variables --- README.md | 2 +- ate/context.py | 15 ++++---- ate/runner.py | 22 +++++------ ate/utils.py | 16 ++++---- docs/quickstart.md | 10 ++--- examples/quickstart-demo-rev-2.yml | 2 +- examples/quickstart-demo-rev-3.yml | 4 +- tests/data/demo_binds.yml | 8 ++-- tests/data/demo_testset_layer.yml | 10 ++--- ...demo_testset_template_import_functions.yml | 4 +- ...demo_testset_template_lambda_functions.yml | 4 +- tests/data/demo_testset_variables.yml | 6 +-- tests/test_context.py | 38 +++++++++---------- tests/test_testcase.py | 6 +-- 14 files changed, 74 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9a8f7d8b8..6f97d7c5f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ And here is testset example of typical scenario: get `token` at the beginning, a ```yaml - config: name: "create user testsets." - variable_binds: + variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' diff --git a/ate/context.py b/ate/context.py index 60af1d521..3c8099304 100644 --- a/ate/context.py +++ b/ate/context.py @@ -54,8 +54,9 @@ def config_context(self, config_dict, level): or config_dict.get('import_module_functions', []) self.import_module_items(module_items, level) - variable_binds = config_dict.get('variable_binds', OrderedDict()) - self.bind_variables(variable_binds, level) + variables = config_dict.get('variables') \ + or config_dict.get('variable_binds', OrderedDict()) + self.bind_variables(variables, level) def import_requires(self, modules): """ import required modules dynamically @@ -92,11 +93,11 @@ def import_module_items(self, modules, level="testcase"): imported_variables_dict = utils.filter_module(imported_module, "variable") self.bind_variables(imported_variables_dict, level) - def bind_variables(self, variable_binds, level="testcase"): + def bind_variables(self, variables, level="testcase"): """ bind variables to testset context or current testcase context. variables in testset context can be used in all testcases of current test suite. - @param (list or OrderDict) variable_binds, variable can be value or custom function. + @param (list or OrderDict) variables, variable can be value or custom function. if value is function, it will be called and bind result to variable. e.g. OrderDict({ @@ -106,10 +107,10 @@ def bind_variables(self, variable_binds, level="testcase"): "md5": "${gen_md5($TOKEN, $json, $random)}" }) """ - if isinstance(variable_binds, list): - variable_binds = utils.convert_to_order_dict(variable_binds) + if isinstance(variables, list): + variables = utils.convert_to_order_dict(variables) - for variable_name, value in variable_binds.items(): + for variable_name, value in variables.items(): variable_evale_value = self.testcase_parser.parse_content_with_bindings(value) if level == "testset": diff --git a/ate/runner.py b/ate/runner.py index bd44e62f3..b84e9206b 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -23,7 +23,7 @@ def init_config(self, config_dict, level): "requires": [], # optional "function_binds": {}, # optional "import_module_items": [], # optional - "variable_binds": [], # optional + "variables": [], # optional "request": { "base_url": "http://127.0.0.1:5000", "headers": { @@ -37,7 +37,7 @@ def init_config(self, config_dict, level): "requires": [], # optional "function_binds": {}, # optional "import_module_items": [], # optional - "variable_binds": [], # optional + "variables": [], # optional "request": { "url": "/api/get-token", "method": "POST", @@ -71,9 +71,9 @@ def _run_test(self, testcase): { "name": "testcase description", "times": 3, - "requires": [], # optional, override - "function_binds": {}, # optional, override - "variable_binds": [], # optional, override + "requires": [], # optional, override + "function_binds": {}, # optional, override + "variables": [], # optional, override "request": { "url": "http://127.0.0.1:5000/api/users/1000", "method": "POST", @@ -136,13 +136,13 @@ def _run_testset(self, testset, variables_mapping=None): "name": "testset description", "requires": [], "function_binds": {}, - "variable_binds": [], + "variables": [], "request": {} }, "testcases": [ { "name": "testcase description", - "variable_binds": [], # optional, override + "variables": [], # optional, override "request": {}, "extractors": {}, # optional "validators": {} # optional @@ -151,7 +151,7 @@ def _run_testset(self, testset, variables_mapping=None): ] } (dict) variables_mapping: - passed in variables mapping, it will override variable_binds in config block + passed in variables mapping, it will override variables in config block @return (dict) test result of testset { @@ -162,9 +162,9 @@ def _run_testset(self, testset, variables_mapping=None): success = True config_dict = testset.get("config", {}) - variable_binds = config_dict.get("variable_binds", []) + variables = config_dict.get("variables", []) variables_mapping = variables_mapping or {} - config_dict["variable_binds"] = utils.override_variables_binds(variable_binds, variables_mapping) + config_dict["variables"] = utils.override_variables_binds(variables, variables_mapping) self.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) @@ -189,7 +189,7 @@ def run(self, path, mapping=None): - absolute/relative folder path - list/set container with file(s) and/or folder(s) (dict) mapping: - passed in variables mapping, it will override variable_binds in config block + passed in variables mapping, it will override variables in config block """ success = True mapping = mapping or {} diff --git a/ate/utils.py b/ate/utils.py index 33f8c694d..e05eb45e0 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -369,18 +369,18 @@ def update_ordered_dict(ordered_dict, override_mapping): return ordered_dict -def override_variables_binds(variable_binds, new_mapping): - """ convert variable_binds in testcase to ordered mapping, with new_mapping overrided +def override_variables_binds(variables, new_mapping): + """ convert variables in testcase to ordered mapping, with new_mapping overrided """ - if isinstance(variable_binds, list): - variable_binds_ordered_dict = convert_to_order_dict(variable_binds) - elif isinstance(variable_binds, OrderedDict): - variable_binds_ordered_dict = variable_binds + if isinstance(variables, list): + variables_ordered_dict = convert_to_order_dict(variables) + elif isinstance(variables, OrderedDict): + variables_ordered_dict = variables else: - raise exception.ParamsError("variable_binds error!") + raise exception.ParamsError("variables error!") return update_ordered_dict( - variable_binds_ordered_dict, + variables_ordered_dict, new_mapping ) diff --git a/docs/quickstart.md b/docs/quickstart.md index 500e74964..f1756da9d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -154,7 +154,7 @@ In actual scenarios, each user's `device_sn` is different, so we should paramete However, the test cases are only `YAML` documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine `Python` scripts with `YAML/JSON` test cases in `ApiTestEngine`. -To achieve this goal, we can utilize `debugtalk.py` plugin and `variable_binds` mechanisms. +To achieve this goal, we can utilize `debugtalk.py` plugin and `variables` mechanisms. To be specific, we can create a Python file (`examples/debugtalk.py`) and implement the related algorithm in it. The `debugtalk.py` file can not only be located beside `YAML/JSON` testset file, but also can be in any upward recursive folder. Since we want `debugtalk.py` to be importable, we should put a `__init__.py` in its folder to make it as a Python module. @@ -187,7 +187,7 @@ And then, we can revise our demo test case and reference the functions. Suppose ```yaml - test: name: get token - variable_binds: + variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' @@ -226,7 +226,7 @@ And then, we can revise our demo test case and reference the functions. Suppose In this revised test case, `variable reference` and `function invoke` mechanisms are both used. -To make fields like `device_sn` can be used more than once, we bind values to variables in `variable_binds` block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. +To make fields like `device_sn` can be used more than once, we bind values to variables in `variables` block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. When we want to reference a variable in the test case, we can do this with a escape character `$`. For example, `$user_agent` will not be taken as a normal string, and `ApiTestEngine` will consider it as a variable named `user_agent`, search and return its binding value. @@ -244,7 +244,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If # examples/quickstart-demo-rev-3.yml - config: name: "smoketest for CRUD users." - variable_binds: + variables: - device_sn: ${gen_random_string(15)} request: base_url: http://127.0.0.1:5000 @@ -253,7 +253,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If - test: name: get token - variable_binds: + variables: - user_agent: 'iOS/10.3' - os_platform: 'ios' - app_version: '2.8.6' diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index 540a4ad34..7b2282f81 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -1,6 +1,6 @@ - test: name: get token - variable_binds: + variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index 44c3bbbf6..cb9370116 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -1,6 +1,6 @@ - config: name: "smoketest for CRUD users." - variable_binds: + variables: - device_sn: ${gen_random_string(15)} request: base_url: http://127.0.0.1:5000 @@ -9,7 +9,7 @@ - test: name: get token - variable_binds: + variables: - user_agent: 'iOS/10.3' - os_platform: 'ios' - app_version: '2.8.6' diff --git a/tests/data/demo_binds.yml b/tests/data/demo_binds.yml index 18135cc7f..1ceb4db91 100644 --- a/tests/data/demo_binds.yml +++ b/tests/data/demo_binds.yml @@ -1,5 +1,5 @@ bind_variables: - variable_binds: + variables: - TOKEN: "debugtalk" - token: $TOKEN @@ -7,7 +7,7 @@ bind_lambda_functions: function_binds: add_one: "lambda x: x + 1" add_two_nums: "lambda x, y: x + y" - variable_binds: + variables: - add1: ${add_one(2)} - sum2nums: ${add_two_nums(2, 3)} @@ -19,7 +19,7 @@ bind_lambda_functions_with_import: function_binds: gen_random_string: "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))" gen_md5: "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" - variable_binds: + variables: - TOKEN: debugtalk - random: ${gen_random_string(5)} - data: "{'name': 'user', 'password': '123456'}" @@ -29,7 +29,7 @@ bind_module_functions: function_binds: import_module_items: - tests.data.debugtalk - variable_binds: + variables: - TOKEN: debugtalk - random: ${gen_random_string(5)} - data: "{'name': 'user', 'password': '123456'}" diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index ba22272fe..441027504 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -1,6 +1,6 @@ - config: name: "user management testset." - variable_binds: + variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' @@ -35,7 +35,7 @@ - test: name: create user which does not exist - variable_binds: + variables: - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) @@ -53,7 +53,7 @@ - test: name: create user which exists - variable_binds: + variables: - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) @@ -63,7 +63,7 @@ - test: name: update user which exists - variable_binds: + variables: - user_name: "user1" - user_password: "654321" api: update_user(1000, $user_name, $user_password, $token) @@ -102,7 +102,7 @@ - test: name: create user which has been deleted - variable_binds: + variables: - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index 28ffdbb7b..fc710cf84 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -2,7 +2,7 @@ name: "create user testsets." import_module_items: - tests.data.debugtalk - variable_binds: + variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string(15)} - os_platform: 'ios' @@ -33,7 +33,7 @@ - test: name: create user which does not exist - variable_binds: + variables: - user_name: "user1" - user_password: "123456" request: diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index 26d5eecf8..7e450aa60 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -12,7 +12,7 @@ 'DebugTalk'.encode('ascii'), ''.join(args).encode('ascii'), hashlib.sha1).hexdigest()" - variable_binds: + variables: - user_agent: 'iOS/10.3' - device_sn: ${gen_random_string_lambda(15)} - os_platform: 'ios' @@ -43,7 +43,7 @@ - test: name: create user which does not exist - variable_binds: + variables: - user_name: "user1" - user_password: "123456" request: diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index 98dd3d16f..56e31fd6c 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -1,6 +1,6 @@ - config: name: "create user testsets." - variable_binds: + variables: - device_sn: 'HZfFBh6tU59EdXJ' request: base_url: $BASE_URL @@ -10,7 +10,7 @@ - test: name: get token - variable_binds: + variables: - user_agent: 'iOS/10.3' - os_platform: 'ios' - app_version: '2.8.6' @@ -34,7 +34,7 @@ - test: name: create user which does not exist - variable_binds: + variables: - user_name: "user1" - user_password: "123456" request: diff --git a/tests/test_context.py b/tests/test_context.py index a1fc774fe..ce07f7afb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -18,11 +18,11 @@ def test_context_init_functions(self): self.assertIn("get_timestamp", self.context.testset_functions_config) self.assertIn("gen_random_string", self.context.testset_functions_config) - variable_binds = [ + variables = [ {"random": "${gen_random_string(5)}"}, {"timestamp10": "${get_timestamp(10)}"} ] - self.context.bind_variables(variable_binds) + self.context.bind_variables(variables) context_variables = self.context.get_testcase_variables_mapping() self.assertEqual(len(context_variables["random"]), 5) @@ -31,7 +31,7 @@ def test_context_init_functions(self): def test_context_bind_testset_variables(self): # testcase in JSON format testcase1 = { - "variable_binds": [ + "variables": [ {"GLOBAL_TOKEN": "debugtalk"}, {"token": "$GLOBAL_TOKEN"} ] @@ -40,8 +40,8 @@ def test_context_bind_testset_variables(self): testcase2 = self.testcases["bind_variables"] for testcase in [testcase1, testcase2]: - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds, level="testset") + variables = testcase['variables'] + self.context.bind_variables(variables, level="testset") testset_variables = self.context.testset_shared_variables_mapping testcase_variables = self.context.get_testcase_variables_mapping() @@ -54,7 +54,7 @@ def test_context_bind_testset_variables(self): def test_context_bind_testcase_variables(self): testcase1 = { - "variable_binds": [ + "variables": [ {"GLOBAL_TOKEN": "debugtalk"}, {"token": "$GLOBAL_TOKEN"} ] @@ -62,8 +62,8 @@ def test_context_bind_testcase_variables(self): testcase2 = self.testcases["bind_variables"] for testcase in [testcase1, testcase2]: - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + variables = testcase['variables'] + self.context.bind_variables(variables) testset_variables = self.context.testset_shared_variables_mapping testcase_variables = self.context.get_testcase_variables_mapping() @@ -80,7 +80,7 @@ def test_context_bind_lambda_functions(self): "add_one": lambda x: x + 1, "add_two_nums": lambda x, y: x + y }, - "variable_binds": [ + "variables": [ {"add1": "${add_one(2)}"}, {"sum2nums": "${add_two_nums(2,3)}"} ] @@ -91,8 +91,8 @@ def test_context_bind_lambda_functions(self): function_binds = testcase.get('function_binds', {}) self.context.bind_functions(function_binds) - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + variables = testcase['variables'] + self.context.bind_variables(variables) context_variables = self.context.get_testcase_variables_mapping() self.assertIn("add1", context_variables) @@ -107,7 +107,7 @@ def test_context_bind_lambda_functions_with_import(self): "gen_random_string": "lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(str_len))", "gen_md5": "lambda *str_args: hashlib.md5(''.join(str_args).encode('utf-8')).hexdigest()" }, - "variable_binds": [ + "variables": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, {"data": '{"name": "user", "password": "123456"}'}, @@ -123,8 +123,8 @@ def test_context_bind_lambda_functions_with_import(self): function_binds = testcase.get('function_binds', {}) self.context.bind_functions(function_binds) - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + variables = testcase['variables'] + self.context.bind_variables(variables) context_variables = self.context.get_testcase_variables_mapping() self.assertIn("TOKEN", context_variables) @@ -144,7 +144,7 @@ def test_context_bind_lambda_functions_with_import(self): def test_import_module_items(self): testcase1 = { "import_module_items": ["tests.data.debugtalk"], - "variable_binds": [ + "variables": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, {"data": '{"name": "user", "password": "123456"}'}, @@ -157,8 +157,8 @@ def test_import_module_items(self): module_items = testcase.get('import_module_items', []) self.context.import_module_items(module_items) - variable_binds = testcase['variable_binds'] - self.context.bind_variables(variable_binds) + variables = testcase['variables'] + self.context.bind_variables(variables) context_variables = self.context.get_testcase_variables_mapping() self.assertIn("TOKEN", context_variables) @@ -182,7 +182,7 @@ def test_get_parsed_request(self): test_runner = runner.Runner() testcase = { "import_module_items": ["tests.data.debugtalk"], - "variable_binds": [ + "variables": [ {"TOKEN": "debugtalk"}, {"random": "${gen_random_string(5)}"}, {"data": '{"name": "user", "password": "123456"}'}, @@ -206,7 +206,7 @@ def test_get_parsed_request(self): self.assertIn("random", parsed_request["headers"]) self.assertEqual(len(parsed_request["headers"]["random"]), 5) self.assertIn("data", parsed_request) - self.assertEqual(parsed_request["data"], testcase["variable_binds"][2]["data"]) + self.assertEqual(parsed_request["data"], testcase["variables"][2]["data"]) self.assertEqual(parsed_request["headers"]["SECRET_KEY"], "DebugTalk") def test_exec_content_functions(self): diff --git a/tests/test_testcase.py b/tests/test_testcase.py index a94d2d932..633d9f8af 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -55,7 +55,7 @@ def test_extract_variables(self): ) def test_eval_content_variables(self): - variable_binds = { + variables = { "var_1": "abc", "var_2": "def", "var_3": 123, @@ -63,7 +63,7 @@ def test_eval_content_variables(self): "var_5": True, "var_6": None } - testcase_parser = testcase.TestcaseParser(variables_binds=variable_binds) + testcase_parser = testcase.TestcaseParser(variables_binds=variables) self.assertEqual( testcase_parser.eval_content_variables("$var_1"), "abc" @@ -417,7 +417,7 @@ def test_load_testcases_by_path_layered(self): path = os.path.join( os.getcwd(), 'tests/data/demo_testset_layer.yml') testsets_list = testcase.load_testcases_by_path(path) - self.assertIn("variable_binds", testsets_list[0]["config"]) + self.assertIn("variables", testsets_list[0]["config"]) self.assertIn("request", testsets_list[0]["config"]) self.assertIn("request", testsets_list[0]["testcases"][0]) self.assertIn("url", testsets_list[0]["testcases"][0]["request"]) From 9e17282f3b5f98355e1fbcb84e450f89952c5f4a Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 16:06:37 +0800 Subject: [PATCH 286/354] rename TestcaseParser argument name: variables_binds => variables --- ate/testcase.py | 14 +++++++------- tests/test_testcase.py | 24 ++++++++++++------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index dcd4c51ef..2f8881b6d 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -329,14 +329,14 @@ def substitute_variables_with_mapping(content, mapping): class TestcaseParser(object): - def __init__(self, variables_binds={}, functions_binds={}, file_path=None): - self.bind_variables(variables_binds) + def __init__(self, variables={}, functions_binds={}, file_path=None): + self.bind_variables(variables) self.bind_functions(functions_binds) self.file_path = file_path - def bind_variables(self, variables_binds): + def bind_variables(self, variables): """ bind variables to current testcase parser - @param (dict) variables_binds, variables binds mapping + @param (dict) variables, variables binds mapping { "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", "random": "A2dEx", @@ -344,7 +344,7 @@ def bind_variables(self, variables_binds): "uuid": 1000 } """ - self.variables_binds = variables_binds + self.variables = variables def bind_functions(self, functions_binds): """ bind functions to current testcase parser @@ -360,8 +360,8 @@ def get_bind_item(self, item_type, item_name): if item_name in self.functions_binds: return self.functions_binds[item_name] elif item_type == "variable": - if item_name in self.variables_binds: - return self.variables_binds[item_name] + if item_name in self.variables: + return self.variables[item_name] else: raise exception.ParamsError("bind item should only be function or variable.") diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 633d9f8af..694368e48 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -63,7 +63,7 @@ def test_eval_content_variables(self): "var_5": True, "var_6": None } - testcase_parser = testcase.TestcaseParser(variables_binds=variables) + testcase_parser = testcase.TestcaseParser(variables=variables) self.assertEqual( testcase_parser.eval_content_variables("$var_1"), "abc" @@ -157,11 +157,11 @@ def test_parse_function(self): ) def test_parse_content_with_bindings_variables(self): - variables_binds = { + variables = { "str_1": "str_value1", "str_2": "str_value2" } - testcase_parser = testcase.TestcaseParser(variables_binds=variables_binds) + testcase_parser = testcase.TestcaseParser(variables=variables) self.assertEqual( testcase_parser.parse_content_with_bindings("$str_1"), "str_value1" @@ -184,11 +184,11 @@ def test_parse_content_with_bindings_variables(self): ) def test_parse_content_with_bindings_multiple_identical_variables(self): - variables_binds = { + variables = { "userid": 100, "data": 1498 } - testcase_parser = testcase.TestcaseParser(variables_binds=variables_binds) + testcase_parser = testcase.TestcaseParser(variables=variables) content = "/users/$userid/training/$data?userId=$userid&data=$data" self.assertEqual( testcase_parser.parse_content_with_bindings(content), @@ -196,12 +196,12 @@ def test_parse_content_with_bindings_multiple_identical_variables(self): ) def test_parse_variables_multiple_identical_variables(self): - variables_binds = { + variables = { "user": 100, "userid": 1000, "data": 1498 } - testcase_parser = testcase.TestcaseParser(variables_binds=variables_binds) + testcase_parser = testcase.TestcaseParser(variables=variables) content = "/users/$user/$userid/$data?userId=$userid&data=$data" self.assertEqual( testcase_parser.parse_content_with_bindings(content), @@ -289,7 +289,7 @@ def test_eval_content_functions_search_upward(self): self.assertEqual(content, "/api/900150983cd24fb0d6963f7d28e17f72") def test_parse_content_with_bindings_testcase(self): - variables_binds = { + variables = { "uid": "1000", "random": "A2dEx", "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", @@ -310,7 +310,7 @@ def test_parse_content_with_bindings_testcase(self): }, "body": "$data" } - parsed_testcase = testcase.TestcaseParser(variables_binds, functions_binds)\ + parsed_testcase = testcase.TestcaseParser(variables, functions_binds)\ .parse_content_with_bindings(testcase_template) self.assertEqual( @@ -319,15 +319,15 @@ def test_parse_content_with_bindings_testcase(self): ) self.assertEqual( parsed_testcase["headers"]["authorization"], - variables_binds["authorization"] + variables["authorization"] ) self.assertEqual( parsed_testcase["headers"]["random"], - variables_binds["random"] + variables["random"] ) self.assertEqual( parsed_testcase["body"], - variables_binds["data"] + variables["data"] ) self.assertEqual( parsed_testcase["headers"]["sum"], From 990871896355e96dd00eb9478a8c4768f6903bdf Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 16:08:09 +0800 Subject: [PATCH 287/354] rename TestcaseParser argument name: functions_binds => functions --- ate/testcase.py | 14 +++++++------- tests/test_testcase.py | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 2f8881b6d..c060e5416 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -329,9 +329,9 @@ def substitute_variables_with_mapping(content, mapping): class TestcaseParser(object): - def __init__(self, variables={}, functions_binds={}, file_path=None): + def __init__(self, variables={}, functions={}, file_path=None): self.bind_variables(variables) - self.bind_functions(functions_binds) + self.bind_functions(functions) self.file_path = file_path def bind_variables(self, variables): @@ -346,19 +346,19 @@ def bind_variables(self, variables): """ self.variables = variables - def bind_functions(self, functions_binds): + def bind_functions(self, functions): """ bind functions to current testcase parser - @param (dict) functions_binds, functions binds mapping + @param (dict) functions, functions binds mapping { "add_two_nums": lambda a, b=1: a + b } """ - self.functions_binds = functions_binds + self.functions = functions def get_bind_item(self, item_type, item_name): if item_type == "function": - if item_name in self.functions_binds: - return self.functions_binds[item_name] + if item_name in self.functions: + return self.functions[item_name] elif item_type == "variable": if item_name in self.variables: return self.variables[item_name] diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 694368e48..0ef354825 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -210,17 +210,17 @@ def test_parse_variables_multiple_identical_variables(self): def test_parse_content_with_bindings_functions(self): import random, string - functions_binds = { + functions = { "gen_random_string": lambda str_len: ''.join(random.choice(string.ascii_letters + string.digits) \ for _ in range(str_len)) } - testcase_parser = testcase.TestcaseParser(functions_binds=functions_binds) + testcase_parser = testcase.TestcaseParser(functions=functions) result = testcase_parser.parse_content_with_bindings("${gen_random_string(5)}") self.assertEqual(len(result), 5) add_two_nums = lambda a, b=1: a + b - functions_binds["add_two_nums"] = add_two_nums + functions["add_two_nums"] = add_two_nums self.assertEqual( testcase_parser.parse_content_with_bindings("${add_two_nums(1)}"), 2 @@ -265,10 +265,10 @@ def test_extract_functions(self): ) def test_eval_content_functions(self): - functions_binds = { + functions = { "add_two_nums": lambda a, b=1: a + b } - testcase_parser = testcase.TestcaseParser(functions_binds=functions_binds) + testcase_parser = testcase.TestcaseParser(functions=functions) self.assertEqual( testcase_parser.eval_content_functions("${add_two_nums(1, 2)}"), 3 @@ -295,7 +295,7 @@ def test_parse_content_with_bindings_testcase(self): "authorization": "a83de0ff8d2e896dbd8efb81ba14e17d", "data": {"name": "user", "password": "123456"} } - functions_binds = { + functions = { "add_two_nums": lambda a, b=1: a + b, "get_timestamp": lambda: int(time.time() * 1000) } @@ -310,7 +310,7 @@ def test_parse_content_with_bindings_testcase(self): }, "body": "$data" } - parsed_testcase = testcase.TestcaseParser(variables, functions_binds)\ + parsed_testcase = testcase.TestcaseParser(variables, functions)\ .parse_content_with_bindings(testcase_template) self.assertEqual( From 132dcc174a278dba4a444447d24f57bc6b9869ec Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 24 Oct 2017 17:04:20 +0800 Subject: [PATCH 288/354] give up support for Python 3.3 --- .travis.yml | 1 - README.md | 2 +- requirements_dev.txt | 1 - setup.py | 10 ++-------- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index b0ec3a619..56d05c582 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ sudo: false language: python python: - 2.7 - - 3.3 - 3.4 - 3.5 - 3.6 diff --git a/README.md b/README.md index 6f97d7c5f..e89de64b8 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Enjoy! ## Supported Python Versions -Python `2.7`, `3.3`, `3.4`, `3.5` and `3.6`. +Python `2.7`, `3.4`, `3.5` and `3.6`. `ApiTestEngine` has been tested on `macOS`, `Linux` and `Windows` platforms. diff --git a/requirements_dev.txt b/requirements_dev.txt index bc7d382c0..8a7c8f00b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,5 +4,4 @@ PyYAML coveralls coverage -e git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport --e git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py -e git+https://github.com/locustio/locust.git#egg=locustio \ No newline at end of file diff --git a/setup.py b/setup.py index 4b502e244..1339bae50 100644 --- a/setup.py +++ b/setup.py @@ -30,26 +30,20 @@ "PyUnitReport" ], extras_require={ - 'mail': [ - "jenkins-mail-py" - ], - 'locust': [ + 'locustio': [ "locustio" ] }, dependency_links=[ "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0", - "git+https://github.com/debugtalk/jenkins-mail-py.git#egg=jenkins-mail-py-0", "git+https://github.com/locustio/locust.git#egg=locust-0" ], classifiers=[ "Development Status :: 3 - Alpha", 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7-dev' + 'Programming Language :: Python :: 3.6' ], entry_points={ 'console_scripts': [ From e4caa4528a5750368747bfdd6149a78ebdd33dcd Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 28 Oct 2017 11:27:38 +0800 Subject: [PATCH 289/354] remove unused import --- ate/locustfile_template | 1 - 1 file changed, 1 deletion(-) diff --git a/ate/locustfile_template b/ate/locustfile_template index 10f53d430..e1ebbdb23 100644 --- a/ate/locustfile_template +++ b/ate/locustfile_template @@ -1,6 +1,5 @@ #coding: utf-8 import zmq -import os from locust import HttpLocust, TaskSet, task from ate import runner, exception From 351000b20b16234f38ff8067ba4d959e47452957 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 30 Oct 2017 17:54:04 +0800 Subject: [PATCH 290/354] print http response content when exception occured. --- ate/runner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ate/runner.py b/ate/runner.py index b84e9206b..142f05f31 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,3 +1,4 @@ +import logging from collections import OrderedDict from ate import exception, response, testcase, utils @@ -120,7 +121,12 @@ def setup_teardown(actions): extracted_variables_mapping = resp_obj.extract_response(extractors) self.context.bind_variables(extracted_variables_mapping, level="testset") - resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) + try: + resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) + except (exception.ParamsError, exception.ResponseError, exception.ValidationError): + text = "Exception occured. HTTP response content shows below: \n{}".format(resp.text) + logging.error(text) + raise setup_teardown(teardown_actions) From 5476ed70e1d681c28a6c6197e101d3e117b839d2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 30 Oct 2017 19:24:43 +0800 Subject: [PATCH 291/354] print http request kwargs when exception occured. --- ate/runner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 142f05f31..f9ae59550 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -124,8 +124,9 @@ def setup_teardown(actions): try: resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) except (exception.ParamsError, exception.ResponseError, exception.ValidationError): - text = "Exception occured. HTTP response content shows below: \n{}".format(resp.text) - logging.error(text) + logging.error("Exception occured.") + logging.error("HTTP request kwargs: \n{}".format(parsed_request)) + logging.error("HTTP response content: \n{}".format(resp.text)) raise setup_teardown(teardown_actions) From a1bc8dd964d12af72b6a83bee64d83895f9ea596 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 30 Oct 2017 20:22:14 +0800 Subject: [PATCH 292/354] locusts: support group url --- ate/__init__.py | 2 +- ate/client.py | 4 +++- ate/runner.py | 8 +++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index ed4e820fe..40b7b12c5 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.5' \ No newline at end of file +__version__ = '0.7.6' \ No newline at end of file diff --git a/ate/client.py b/ate/client.py index de2685ca5..26b7d9fac 100644 --- a/ate/client.py +++ b/ate/client.py @@ -54,7 +54,7 @@ def _build_url(self, path): else: raise ParamsError("base url missed!") - def request(self, method, url, **kwargs): + def request(self, method, url, name=None, **kwargs): """ Constructs and sends a :py:class:`requests.Request`. Returns :py:class:`requests.Response` object. @@ -63,6 +63,8 @@ def request(self, method, url, **kwargs): method for the new :class:`Request` object. :param url: URL for the new :class:`Request` object. + :param name: (optional) + Placeholder, make compatible with Locust's HttpSession :param params: (optional) Dictionary or bytes to be sent in the query string for the :class:`Request`. :param data: (optional) diff --git a/ate/runner.py b/ate/runner.py index f9ae59550..c406367fa 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -97,6 +97,7 @@ def _run_test(self, testcase): try: url = parsed_request.pop('url') method = parsed_request.pop('method') + group_name = parsed_request.pop("group", None) except KeyError: raise exception.ParamsError("URL or METHOD missed!") @@ -115,7 +116,12 @@ def setup_teardown(actions): for _ in range(run_times): setup_teardown(setup_actions) - resp = self.http_client_session.request(url=url, method=method, **parsed_request) + resp = self.http_client_session.request( + method, + url, + name=group_name, + **parsed_request + ) resp_obj = response.ResponseObject(resp) extracted_variables_mapping = resp_obj.extract_response(extractors) From 577cb01dcfdedeecf9858be511d2ee2704cec4d1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 31 Oct 2017 23:26:38 +0800 Subject: [PATCH 293/354] extractor: add support to extract value from html text with regex string --- README.md | 2 +- ate/__init__.py | 2 +- ate/response.py | 70 ++++++++++++++++++++++++++++++++++-------- tests/test_response.py | 51 +++++++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e89de64b8..8d258f281 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.7.5 +ApiTestEngine version: 0.7.7 ``` Execute the command `ate -h` to view command help. diff --git a/ate/__init__.py b/ate/__init__.py index 40b7b12c5..7adfc4e4b 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.6' \ No newline at end of file +__version__ = '0.7.7' \ No newline at end of file diff --git a/ate/response.py b/ate/response.py index 933ae25b7..b7013a75c 100644 --- a/ate/response.py +++ b/ate/response.py @@ -1,6 +1,11 @@ +import logging +import re from collections import OrderedDict from ate import exception, utils +from requests.structures import CaseInsensitiveDict + +text_extractor_regexp_compile = re.compile(r".*\(.*\).*") class ResponseObject(object): @@ -10,23 +15,45 @@ def __init__(self, resp_obj): @param (requests.Response instance) resp_obj """ self.resp_obj = resp_obj + self.resp_text = resp_obj.text + self.resp_body = self.parsed_body() def parsed_body(self): try: return self.resp_obj.json() except ValueError: - return self.resp_obj.text + return self.resp_text def parsed_dict(self): return { 'status_code': self.resp_obj.status_code, 'headers': self.resp_obj.headers, - 'body': self.parsed_body() + 'body': self.resp_body } - def extract_field(self, field, delimiter='.'): - """ extract field from requests.Response - @param (str) field of requests.Response object, and may be joined by delimiter + def _extract_field_with_regex(self, field): + """ extract field from response content with regex. + requests.Response body could be json or html text. + @param (str) field should only be regex string that matched r".*\(.*\).*" + e.g. + self.resp_text: "LB123abcRB789" + field: "LB[\d]*(.*)RB[\d]*" + return: abc + """ + matched = re.search(field, self.resp_text) + if not matched: + err_msg = "Extractor error: failed to extract data with regex!\n" + err_msg += "response body: {}\n".format(self.resp_text) + err_msg += "regex: {}\n".format(field) + logging.error(err_msg) + raise exception.ParamsError(err_msg) + + return matched.group(1) + + def _extract_field_with_delimiter(self, field): + """ response content could be json or html text. + @param (str) field should be string joined by delimiter. + e.g. "status_code" "content" "headers.content-type" @@ -36,28 +63,47 @@ def extract_field(self, field, delimiter='.'): # string.split(sep=None, maxsplit=-1) -> list of strings # e.g. "content.person.name" => ["content", "person.name"] try: - top_query, sub_query = field.split(delimiter, 1) + top_query, sub_query = field.split('.', 1) except ValueError: top_query = field sub_query = None if top_query in ["body", "content", "text"]: - json_content = self.parsed_body() + top_query_content = self.parsed_body() else: - json_content = getattr(self.resp_obj, top_query) + top_query_content = getattr(self.resp_obj, top_query) if sub_query: + if not isinstance(top_query_content, (dict, CaseInsensitiveDict, list)): + err_msg = "Extractor error: failed to extract data with regex!\n" + err_msg += "response: {}\n".format(self.parsed_dict()) + err_msg += "regex: {}\n".format(field) + logging.error(err_msg) + raise exception.ParamsError(err_msg) + # e.g. key: resp_headers_content_type, sub_query = "content-type" - return utils.query_json(json_content, sub_query) + return utils.query_json(top_query_content, sub_query) else: # e.g. key: resp_status_code, resp_content - return json_content + return top_query_content except AttributeError: - raise exception.ParseResponseError("failed to extract bind variable in response!") + err_msg = "Failed to extract value from response!\n" + err_msg += "response: {}\n".format(self.parsed_dict()) + err_msg += "extract field field: {}\n".format(field) + logging.error(err_msg) + raise exception.ParamsError(err_msg) + + def extract_field(self, field): + """ extract value from requests.Response. + """ + if text_extractor_regexp_compile.match(field): + return self._extract_field_with_regex(field) + else: + return self._extract_field_with_delimiter(field) def extract_response(self, extractors): - """ extract content from requests.Response + """ extract value from requests.Response and store in OrderedDict. @param (list) extractors [ {"resp_status_code": "status_code"}, diff --git a/tests/test_response.py b/tests/test_response.py index 948745713..cce3aca89 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -149,6 +149,55 @@ def test_extract_response_json_string(self): "abc" ) + def test_extract_text_response(self): + resp = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'Content-Type': "application/json" + }, + 'body': "LB123abcRB789" + } + ) + + extract_binds_list = [ + {"resp_content_key1": "LB123(.*)RB789"}, + {"resp_content_key2": "LB[\d]*(.*)RB[\d]*"}, + {"resp_content_key3": "LB[\d]*(.*)9"} + ] + resp_obj = response.ResponseObject(resp) + + extract_binds_dict = resp_obj.extract_response(extract_binds_list) + self.assertEqual( + extract_binds_dict["resp_content_key1"], + "abc" + ) + self.assertEqual( + extract_binds_dict["resp_content_key2"], + "abc" + ) + self.assertEqual( + extract_binds_dict["resp_content_key3"], + "abcRB78" + ) + + def test_extract_text_response_exception(self): + resp = requests.post( + url="http://127.0.0.1:5000/customize-response", + json={ + 'headers': { + 'Content-Type': "application/json" + }, + 'body': "LB123abcRB789" + } + ) + extract_binds_list = [ + {"resp_content_key1": "LB123.*RB789"} + ] + resp_obj = response.ResponseObject(resp) + with self.assertRaises(exception.ParamsError): + resp_obj.extract_response(extract_binds_list) + def test_extract_response_empty(self): resp = requests.post( url="http://127.0.0.1:5000/customize-response", @@ -174,7 +223,7 @@ def test_extract_response_empty(self): {"resp_content_body": "content.abc"} ] resp_obj = response.ResponseObject(resp) - with self.assertRaises(exception.ResponseError): + with self.assertRaises(exception.ParamsError): resp_obj.extract_response(extract_binds_list) def test_validate(self): From 62c64e9309642448adeaaed1415ca4d7389cea29 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 1 Nov 2017 14:30:36 +0800 Subject: [PATCH 294/354] rename extractor/extractors to extract --- README.md | 2 +- ate/runner.py | 7 +++---- docs/extraction-and-validation.md | 4 ++-- docs/quickstart.md | 8 ++++---- examples/quickstart-demo-rev-1.yml | 2 +- examples/quickstart-demo-rev-2.yml | 2 +- examples/quickstart-demo-rev-3.yml | 2 +- tests/data/demo_testset_hardcode.json | 2 +- tests/data/demo_testset_hardcode.yml | 2 +- tests/data/demo_testset_layer.yml | 2 +- tests/data/demo_testset_template_import_functions.yml | 2 +- tests/data/demo_testset_template_lambda_functions.yml | 2 +- tests/data/demo_testset_variables.yml | 2 +- tests/test_runner.py | 2 +- 14 files changed, 20 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8d258f281..760b3f1e6 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ And here is testset example of typical scenario: get `token` at the beginning, a app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/ate/runner.py b/ate/runner.py index c406367fa..46210efc9 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -85,7 +85,7 @@ def _run_test(self, testcase): }, "body": '{"name": "user", "password": "123456"}' }, - "extractors": [], # optional + "extract": [], # optional "validators": [], # optional "setup": [], # optional "teardown": [] # optional @@ -102,8 +102,7 @@ def _run_test(self, testcase): raise exception.ParamsError("URL or METHOD missed!") run_times = int(testcase.get("times", 1)) - extractors = testcase.get("extractors") \ - or testcase.get("extractor") \ + extractors = testcase.get("extract") \ or testcase.get("extract_binds", []) validators = testcase.get("validators", []) setup_actions = testcase.get("setup", []) @@ -157,7 +156,7 @@ def _run_testset(self, testset, variables_mapping=None): "name": "testcase description", "variables": [], # optional, override "request": {}, - "extractors": {}, # optional + "extract": {}, # optional "validators": {} # optional }, testcase12 diff --git a/docs/extraction-and-validation.md b/docs/extraction-and-validation.md index 0cb76fc0c..e2abdba38 100644 --- a/docs/extraction-and-validation.md +++ b/docs/extraction-and-validation.md @@ -24,7 +24,7 @@ Suppose we get the following HTTP response. } ``` -In `extractors` and `validators`, we can do chain operation to extract data field in HTTP response. +In `extract` and `validators`, we can do chain operation to extract data field in HTTP response. For instance, if we want to get `Content-Type` in response headers, then we can specify `headers.content-type`; if we want to get `first_name` in response content, we can specify `content.person.name.first_name`. @@ -46,7 +46,7 @@ content.person.cities.1 ``` ```yaml -extractors: +extract: - content_type: headers.content-type - first_name: content.person.name.first_name validators: diff --git a/docs/quickstart.md b/docs/quickstart.md index f1756da9d..6fe28976b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -120,7 +120,7 @@ To fix this problem, we should correlate `token` field in the second API test ca app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} @@ -142,7 +142,7 @@ To fix this problem, we should correlate `token` field in the second API test ca - {"check": "content.success", "comparator": "eq", "expected": true} ``` -As you see, the `token` field is no longer hardcoded, instead it is extracted from the first API request with `extractors` mechanism. In the meanwhile, it is assigned to `token` variable, which can be referenced by the subsequent API requests. +As you see, the `token` field is no longer hardcoded, instead it is extracted from the first API request with `extract` mechanism. In the meanwhile, it is assigned to `token` variable, which can be referenced by the subsequent API requests. Now we save the test cases to [`quickstart-demo-rev-1.yml`][quickstart-demo-rev-1] and rerun it, and we will find that both API requests to be successful. @@ -202,7 +202,7 @@ And then, we can revise our demo test case and reference the functions. Suppose app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} @@ -266,7 +266,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/examples/quickstart-demo-rev-1.yml b/examples/quickstart-demo-rev-1.yml index d203cf8ae..5884983b4 100644 --- a/examples/quickstart-demo-rev-1.yml +++ b/examples/quickstart-demo-rev-1.yml @@ -10,7 +10,7 @@ app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index 7b2282f81..5d403a979 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -15,7 +15,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index cb9370116..caae0485a 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -22,7 +22,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index bd1549330..0c89d8854 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -16,7 +16,7 @@ "sign": "f1219719911caae89ccc301679857ebfda115ca2" } }, - "extractors": [ + "extract": [ { "token": "content.token" } diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index 2b112e364..71061b919 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -11,7 +11,7 @@ app_version: '2.8.6' json: sign: f1219719911caae89ccc301679857ebfda115ca2 - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index 441027504..07514dd24 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -16,7 +16,7 @@ - test: name: get token api: get_token($user_agent, $device_sn, $os_platform, $app_version) - extractors: + extract: - token: content.token - test: diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index fc710cf84..9799d1444 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -25,7 +25,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index 7e450aa60..c45fb1b52 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -35,7 +35,7 @@ app_version: $app_version json: sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index 56e31fd6c..116d25409 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -26,7 +26,7 @@ app_version: $app_version json: sign: $sign - extractors: + extract: - token: content.token validators: - {"check": "status_code", "comparator": "eq", "expected": 200} diff --git a/tests/test_runner.py b/tests/test_runner.py index 9996d25aa..fcbf89d50 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -53,7 +53,7 @@ def test_run_single_testcase_fail(self): "sign": "f1219719911caae89ccc301679857ebfda115ca2" } }, - "extractors": [ + "extract": [ {"token": "content.token"} ], "validators": [ From 49797c51fd010e7859598b6d0243085cd479bbc8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 1 Nov 2017 14:37:47 +0800 Subject: [PATCH 295/354] rename validators to validate --- README.md | 4 ++-- ate/runner.py | 7 +++--- docs/extraction-and-validation.md | 4 ++-- docs/quickstart.md | 14 +++++------ examples/quickstart-demo-rev-0.yml | 4 ++-- examples/quickstart-demo-rev-1.yml | 4 ++-- examples/quickstart-demo-rev-2.yml | 4 ++-- examples/quickstart-demo-rev-3.yml | 4 ++-- tests/api/demo.yml | 2 +- tests/data/demo_testset_hardcode.json | 6 ++--- tests/data/demo_testset_hardcode.yml | 6 ++--- tests/data/demo_testset_layer.yml | 24 +++++++++---------- ...demo_testset_template_import_functions.yml | 6 ++--- ...demo_testset_template_lambda_functions.yml | 6 ++--- tests/data/demo_testset_variables.yml | 6 ++--- tests/test_runner.py | 2 +- tests/test_testcase.py | 2 +- 17 files changed, 53 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 760b3f1e6..1e82bc5fa 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ And here is testset example of typical scenario: get `token` at the beginning, a sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -114,7 +114,7 @@ And here is testset example of typical scenario: get `token` at the beginning, a json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} ``` diff --git a/ate/runner.py b/ate/runner.py index 46210efc9..2e4055afe 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -86,7 +86,7 @@ def _run_test(self, testcase): "body": '{"name": "user", "password": "123456"}' }, "extract": [], # optional - "validators": [], # optional + "validate": [], # optional "setup": [], # optional "teardown": [] # optional } @@ -104,7 +104,8 @@ def _run_test(self, testcase): run_times = int(testcase.get("times", 1)) extractors = testcase.get("extract") \ or testcase.get("extract_binds", []) - validators = testcase.get("validators", []) + validators = testcase.get("validate") \ + or testcase.get("validators", []) setup_actions = testcase.get("setup", []) teardown_actions = testcase.get("teardown", []) @@ -157,7 +158,7 @@ def _run_testset(self, testset, variables_mapping=None): "variables": [], # optional, override "request": {}, "extract": {}, # optional - "validators": {} # optional + "validate": {} # optional }, testcase12 ] diff --git a/docs/extraction-and-validation.md b/docs/extraction-and-validation.md index e2abdba38..0e380f3cc 100644 --- a/docs/extraction-and-validation.md +++ b/docs/extraction-and-validation.md @@ -24,7 +24,7 @@ Suppose we get the following HTTP response. } ``` -In `extract` and `validators`, we can do chain operation to extract data field in HTTP response. +In `extract` and `validate`, we can do chain operation to extract data field in HTTP response. For instance, if we want to get `Content-Type` in response headers, then we can specify `headers.content-type`; if we want to get `first_name` in response content, we can specify `content.person.name.first_name`. @@ -49,7 +49,7 @@ content.person.cities.1 extract: - content_type: headers.content-type - first_name: content.person.name.first_name -validators: +validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "headers.content-type", "expected": "application/json"} - {"check": "headers.content-length", "comparator": "gt", "expected": 40} diff --git a/docs/quickstart.md b/docs/quickstart.md index 6fe28976b..c6b2783b9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -65,7 +65,7 @@ Open your favorite text editor and you can write test cases like this. json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} ``` @@ -122,7 +122,7 @@ To fix this problem, we should correlate `token` field in the second API test ca sign: 19067cf712265eb5426db8d3664026c1ccea02b9 extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -137,7 +137,7 @@ To fix this problem, we should correlate `token` field in the second API test ca json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} ``` @@ -204,7 +204,7 @@ And then, we can revise our demo test case and reference the functions. Suppose sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -219,7 +219,7 @@ And then, we can revise our demo test case and reference the functions. Suppose json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} ``` @@ -268,7 +268,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -282,7 +282,7 @@ To handle this case, overall `config` block is supported in `ApiTestEngine`. If json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} ``` diff --git a/examples/quickstart-demo-rev-0.yml b/examples/quickstart-demo-rev-0.yml index 1080d5ebf..5580b17da 100644 --- a/examples/quickstart-demo-rev-0.yml +++ b/examples/quickstart-demo-rev-0.yml @@ -10,7 +10,7 @@ app_version: 2.8.6 json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -25,6 +25,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} diff --git a/examples/quickstart-demo-rev-1.yml b/examples/quickstart-demo-rev-1.yml index 5884983b4..0610615a6 100644 --- a/examples/quickstart-demo-rev-1.yml +++ b/examples/quickstart-demo-rev-1.yml @@ -12,7 +12,7 @@ sign: 19067cf712265eb5426db8d3664026c1ccea02b9 extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -27,6 +27,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index 5d403a979..c41a37f8e 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -17,7 +17,7 @@ sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -32,6 +32,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} \ No newline at end of file diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index caae0485a..952d2a59f 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -24,7 +24,7 @@ sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -38,6 +38,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} diff --git a/tests/api/demo.yml b/tests/api/demo.yml index ee752e393..d256bc686 100644 --- a/tests/api/demo.yml +++ b/tests/api/demo.yml @@ -10,7 +10,7 @@ app_version: $app_version json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index 0c89d8854..08d722615 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -21,7 +21,7 @@ "token": "content.token" } ], - "validators": [ + "validate": [ {"check": "status_code", "comparator": "eq", "expected": 200}, {"check": "content.token", "comparator": "len_eq", "expected": 16} ] @@ -43,7 +43,7 @@ "password": "123456" } }, - "validators": [ + "validate": [ {"check": "status_code", "comparator": "eq", "expected": 201}, {"check": "content.success", "comparator": "eq", "expected": true} ] @@ -65,7 +65,7 @@ "password": "123456" } }, - "validators": [ + "validate": [ {"check": "status_code", "comparator": "eq", "expected": 500}, {"check": "content.success", "comparator": "eq", "expected": false} ] diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index 71061b919..7646e86f4 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -13,7 +13,7 @@ sign: f1219719911caae89ccc301679857ebfda115ca2 extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -29,7 +29,7 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -45,6 +45,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} \ No newline at end of file diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index 07514dd24..50328b6fd 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -22,14 +22,14 @@ - test: name: reset all users api: reset_all($token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.success", "expected": true} - test: name: get user that does not exist api: get_user(1000, $token) - validators: + validate: - {"check": "status_code", "expected": 404} - {"check": "content.success", "expected": false} @@ -39,14 +39,14 @@ - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) - validators: + validate: - {"check": "status_code", "expected": 201} - {"check": "content.success", "expected": true} - test: name: get user that has been created api: get_user(1000, $token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.success", "expected": true} - {"check": "content.data.password", "expected": "123456"} @@ -57,7 +57,7 @@ - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) - validators: + validate: - {"check": "status_code", "expected": 500} - {"check": "content.success", "expected": false} @@ -67,14 +67,14 @@ - user_name: "user1" - user_password: "654321" api: update_user(1000, $user_name, $user_password, $token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.success", "expected": true} - test: name: get user that has been updated api: get_user(1000, $token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.success", "expected": true} - {"check": "content.data.password", "expected": "654321"} @@ -82,21 +82,21 @@ - test: name: get users api: get_users($token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.count", "expected": 1} - test: name: delete user that exists api: delete_user(1000, $token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.success", "expected": true} - test: name: get users api: get_users($token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.count", "expected": 0} @@ -106,13 +106,13 @@ - user_name: "user1" - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) - validators: + validate: - {"check": "status_code", "expected": 201} - {"check": "content.success", "expected": true} - test: name: get users api: get_users($token) - validators: + validate: - {"check": "status_code", "expected": 200} - {"check": "content.count", "expected": 1} diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index 9799d1444..d786ecdf9 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -27,7 +27,7 @@ sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -44,7 +44,7 @@ json: name: $user_name password: $user_password - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -58,6 +58,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index c45fb1b52..983217842 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -37,7 +37,7 @@ sign: ${get_sign_lambda($user_agent, $device_sn, $os_platform, $app_version)} extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -54,7 +54,7 @@ json: name: $user_name password: $user_password - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -68,6 +68,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index 116d25409..1efd6bb9e 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -28,7 +28,7 @@ sign: $sign extract: - token: content.token - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 200} - {"check": "content.token", "comparator": "len_eq", "expected": 16} @@ -45,7 +45,7 @@ json: name: $user_name password: $user_password - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 201} - {"check": "content.success", "comparator": "eq", "expected": true} @@ -59,6 +59,6 @@ json: name: "user1" password: "123456" - validators: + validate: - {"check": "status_code", "comparator": "eq", "expected": 500} - {"check": "content.success", "comparator": "eq", "expected": false} diff --git a/tests/test_runner.py b/tests/test_runner.py index fcbf89d50..006d72561 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -56,7 +56,7 @@ def test_run_single_testcase_fail(self): "extract": [ {"token": "content.token"} ], - "validators": [ + "validate": [ {"check": "status_code", "comparator": "eq", "expected": 205}, {"check": "content.token", "comparator": "len_eq", "expected": 19} ] diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 0ef354825..5b7ffdf59 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -421,7 +421,7 @@ def test_load_testcases_by_path_layered(self): self.assertIn("request", testsets_list[0]["config"]) self.assertIn("request", testsets_list[0]["testcases"][0]) self.assertIn("url", testsets_list[0]["testcases"][0]["request"]) - self.assertIn("validators", testsets_list[0]["testcases"][0]) + self.assertIn("validate", testsets_list[0]["testcases"][0]) def test_substitute_variables_with_mapping(self): content = { From 7edba7be518884ca48da9658ce1692c29e41c03e Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 1 Nov 2017 14:47:33 +0800 Subject: [PATCH 296/354] add extractors back, make compatible with older version --- ate/runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ate/runner.py b/ate/runner.py index 2e4055afe..b04ce320c 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -103,6 +103,7 @@ def _run_test(self, testcase): run_times = int(testcase.get("times", 1)) extractors = testcase.get("extract") \ + or testcase.get("extractors") \ or testcase.get("extract_binds", []) validators = testcase.get("validate") \ or testcase.get("validators", []) From ed9079c495428332516ae6ba1dc2d15f8bac774e Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 1 Nov 2017 16:42:52 +0800 Subject: [PATCH 297/354] TestcaseParser: rename function name, bind_variables => update_binded_variables --- ate/context.py | 4 ++-- ate/testcase.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ate/context.py b/ate/context.py index 3c8099304..36b6a64ce 100644 --- a/ate/context.py +++ b/ate/context.py @@ -34,7 +34,7 @@ def init_context(self, level='testset'): self.testcase_variables_mapping = copy.deepcopy(self.testset_shared_variables_mapping) self.testcase_parser.bind_functions(self.testcase_functions_config) - self.testcase_parser.bind_variables(self.testcase_variables_mapping) + self.testcase_parser.update_binded_variables(self.testcase_variables_mapping) if level == "testset": self.import_module_items(["ate.built_in"], "testset") @@ -117,7 +117,7 @@ def bind_variables(self, variables, level="testcase"): self.testset_shared_variables_mapping[variable_name] = variable_evale_value self.testcase_variables_mapping[variable_name] = variable_evale_value - self.testcase_parser.bind_variables(self.testcase_variables_mapping) + self.testcase_parser.update_binded_variables(self.testcase_variables_mapping) def __update_context_functions_config(self, level, config_mapping): """ diff --git a/ate/testcase.py b/ate/testcase.py index c060e5416..34a02725a 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -330,11 +330,11 @@ def substitute_variables_with_mapping(content, mapping): class TestcaseParser(object): def __init__(self, variables={}, functions={}, file_path=None): - self.bind_variables(variables) + self.update_binded_variables(variables) self.bind_functions(functions) self.file_path = file_path - def bind_variables(self, variables): + def update_binded_variables(self, variables): """ bind variables to current testcase parser @param (dict) variables, variables binds mapping { From d8630213f1a2bc1faa7075c4c5e4e56ddda70013 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 1 Nov 2017 16:54:48 +0800 Subject: [PATCH 298/354] add bind_extracted_variables: extracted value do not need to evaluate --- ate/context.py | 10 ++++++++++ ate/runner.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ate/context.py b/ate/context.py index 36b6a64ce..cda6c5a3f 100644 --- a/ate/context.py +++ b/ate/context.py @@ -119,6 +119,16 @@ def bind_variables(self, variables, level="testcase"): self.testcase_variables_mapping[variable_name] = variable_evale_value self.testcase_parser.update_binded_variables(self.testcase_variables_mapping) + def bind_extracted_variables(self, variables): + """ bind extracted variables to testset context + @param (OrderDict) variables + extracted value do not need to evaluate. + """ + for variable_name, value in variables.items(): + self.testset_shared_variables_mapping[variable_name] = value + self.testcase_variables_mapping[variable_name] = value + self.testcase_parser.update_binded_variables(self.testcase_variables_mapping) + def __update_context_functions_config(self, level, config_mapping): """ @param level: testset or testcase diff --git a/ate/runner.py b/ate/runner.py index b04ce320c..7c80de964 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -126,7 +126,7 @@ def setup_teardown(actions): resp_obj = response.ResponseObject(resp) extracted_variables_mapping = resp_obj.extract_response(extractors) - self.context.bind_variables(extracted_variables_mapping, level="testset") + self.context.bind_extracted_variables(extracted_variables_mapping) try: resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) From 826f737280b7b9b385b100c1e05946955e72d09e Mon Sep 17 00:00:00 2001 From: firefoxwang <1228137800@qq.com> Date: Wed, 1 Nov 2017 20:25:40 +0800 Subject: [PATCH 299/354] =?UTF-8?q?bugfix:Presentation=20file=EF=BC=88base?= =?UTF-8?q?.py=EF=BC=89=20multiprocessing=20problem=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/base.py b/tests/base.py index faa15ef0a..aa9d1b70a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -6,8 +6,10 @@ from ate import utils from tests import api_server + def apprun(): - _ = api_server.app.run() + api_server.app.run() + class ApiServerUnittest(unittest.TestCase): """ Test case class that sets up an HTTP server which can be used within the tests From eac1173cd7db62b7ef7b46fe51fc861f2592feb6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 11:49:43 +0800 Subject: [PATCH 300/354] add testcase content check --- ate/cli.py | 9 ++++++-- ate/exception.py | 3 +++ ate/testcase.py | 16 +++++++++++++++ ate/utils.py | 20 +++++++++++++++--- tests/test_utils.py | 50 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index 2d04409cb..401189e6e 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -4,7 +4,7 @@ import sys from collections import OrderedDict -from ate import __version__ +from ate import __version__, exception from ate.task import TaskSuite from ate.utils import create_scaffold @@ -62,7 +62,12 @@ def main_ate(): for testset_path in set(args.testset_paths): testset_path = testset_path.rstrip('/') - task_suite = TaskSuite(testset_path) + + try: + task_suite = TaskSuite(testset_path) + except exception.FileFormatError: + success = False + continue output_folder_name = os.path.basename(os.path.splitext(testset_path)[0]) kwargs = { diff --git a/ate/exception.py b/ate/exception.py index 38460efc6..6abf7ae32 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -7,6 +7,9 @@ class MyBaseError(BaseException): pass +class FileFormatError(MyBaseError): + pass + class ParamsError(MyBaseError): pass diff --git a/ate/testcase.py b/ate/testcase.py index 34a02725a..563e6451b 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,4 +1,5 @@ import ast +import logging import os import re @@ -326,6 +327,21 @@ def substitute_variables_with_mapping(content, mapping): return content +def check_format(file_path, content): + """ check testcase format if valid + """ + if not content: + # testcase file content is empty + err_msg = "Testcase file content is empty: {}".format(file_path) + logging.error(err_msg) + raise exception.FileFormatError(err_msg) + + elif not isinstance(content, (list, dict)): + # testcase file content does not match testcase format + err_msg = "Testcase file content format invalid: {}".format(file_path) + logging.error(err_msg) + raise exception.FileFormatError(err_msg) + class TestcaseParser(object): diff --git a/ate/utils.py b/ate/utils.py index e05eb45e0..915d58e65 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -13,7 +13,7 @@ from collections import OrderedDict import yaml -from ate import exception +from ate import exception, testcase from requests.structures import CaseInsensitiveDict try: @@ -42,12 +42,26 @@ def get_sign(*args): return sign def load_yaml_file(yaml_file): + """ load yaml file and check file content format + """ with codecs.open(yaml_file, 'r+', encoding='utf-8') as stream: - return yaml.load(stream) + yaml_content = yaml.load(stream) + testcase.check_format(yaml_file, yaml_content) + return yaml_content def load_json_file(json_file): + """ load json file and check file content format + """ with codecs.open(json_file, encoding='utf-8') as data_file: - return json.load(data_file) + try: + json_content = json.load(data_file) + except json.decoder.JSONDecodeError: + err_msg = "JSONDecodeError: JSON file format error: {}".format(json_file) + logging.error(err_msg) + raise exception.FileFormatError(err_msg) + + testcase.check_format(json_file, json_content) + return json_content def load_tests(testcase_file_path): file_suffix = os.path.splitext(testcase_file_path)[1] diff --git a/tests/test_utils.py b/tests/test_utils.py index eb274b846..fd9565479 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -34,6 +34,55 @@ def test_load_yaml_testcases(self): self.assertIn('url', testcase['request']) self.assertIn('method', testcase['request']) + def test_load_yaml_file_file_format_error(self): + yaml_tmp_file = "tests/data/tmp.yml" + # create empty yaml file + with open(yaml_tmp_file, 'w') as f: + f.write("") + + with self.assertRaises(exception.FileFormatError): + utils.load_yaml_file(yaml_tmp_file) + + os.remove(yaml_tmp_file) + + # create invalid format yaml file + with open(yaml_tmp_file, 'w') as f: + f.write("abc") + + with self.assertRaises(exception.FileFormatError): + utils.load_yaml_file(yaml_tmp_file) + + os.remove(yaml_tmp_file) + + def test_load_json_file_file_format_error(self): + json_tmp_file = "tests/data/tmp.json" + # create empty file + with open(json_tmp_file, 'w') as f: + f.write("") + + with self.assertRaises(exception.FileFormatError): + utils.load_json_file(json_tmp_file) + + os.remove(json_tmp_file) + + # create empty json file + with open(json_tmp_file, 'w') as f: + f.write("{}") + + with self.assertRaises(exception.FileFormatError): + utils.load_json_file(json_tmp_file) + + os.remove(json_tmp_file) + + # create invalid format json file + with open(json_tmp_file, 'w') as f: + f.write("abc") + + with self.assertRaises(exception.FileFormatError): + utils.load_json_file(json_tmp_file) + + os.remove(json_tmp_file) + def test_load_folder_files(self): folder = os.path.join(os.getcwd(), 'tests') file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') @@ -50,7 +99,6 @@ def test_load_folder_files(self): api_file = os.path.join(os.getcwd(), 'tests', 'api', 'demo.yml') self.assertEqual(files_1[0], api_file) - folder_list = [folder, folder] files_2 = utils.load_folder_files(folder) api_file = os.path.join(os.getcwd(), 'tests', 'api', 'demo.yml') self.assertEqual(files_2[0], api_file) From 02fa6050ddbe0cfb9e5800b08386b0f00eed7ea6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 12:03:05 +0800 Subject: [PATCH 301/354] add testcase file path check --- ate/cli.py | 3 +++ ate/task.py | 5 ++++- ate/testcase.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ate/cli.py b/ate/cli.py index 401189e6e..314943460 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -68,6 +68,9 @@ def main_ate(): except exception.FileFormatError: success = False continue + except exception.FileNotFoundError: + success = False + continue output_folder_name = os.path.basename(os.path.splitext(testset_path)[0]) kwargs = { diff --git a/ate/task.py b/ate/task.py index 572192f5d..438059a1b 100644 --- a/ate/task.py +++ b/ate/task.py @@ -1,6 +1,7 @@ +import logging import unittest -from ate import runner, testcase, utils +from ate import exception, runner, testcase, utils class ApiTestCase(unittest.TestCase): @@ -50,6 +51,8 @@ def __init__(self, testcase_path): super(TaskSuite, self).__init__() self.suite_list = [] testsets = testcase.load_testcases_by_path(testcase_path) + if not testsets: + raise exception.FileNotFoundError for testset in testsets: suite = ApiTestSuite(testset) diff --git a/ate/testcase.py b/ate/testcase.py index 563e6451b..1e393fe7d 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -166,6 +166,7 @@ def load_testcases_by_path(path): testcases_list = [] else: + logging.error("file not found: {}".format(path)) testcases_list = [] testcases_cache_mapping[path] = testcases_list From 5b0d28b8c65e9762985c74bbc52702f038ac8787 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 12:45:59 +0800 Subject: [PATCH 302/354] fix circular reference in utils and testcase module --- ate/exception.py | 7 ++++ ate/testcase.py | 37 +++++++++++++++++++- ate/utils.py | 36 +------------------- tests/test_context.py | 4 +-- tests/test_runner.py | 21 ++++++------ tests/test_testcase.py | 77 +++++++++++++++++++++++++++++++++++++++++- tests/test_utils.py | 75 ---------------------------------------- 7 files changed, 132 insertions(+), 125 deletions(-) diff --git a/ate/exception.py b/ate/exception.py index 6abf7ae32..70461d59c 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -1,9 +1,16 @@ #coding: utf-8 +import json + try: FileNotFoundError = FileNotFoundError except NameError: FileNotFoundError = IOError +try: + JSONDecodeError = json.decoder.JSONDecodeError +except AttributeError: + JSONDecodeError = ValueError + class MyBaseError(BaseException): pass diff --git a/ate/testcase.py b/ate/testcase.py index 1e393fe7d..0f0dfed40 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -1,8 +1,11 @@ import ast +import codecs +import json import logging import os import re +import yaml from ate import exception, utils variable_regexp = r"\$([\w_]+)" @@ -16,6 +19,38 @@ testcases_cache_mapping = {} +def load_yaml_file(yaml_file): + """ load yaml file and check file content format + """ + with codecs.open(yaml_file, 'r+', encoding='utf-8') as stream: + yaml_content = yaml.load(stream) + check_format(yaml_file, yaml_content) + return yaml_content + +def load_json_file(json_file): + """ load json file and check file content format + """ + with codecs.open(json_file, encoding='utf-8') as data_file: + try: + json_content = json.load(data_file) + except exception.JSONDecodeError: + err_msg = "JSONDecodeError: JSON file format error: {}".format(json_file) + logging.error(err_msg) + raise exception.FileFormatError(err_msg) + + check_format(json_file, json_content) + return json_content + +def load_tests(testcase_file_path): + file_suffix = os.path.splitext(testcase_file_path)[1] + if file_suffix == '.json': + return load_json_file(testcase_file_path) + elif file_suffix in ['.yaml', '.yml']: + return load_yaml_file(testcase_file_path) + else: + # '' or other suffix + return [] + def extract_variables(content): """ extract all variable names from content, which is in format $variable @param (str) content @@ -191,7 +226,7 @@ def load_test_file(file_path): "api": {}, "testcases": [] } - tests_list = utils.load_tests(file_path) + tests_list = load_tests(file_path) for item in tests_list: for key in item: diff --git a/ate/utils.py b/ate/utils.py index 915d58e65..e904d6f2a 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -1,9 +1,7 @@ -import codecs import hashlib import hmac import imp import importlib -import json import logging import os.path import random @@ -13,7 +11,7 @@ from collections import OrderedDict import yaml -from ate import exception, testcase +from ate import exception from requests.structures import CaseInsensitiveDict try: @@ -41,38 +39,6 @@ def get_sign(*args): sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() return sign -def load_yaml_file(yaml_file): - """ load yaml file and check file content format - """ - with codecs.open(yaml_file, 'r+', encoding='utf-8') as stream: - yaml_content = yaml.load(stream) - testcase.check_format(yaml_file, yaml_content) - return yaml_content - -def load_json_file(json_file): - """ load json file and check file content format - """ - with codecs.open(json_file, encoding='utf-8') as data_file: - try: - json_content = json.load(data_file) - except json.decoder.JSONDecodeError: - err_msg = "JSONDecodeError: JSON file format error: {}".format(json_file) - logging.error(err_msg) - raise exception.FileFormatError(err_msg) - - testcase.check_format(json_file, json_content) - return json_content - -def load_tests(testcase_file_path): - file_suffix = os.path.splitext(testcase_file_path)[1] - if file_suffix == '.json': - return load_json_file(testcase_file_path) - elif file_suffix in ['.yaml', '.yml']: - return load_yaml_file(testcase_file_path) - else: - # '' or other suffix - return [] - def load_folder_files(folder_path, recursive=True): """ load folder path, return all files in list format. @param diff --git a/tests/test_context.py b/tests/test_context.py index ce07f7afb..484b5f2d2 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -2,7 +2,7 @@ import time import unittest -from ate import runner, utils +from ate import runner, testcase, utils from ate.context import Context from ate.exception import ParamsError @@ -12,7 +12,7 @@ class VariableBindsUnittest(unittest.TestCase): def setUp(self): self.context = Context() testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_binds.yml') - self.testcases = utils.load_tests(testcase_file_path) + self.testcases = testcase.load_tests(testcase_file_path) def test_context_init_functions(self): self.assertIn("get_timestamp", self.context.testset_functions_config) diff --git a/tests/test_runner.py b/tests/test_runner.py index 006d72561..0ef7458fc 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,7 +1,6 @@ import os -import requests -from ate import exception, runner, testcase, utils +from ate import exception, runner, testcase from tests.base import ApiServerUnittest @@ -26,18 +25,18 @@ def reset_all(self): def test_run_single_testcase(self): for testcase_file_path in self.testcase_file_path_list: - testcases = utils.load_tests(testcase_file_path) - testcase = testcases[0]["test"] - self.assertTrue(self.test_runner._run_test(testcase)) + testcases = testcase.load_tests(testcase_file_path) + test = testcases[0]["test"] + self.assertTrue(self.test_runner._run_test(test)) - testcase = testcases[1]["test"] - self.assertTrue(self.test_runner._run_test(testcase)) + test = testcases[1]["test"] + self.assertTrue(self.test_runner._run_test(test)) - testcase = testcases[2]["test"] - self.assertTrue(self.test_runner._run_test(testcase)) + test = testcases[2]["test"] + self.assertTrue(self.test_runner._run_test(test)) def test_run_single_testcase_fail(self): - testcase = { + test = { "name": "get token", "request": { "url": "http://127.0.0.1:5000/api/get-token", @@ -63,7 +62,7 @@ def test_run_single_testcase_fail(self): } with self.assertRaises(exception.ValidationError): - self.test_runner._run_test(testcase) + self.test_runner._run_test(test) def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 5b7ffdf59..a6f3060e7 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -3,11 +3,86 @@ import unittest from ate import testcase -from ate.exception import ParamsError, ApiNotFound +from ate.exception import ApiNotFound, FileFormatError, ParamsError class TestcaseParserUnittest(unittest.TestCase): + def test_load_testcases_bad_filepath(self): + testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo') + self.assertEqual(testcase.load_tests(testcase_file_path), []) + + def test_load_json_testcases(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_hardcode.json') + testcases = testcase.load_tests(testcase_file_path) + self.assertEqual(len(testcases), 3) + test = testcases[0]["test"] + self.assertIn('name', test) + self.assertIn('request', test) + self.assertIn('url', test['request']) + self.assertIn('method', test['request']) + + def test_load_yaml_testcases(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/demo_testset_hardcode.yml') + testcases = testcase.load_tests(testcase_file_path) + self.assertEqual(len(testcases), 3) + test = testcases[0]["test"] + self.assertIn('name', test) + self.assertIn('request', test) + self.assertIn('url', test['request']) + self.assertIn('method', test['request']) + + def test_load_yaml_file_file_format_error(self): + yaml_tmp_file = "tests/data/tmp.yml" + # create empty yaml file + with open(yaml_tmp_file, 'w') as f: + f.write("") + + with self.assertRaises(FileFormatError): + testcase.load_yaml_file(yaml_tmp_file) + + os.remove(yaml_tmp_file) + + # create invalid format yaml file + with open(yaml_tmp_file, 'w') as f: + f.write("abc") + + with self.assertRaises(FileFormatError): + testcase.load_yaml_file(yaml_tmp_file) + + os.remove(yaml_tmp_file) + + def test_load_json_file_file_format_error(self): + json_tmp_file = "tests/data/tmp.json" + # create empty file + with open(json_tmp_file, 'w') as f: + f.write("") + + with self.assertRaises(FileFormatError): + testcase.load_json_file(json_tmp_file) + + os.remove(json_tmp_file) + + # create empty json file + with open(json_tmp_file, 'w') as f: + f.write("{}") + + with self.assertRaises(FileFormatError): + testcase.load_json_file(json_tmp_file) + + os.remove(json_tmp_file) + + # create invalid format json file + with open(json_tmp_file, 'w') as f: + f.write("abc") + + with self.assertRaises(FileFormatError): + testcase.load_json_file(json_tmp_file) + + os.remove(json_tmp_file) + def test_extract_variables(self): self.assertEqual( testcase.extract_variables("$var"), diff --git a/tests/test_utils.py b/tests/test_utils.py index fd9565479..d56f05731 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,81 +8,6 @@ class TestUtils(ApiServerUnittest): - def test_load_testcases_bad_filepath(self): - testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo') - self.assertEqual(utils.load_tests(testcase_file_path), []) - - def test_load_json_testcases(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_testset_hardcode.json') - testcases = utils.load_tests(testcase_file_path) - self.assertEqual(len(testcases), 3) - testcase = testcases[0]["test"] - self.assertIn('name', testcase) - self.assertIn('request', testcase) - self.assertIn('url', testcase['request']) - self.assertIn('method', testcase['request']) - - def test_load_yaml_testcases(self): - testcase_file_path = os.path.join( - os.getcwd(), 'tests/data/demo_testset_hardcode.yml') - testcases = utils.load_tests(testcase_file_path) - self.assertEqual(len(testcases), 3) - testcase = testcases[0]["test"] - self.assertIn('name', testcase) - self.assertIn('request', testcase) - self.assertIn('url', testcase['request']) - self.assertIn('method', testcase['request']) - - def test_load_yaml_file_file_format_error(self): - yaml_tmp_file = "tests/data/tmp.yml" - # create empty yaml file - with open(yaml_tmp_file, 'w') as f: - f.write("") - - with self.assertRaises(exception.FileFormatError): - utils.load_yaml_file(yaml_tmp_file) - - os.remove(yaml_tmp_file) - - # create invalid format yaml file - with open(yaml_tmp_file, 'w') as f: - f.write("abc") - - with self.assertRaises(exception.FileFormatError): - utils.load_yaml_file(yaml_tmp_file) - - os.remove(yaml_tmp_file) - - def test_load_json_file_file_format_error(self): - json_tmp_file = "tests/data/tmp.json" - # create empty file - with open(json_tmp_file, 'w') as f: - f.write("") - - with self.assertRaises(exception.FileFormatError): - utils.load_json_file(json_tmp_file) - - os.remove(json_tmp_file) - - # create empty json file - with open(json_tmp_file, 'w') as f: - f.write("{}") - - with self.assertRaises(exception.FileFormatError): - utils.load_json_file(json_tmp_file) - - os.remove(json_tmp_file) - - # create invalid format json file - with open(json_tmp_file, 'w') as f: - f.write("abc") - - with self.assertRaises(exception.FileFormatError): - utils.load_json_file(json_tmp_file) - - os.remove(json_tmp_file) - def test_load_folder_files(self): folder = os.path.join(os.getcwd(), 'tests') file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') From 6165f912656bea77ba94d108b6607f0e19f6758f Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 13:10:43 +0800 Subject: [PATCH 303/354] fix typo error --- ate/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/response.py b/ate/response.py index b7013a75c..ebd8bf7ef 100644 --- a/ate/response.py +++ b/ate/response.py @@ -90,7 +90,7 @@ def _extract_field_with_delimiter(self, field): except AttributeError: err_msg = "Failed to extract value from response!\n" err_msg += "response: {}\n".format(self.parsed_dict()) - err_msg += "extract field field: {}\n".format(field) + err_msg += "extract field: {}\n".format(field) logging.error(err_msg) raise exception.ParamsError(err_msg) From 4067328bf380622c7ba5a139ffae71fc7b7c9d0d Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 13:32:51 +0800 Subject: [PATCH 304/354] rename function name, resolve conflict with unittest --- ate/testcase.py | 12 ++++++------ tests/test_context.py | 2 +- tests/test_runner.py | 2 +- tests/test_testcase.py | 16 ++++++++-------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/ate/testcase.py b/ate/testcase.py index 0f0dfed40..9f714faa7 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -19,7 +19,7 @@ testcases_cache_mapping = {} -def load_yaml_file(yaml_file): +def _load_yaml_file(yaml_file): """ load yaml file and check file content format """ with codecs.open(yaml_file, 'r+', encoding='utf-8') as stream: @@ -27,7 +27,7 @@ def load_yaml_file(yaml_file): check_format(yaml_file, yaml_content) return yaml_content -def load_json_file(json_file): +def _load_json_file(json_file): """ load json file and check file content format """ with codecs.open(json_file, encoding='utf-8') as data_file: @@ -41,12 +41,12 @@ def load_json_file(json_file): check_format(json_file, json_content) return json_content -def load_tests(testcase_file_path): +def _load_file(testcase_file_path): file_suffix = os.path.splitext(testcase_file_path)[1] if file_suffix == '.json': - return load_json_file(testcase_file_path) + return _load_json_file(testcase_file_path) elif file_suffix in ['.yaml', '.yml']: - return load_yaml_file(testcase_file_path) + return _load_yaml_file(testcase_file_path) else: # '' or other suffix return [] @@ -226,7 +226,7 @@ def load_test_file(file_path): "api": {}, "testcases": [] } - tests_list = load_tests(file_path) + tests_list = _load_file(file_path) for item in tests_list: for key in item: diff --git a/tests/test_context.py b/tests/test_context.py index 484b5f2d2..d56f6bdf2 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -12,7 +12,7 @@ class VariableBindsUnittest(unittest.TestCase): def setUp(self): self.context = Context() testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo_binds.yml') - self.testcases = testcase.load_tests(testcase_file_path) + self.testcases = testcase._load_file(testcase_file_path) def test_context_init_functions(self): self.assertIn("get_timestamp", self.context.testset_functions_config) diff --git a/tests/test_runner.py b/tests/test_runner.py index 0ef7458fc..f2d8e684b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -25,7 +25,7 @@ def reset_all(self): def test_run_single_testcase(self): for testcase_file_path in self.testcase_file_path_list: - testcases = testcase.load_tests(testcase_file_path) + testcases = testcase._load_file(testcase_file_path) test = testcases[0]["test"] self.assertTrue(self.test_runner._run_test(test)) diff --git a/tests/test_testcase.py b/tests/test_testcase.py index a6f3060e7..10d752d66 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -10,12 +10,12 @@ class TestcaseParserUnittest(unittest.TestCase): def test_load_testcases_bad_filepath(self): testcase_file_path = os.path.join(os.getcwd(), 'tests/data/demo') - self.assertEqual(testcase.load_tests(testcase_file_path), []) + self.assertEqual(testcase._load_file(testcase_file_path), []) def test_load_json_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_hardcode.json') - testcases = testcase.load_tests(testcase_file_path) + testcases = testcase._load_file(testcase_file_path) self.assertEqual(len(testcases), 3) test = testcases[0]["test"] self.assertIn('name', test) @@ -26,7 +26,7 @@ def test_load_json_testcases(self): def test_load_yaml_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testset_hardcode.yml') - testcases = testcase.load_tests(testcase_file_path) + testcases = testcase._load_file(testcase_file_path) self.assertEqual(len(testcases), 3) test = testcases[0]["test"] self.assertIn('name', test) @@ -41,7 +41,7 @@ def test_load_yaml_file_file_format_error(self): f.write("") with self.assertRaises(FileFormatError): - testcase.load_yaml_file(yaml_tmp_file) + testcase._load_yaml_file(yaml_tmp_file) os.remove(yaml_tmp_file) @@ -50,7 +50,7 @@ def test_load_yaml_file_file_format_error(self): f.write("abc") with self.assertRaises(FileFormatError): - testcase.load_yaml_file(yaml_tmp_file) + testcase._load_yaml_file(yaml_tmp_file) os.remove(yaml_tmp_file) @@ -61,7 +61,7 @@ def test_load_json_file_file_format_error(self): f.write("") with self.assertRaises(FileFormatError): - testcase.load_json_file(json_tmp_file) + testcase._load_json_file(json_tmp_file) os.remove(json_tmp_file) @@ -70,7 +70,7 @@ def test_load_json_file_file_format_error(self): f.write("{}") with self.assertRaises(FileFormatError): - testcase.load_json_file(json_tmp_file) + testcase._load_json_file(json_tmp_file) os.remove(json_tmp_file) @@ -79,7 +79,7 @@ def test_load_json_file_file_format_error(self): f.write("abc") with self.assertRaises(FileFormatError): - testcase.load_json_file(json_tmp_file) + testcase._load_json_file(json_tmp_file) os.remove(json_tmp_file) From f19fe48286a3dc6a01051e15e9ea15d76f4755f5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 15:08:59 +0800 Subject: [PATCH 305/354] fix: run testcases by folder path --- ate/cli.py | 5 +---- ate/exception.py | 14 ++++++++++---- ate/task.py | 2 +- ate/testcase.py | 13 +++++++++---- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index 314943460..74930d8bc 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -65,10 +65,7 @@ def main_ate(): try: task_suite = TaskSuite(testset_path) - except exception.FileFormatError: - success = False - continue - except exception.FileNotFoundError: + except exception.TestcaseNotFound: success = False continue diff --git a/ate/exception.py b/ate/exception.py index 70461d59c..f9c139510 100644 --- a/ate/exception.py +++ b/ate/exception.py @@ -29,14 +29,20 @@ class ParseResponseError(MyBaseError): class ValidationError(MyBaseError): pass -class FunctionNotFound(NameError): +class NotFoundError(MyBaseError): pass -class VariableNotFound(NameError): +class FunctionNotFound(NotFoundError): pass -class ApiNotFound(NameError): +class VariableNotFound(NotFoundError): pass -class SuiteNotFound(NameError): +class ApiNotFound(NotFoundError): + pass + +class SuiteNotFound(NotFoundError): + pass + +class TestcaseNotFound(NotFoundError): pass diff --git a/ate/task.py b/ate/task.py index 438059a1b..ec5241ea8 100644 --- a/ate/task.py +++ b/ate/task.py @@ -52,7 +52,7 @@ def __init__(self, testcase_path): self.suite_list = [] testsets = testcase.load_testcases_by_path(testcase_path) if not testsets: - raise exception.FileNotFoundError + raise exception.TestcaseNotFound for testset in testsets: suite = ApiTestSuite(testset) diff --git a/ate/testcase.py b/ate/testcase.py index 9f714faa7..437bcdd50 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -49,6 +49,8 @@ def _load_file(testcase_file_path): return _load_yaml_file(testcase_file_path) else: # '' or other suffix + err_msg = "file is not in YAML/JSON format: {}".format(testcase_file_path) + logging.warning(err_msg) return [] def extract_variables(content): @@ -194,10 +196,13 @@ def load_testcases_by_path(path): testcases_list = load_testcases_by_path(files_list) elif os.path.isfile(path): - testset = load_test_file(path) - if testset["testcases"] or testset["api"]: - testcases_list = [testset] - else: + try: + testset = load_test_file(path) + if testset["testcases"] or testset["api"]: + testcases_list = [testset] + else: + testcases_list = [] + except exception.FileFormatError: testcases_list = [] else: From 4f589ff86951f5ec59165025304d2869df04e29c Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 15:31:49 +0800 Subject: [PATCH 306/354] rename variable name, avoid confliction with module name --- ate/runner.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/ate/runner.py b/ate/runner.py index 7c80de964..cd6155cc1 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -66,9 +66,9 @@ def init_config(self, config_dict, level): return parsed_request - def _run_test(self, testcase): + def _run_test(self, testcase_dict): """ run single testcase. - @param (dict) testcase + @param (dict) testcase_dict { "name": "testcase description", "times": 3, @@ -92,7 +92,7 @@ def _run_test(self, testcase): } @return True or raise exception during test """ - parsed_request = self.init_config(testcase, level="testcase") + parsed_request = self.init_config(testcase_dict, level="testcase") try: url = parsed_request.pop('url') @@ -101,14 +101,14 @@ def _run_test(self, testcase): except KeyError: raise exception.ParamsError("URL or METHOD missed!") - run_times = int(testcase.get("times", 1)) - extractors = testcase.get("extract") \ - or testcase.get("extractors") \ - or testcase.get("extract_binds", []) - validators = testcase.get("validate") \ - or testcase.get("validators", []) - setup_actions = testcase.get("setup", []) - teardown_actions = testcase.get("teardown", []) + run_times = int(testcase_dict.get("times", 1)) + extractors = testcase_dict.get("extract") \ + or testcase_dict.get("extractors") \ + or testcase_dict.get("extract_binds", []) + validators = testcase_dict.get("validate") \ + or testcase_dict.get("validators", []) + setup_actions = testcase_dict.get("setup", []) + teardown_actions = testcase_dict.get("teardown", []) def setup_teardown(actions): for action in actions: @@ -182,9 +182,9 @@ def _run_testset(self, testset, variables_mapping=None): self.init_config(config_dict, level="testset") testcases = testset.get("testcases", []) - for testcase in testcases: + for testcase_dict in testcases: try: - assert self._run_test(testcase) + assert self._run_test(testcase_dict) except AssertionError: success = False From 9638349ecf3459bbdac6595dd8701329af2f4d44 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 16:03:07 +0800 Subject: [PATCH 307/354] remove unused import --- ate/runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ate/runner.py b/ate/runner.py index cd6155cc1..31985f5f9 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -1,5 +1,4 @@ import logging -from collections import OrderedDict from ate import exception, response, testcase, utils from ate.client import HttpSession From cc6fa938a0d98a3a3e20deaaa6bcc203cf391256 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 16:54:27 +0800 Subject: [PATCH 308/354] rename variable name, avoid confliction with module name --- ate/task.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ate/task.py b/ate/task.py index ec5241ea8..e35c361a0 100644 --- a/ate/task.py +++ b/ate/task.py @@ -7,15 +7,15 @@ class ApiTestCase(unittest.TestCase): """ create a testcase. """ - def __init__(self, test_runner, testcase): + def __init__(self, test_runner, testcase_dict): super(ApiTestCase, self).__init__() self.test_runner = test_runner - self.testcase = testcase + self.testcase_dict = testcase_dict def runTest(self): """ run testcase and check result. """ - self.assertTrue(self.test_runner._run_test(self.testcase)) + self.assertTrue(self.test_runner._run_test(self.testcase_dict)) class ApiTestSuite(unittest.TestSuite): """ create test suite with a testset, it may include one or several testcases. @@ -30,13 +30,13 @@ def __init__(self, testset): self._add_tests_to_suite(testcases) def _add_tests_to_suite(self, testcases): - for testcase in testcases: + for testcase_dict in testcases: if utils.PYTHON_VERSION == 3: - ApiTestCase.runTest.__doc__ = testcase['name'] + ApiTestCase.runTest.__doc__ = testcase_dict['name'] else: - ApiTestCase.runTest.__func__.__doc__ = testcase['name'] + ApiTestCase.runTest.__func__.__doc__ = testcase_dict['name'] - test = ApiTestCase(self.test_runner, testcase) + test = ApiTestCase(self.test_runner, testcase_dict) self.addTest(test) def print_output(self): From e08e3d5299b407e593cda1c2bc509af8927d3930 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 18:42:26 +0800 Subject: [PATCH 309/354] add remove_prefix utils --- ate/utils.py | 7 +++++++ tests/test_utils.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/ate/utils.py b/ate/utils.py index e904d6f2a..1f9143969 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -39,6 +39,13 @@ def get_sign(*args): sign = hmac.new(sign_key, content, hashlib.sha1).hexdigest() return sign +def remove_prefix(text, prefix): + """ remove prefix from text + """ + if text.startswith(prefix): + return text[len(prefix):] + return text + def load_folder_files(folder_path, recursive=True): """ load folder path, return all files in list format. @param diff --git a/tests/test_utils.py b/tests/test_utils.py index d56f05731..8b03df1da 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,6 +8,14 @@ class TestUtils(ApiServerUnittest): + def test_remove_prefix(self): + full_url = "http://debugtalk.com/post/123" + prefix = "http://debugtalk.com" + self.assertEqual( + utils.remove_prefix(full_url, prefix), + "/post/123" + ) + def test_load_folder_files(self): folder = os.path.join(os.getcwd(), 'tests') file1 = os.path.join(os.getcwd(), 'tests', 'test_utils.py') From ed696b9de50afc152c4cddcfe8a8fc34adce5ad5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 19:30:35 +0800 Subject: [PATCH 310/354] locusts: report exception when error occured --- ate/__init__.py | 2 +- ate/locustfile_template | 10 ++++------ ate/runner.py | 21 ++++++++++++++++----- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/ate/__init__.py b/ate/__init__.py index 7adfc4e4b..b7bef124f 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.7' \ No newline at end of file +__version__ = '0.7.8' \ No newline at end of file diff --git a/ate/locustfile_template b/ate/locustfile_template index e1ebbdb23..6d4bf2c02 100644 --- a/ate/locustfile_template +++ b/ate/locustfile_template @@ -1,18 +1,16 @@ #coding: utf-8 import zmq from locust import HttpLocust, TaskSet, task -from ate import runner, exception +from locust.events import request_failure +from ate import runner class WebPageTasks(TaskSet): def on_start(self): - self.test_runner = runner.Runner(self.client) + self.test_runner = runner.Runner(self.client, request_failure) @task def test_specified_scenario(self): - try: - self.test_runner.run(self.locust.file_path) - except exception.ValidationError: - pass + self.test_runner.run(self.locust.file_path) class WebPageUser(HttpLocust): host = "$HOST" diff --git a/ate/runner.py b/ate/runner.py index 31985f5f9..bbfa7f391 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -7,10 +7,11 @@ class Runner(object): - def __init__(self, http_client_session=None): + def __init__(self, http_client_session=None, request_failure_hook=None): self.http_client_session = http_client_session self.context = Context() testcase.load_test_dependencies() + self.request_failure_hook = request_failure_hook def init_config(self, config_dict, level): """ create/update context variables binds @@ -131,7 +132,9 @@ def setup_teardown(actions): resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) except (exception.ParamsError, exception.ResponseError, exception.ValidationError): logging.error("Exception occured.") + logging.error("HTTP request url: \n{}".format(url)) logging.error("HTTP request kwargs: \n{}".format(parsed_request)) + logging.error("HTTP response status_code: \n{}".format(resp.status_code)) logging.error("HTTP response content: \n{}".format(resp.text)) raise @@ -155,9 +158,9 @@ def _run_testset(self, testset, variables_mapping=None): "testcases": [ { "name": "testcase description", - "variables": [], # optional, override + "variables": [], # optional, override "request": {}, - "extract": {}, # optional + "extract": {}, # optional "validate": {} # optional }, testcase12 @@ -183,9 +186,17 @@ def _run_testset(self, testset, variables_mapping=None): testcases = testset.get("testcases", []) for testcase_dict in testcases: try: - assert self._run_test(testcase_dict) - except AssertionError: + self._run_test(testcase_dict) + except exception.MyBaseError as ex: success = False + if self.request_failure_hook: + self.request_failure_hook.fire( + request_type=testcase_dict.get("request", {}).get("method"), + name=testcase_dict.get("request", {}).get("url"), + response_time=0, + exception=ex + ) + break output_variables_list = config_dict.get("output", []) From fdde85c1bd96e157d181f0082acd77dd09fbd740 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 2 Nov 2017 20:40:56 +0800 Subject: [PATCH 311/354] make logging string unicode --- ate/client.py | 2 +- ate/response.py | 18 +++++++++--------- ate/runner.py | 11 ++++++----- ate/testcase.py | 10 +++++----- ate/utils.py | 2 +- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/ate/client.py b/ate/client.py index 26b7d9fac..a94a3ce76 100644 --- a/ate/client.py +++ b/ate/client.py @@ -136,7 +136,7 @@ def request(self, method, url, name=None, **kwargs): try: response.raise_for_status() except RequestException as e: - logging.error(" Failed to {method} {url}! exception msg: {exception}".format( + logging.error(u" Failed to {method} {url}! exception msg: {exception}".format( method=method, url=url, exception=str(e))) else: logging.info( diff --git a/ate/response.py b/ate/response.py index ebd8bf7ef..cb38038bd 100644 --- a/ate/response.py +++ b/ate/response.py @@ -42,9 +42,9 @@ def _extract_field_with_regex(self, field): """ matched = re.search(field, self.resp_text) if not matched: - err_msg = "Extractor error: failed to extract data with regex!\n" - err_msg += "response body: {}\n".format(self.resp_text) - err_msg += "regex: {}\n".format(field) + err_msg = u"Extractor error: failed to extract data with regex!\n" + err_msg += u"response body: {}\n".format(self.resp_text) + err_msg += u"regex: {}\n".format(field) logging.error(err_msg) raise exception.ParamsError(err_msg) @@ -75,9 +75,9 @@ def _extract_field_with_delimiter(self, field): if sub_query: if not isinstance(top_query_content, (dict, CaseInsensitiveDict, list)): - err_msg = "Extractor error: failed to extract data with regex!\n" - err_msg += "response: {}\n".format(self.parsed_dict()) - err_msg += "regex: {}\n".format(field) + err_msg = u"Extractor error: failed to extract data with regex!\n" + err_msg += u"response: {}\n".format(self.parsed_dict()) + err_msg += u"regex: {}\n".format(field) logging.error(err_msg) raise exception.ParamsError(err_msg) @@ -88,9 +88,9 @@ def _extract_field_with_delimiter(self, field): return top_query_content except AttributeError: - err_msg = "Failed to extract value from response!\n" - err_msg += "response: {}\n".format(self.parsed_dict()) - err_msg += "extract field: {}\n".format(field) + err_msg = u"Failed to extract value from response!\n" + err_msg += u"response: {}\n".format(self.parsed_dict()) + err_msg += u"extract field: {}\n".format(field) logging.error(err_msg) raise exception.ParamsError(err_msg) diff --git a/ate/runner.py b/ate/runner.py index bbfa7f391..5a5b64346 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -131,11 +131,12 @@ def setup_teardown(actions): try: resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) except (exception.ParamsError, exception.ResponseError, exception.ValidationError): - logging.error("Exception occured.") - logging.error("HTTP request url: \n{}".format(url)) - logging.error("HTTP request kwargs: \n{}".format(parsed_request)) - logging.error("HTTP response status_code: \n{}".format(resp.status_code)) - logging.error("HTTP response content: \n{}".format(resp.text)) + err_msg = u"Exception occured.\n" + err_msg += u"HTTP request url: {}\n".format(url) + err_msg += u"HTTP request kwargs: \n{}".format(parsed_request) + err_msg += u"HTTP response status_code: {}\n".format(resp.status_code) + err_msg += u"HTTP response content: \n{}".format(resp.text) + logging.error(err_msg) raise setup_teardown(teardown_actions) diff --git a/ate/testcase.py b/ate/testcase.py index 437bcdd50..da2f04afd 100644 --- a/ate/testcase.py +++ b/ate/testcase.py @@ -34,7 +34,7 @@ def _load_json_file(json_file): try: json_content = json.load(data_file) except exception.JSONDecodeError: - err_msg = "JSONDecodeError: JSON file format error: {}".format(json_file) + err_msg = u"JSONDecodeError: JSON file format error: {}".format(json_file) logging.error(err_msg) raise exception.FileFormatError(err_msg) @@ -49,7 +49,7 @@ def _load_file(testcase_file_path): return _load_yaml_file(testcase_file_path) else: # '' or other suffix - err_msg = "file is not in YAML/JSON format: {}".format(testcase_file_path) + err_msg = u"file is not in YAML/JSON format: {}".format(testcase_file_path) logging.warning(err_msg) return [] @@ -206,7 +206,7 @@ def load_testcases_by_path(path): testcases_list = [] else: - logging.error("file not found: {}".format(path)) + logging.error(u"file not found: {}".format(path)) testcases_list = [] testcases_cache_mapping[path] = testcases_list @@ -373,13 +373,13 @@ def check_format(file_path, content): """ if not content: # testcase file content is empty - err_msg = "Testcase file content is empty: {}".format(file_path) + err_msg = u"Testcase file content is empty: {}".format(file_path) logging.error(err_msg) raise exception.FileFormatError(err_msg) elif not isinstance(content, (list, dict)): # testcase file content does not match testcase format - err_msg = "Testcase file content format invalid: {}".format(file_path) + err_msg = u"Testcase file content format invalid: {}".format(file_path) logging.error(err_msg) raise exception.FileFormatError(err_msg) diff --git a/ate/utils.py b/ate/utils.py index 1f9143969..10176a7a4 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -398,7 +398,7 @@ def create_scaffold(project_path): if os.path.isdir(project_path): folder_name = os.path.basename(project_path) - logging.warning(" Folder {} exists, please specify a new folder name.".format(folder_name)) + logging.warning(u" Folder {} exists, please specify a new folder name.".format(folder_name)) return def create_path(path, ptype): From 00d427c2c3c317fc99539b42121a3e458ed277b8 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 3 Nov 2017 12:55:23 +0800 Subject: [PATCH 312/354] update lower_dict_keys, avoid mistakes in OrderDict --- ate/utils.py | 31 ++++++++++++++----------------- tests/test_utils.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/ate/utils.py b/ate/utils.py index 10176a7a4..e05541681 100644 --- a/ate/utils.py +++ b/ate/utils.py @@ -287,31 +287,28 @@ def search_conf_item(start_path, item_type, item_name): return search_conf_item(dir_path, item_type, item_name) -def lower_dict_key(origin_dict, depth=1): - """ convert dict key to lower case, with depth control supported. +def lower_dict_keys(origin_dict): + """ convert keys in dict to lower case + e.g. + Name => name, Request => request + URL => url, METHOD => method, Headers => headers, Data => data """ - new_dict = {} + if not origin_dict or not isinstance(origin_dict, dict): + return origin_dict - for key, value in origin_dict.items(): - if depth >= 2: - new_dict[key] = value - continue - - if isinstance(value, dict): - value = lower_dict_key(value, depth+1) - - new_dict[key.lower()] = value - - return new_dict + return { + key.lower(): value + for key, value in origin_dict.items() + } def lower_config_dict_key(config_dict): """ convert key in config dict to lower case, convertion will occur in two places: 1, all keys in config dict; 2, all keys in config["request"] """ - config_dict = lower_dict_key(config_dict) - if "request" in config_dict and isinstance(config_dict["request"], dict): - config_dict["request"] = lower_dict_key(config_dict["request"]) + config_dict = lower_dict_keys(config_dict) + if "request" in config_dict: + config_dict["request"] = lower_dict_keys(config_dict["request"]) return config_dict diff --git a/tests/test_utils.py b/tests/test_utils.py index 8b03df1da..bc546e5d8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -245,6 +245,29 @@ def test_handle_config_key_case(self): new_dict = utils.lower_config_dict_key(origin_dict) self.assertIn("$default_request", new_dict["request"]) + def test_lower_dict_keys(self): + request_dict = { + "url": "http://127.0.0.1:5000", + "METHOD": "POST", + "Headers": { + "Accept": "application/json", + "User-Agent": "ios/9.3" + } + } + new_request_dict = utils.lower_dict_keys(request_dict) + self.assertIn("method", new_request_dict) + self.assertIn("headers", new_request_dict) + self.assertIn("Accept", new_request_dict["headers"]) + self.assertIn("User-Agent", new_request_dict["headers"]) + + request_dict = "$default_request" + new_request_dict = utils.lower_dict_keys(request_dict) + self.assertEqual("$default_request", request_dict) + + request_dict = None + new_request_dict = utils.lower_dict_keys(request_dict) + self.assertEqual(None, request_dict) + def test_convert_to_order_dict(self): map_list = [ {"a": 1}, From 588cb5133844f945565da2c181a4c84c5dddccf5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 3 Nov 2017 14:20:54 +0800 Subject: [PATCH 313/354] update err_msg --- ate/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ate/runner.py b/ate/runner.py index 5a5b64346..856654b53 100644 --- a/ate/runner.py +++ b/ate/runner.py @@ -133,7 +133,7 @@ def setup_teardown(actions): except (exception.ParamsError, exception.ResponseError, exception.ValidationError): err_msg = u"Exception occured.\n" err_msg += u"HTTP request url: {}\n".format(url) - err_msg += u"HTTP request kwargs: \n{}".format(parsed_request) + err_msg += u"HTTP request kwargs: {}\n".format(parsed_request) err_msg += u"HTTP response status_code: {}\n".format(resp.status_code) err_msg += u"HTTP response content: \n{}".format(resp.text) logging.error(err_msg) From 0f01ae93c79cb14bad751d88451ceb4e5867efca Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 5 Nov 2017 11:41:16 +0800 Subject: [PATCH 314/354] rename project name to HttpRunner --- README.md | 22 ++++++++++++---------- ate/__init__.py | 2 +- ate/cli.py | 2 +- ate/client.py | 4 ++-- docs/FAQ.md | 8 ++++---- docs/background-CN.md | 2 +- docs/quickstart.md | 12 ++++++------ setup.py | 8 ++++---- 8 files changed, 31 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 1e82bc5fa..12f67a84a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -# ApiTestEngine +# HttpRunner -[![Build Status](https://travis-ci.org/debugtalk/ApiTestEngine.svg?branch=master)](https://travis-ci.org/debugtalk/ApiTestEngine) -[![Coverage Status](https://coveralls.io/repos/github/debugtalk/ApiTestEngine/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/ApiTestEngine?branch=master) +[![Build Status](https://travis-ci.org/debugtalk/HttpRunner.svg?branch=master)](https://travis-ci.org/debugtalk/HttpRunner) +[![Coverage Status](https://coveralls.io/repos/github/debugtalk/HttpRunner/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/HttpRunner?branch=master) + +New name for `ApiTestEngine`. ## Design Philosophy @@ -23,13 +25,13 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], ## Installation/Upgrade ```bash -$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine --process-dependency-links +$ pip install git+https://github.com/debugtalk/HttpRunner.git#egg=HttpRunner --process-dependency-links ``` To upgrade all specified packages to the newest available version, you should add the `-U` option. ```bash -$ pip install -U git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine --process-dependency-links +$ pip install -U git+https://github.com/debugtalk/HttpRunner.git#egg=HttpRunner --process-dependency-links ``` If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). @@ -38,7 +40,7 @@ To ensure the installation or upgrade is successful, you can execute command `at ```text $ ate -V -ApiTestEngine version: 0.7.7 +HttpRunner version: 0.8.0 ``` Execute the command `ate -h` to view command help. @@ -49,7 +51,7 @@ usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] [--failfast] [--startproject STARTPROJECT] [testset_paths [testset_paths ...]] -ApiTestEngine. +HttpRunner. positional arguments: testset_paths testset file path @@ -125,7 +127,7 @@ For detailed regulations of writing testcases, you can read the [`QuickStart`][q ## Run testcases -`ApiTestEngine` can run testcases in diverse ways. +`HttpRunner` can run testcases in diverse ways. You can run single testset by specifying testset file path. @@ -204,11 +206,11 @@ Enjoy! Python `2.7`, `3.4`, `3.5` and `3.6`. -`ApiTestEngine` has been tested on `macOS`, `Linux` and `Windows` platforms. +`HttpRunner` has been tested on `macOS`, `Linux` and `Windows` platforms. ## Development -To develop or debug `ApiTestEngine`, you can install relevant requirements and use `main-ate.py` or `main-locust.py` as entrances. +To develop or debug `HttpRunner`, you can install relevant requirements and use `main-ate.py` or `main-locust.py` as entrances. ```bash $ pip install -r requirements_dev.txt diff --git a/ate/__init__.py b/ate/__init__.py index b7bef124f..707f5d34b 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.8' \ No newline at end of file +__version__ = '0.7.9' \ No newline at end of file diff --git a/ate/cli.py b/ate/cli.py index 74930d8bc..decb80f10 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -38,7 +38,7 @@ def main_ate(): args = parser.parse_args() if args.version: - print("ApiTestEngine version: {}".format(__version__)) + print("HttpRunner version: {}".format(__version__)) exit(0) log_level = getattr(logging, args.log_level.upper()) diff --git a/ate/client.py b/ate/client.py index a94a3ce76..8c1cdca16 100644 --- a/ate/client.py +++ b/ate/client.py @@ -31,7 +31,7 @@ def raise_for_status(self): class HttpSession(requests.Session): """ Class for performing HTTP requests and holding (session-) cookies between requests (in order - to be able to log in and out of websites). Each request is logged so that ApiTestEngine can + to be able to log in and out of websites). Each request is logged so that HttpRunner can display statistics. This is a slightly extended version of `python-request `_'s @@ -39,7 +39,7 @@ class HttpSession(requests.Session): the methods for making requests (get, post, delete, put, head, options, patch, request) can now take a *url* argument that's only the path part of the URL, in which case the host part of the URL will be prepended with the HttpSession.base_url which is normally inherited - from a ApiTestEngine class' host property. + from a HttpRunner class' host property. """ def __init__(self, base_url=None, *args, **kwargs): super(HttpSession, self).__init__(*args, **kwargs) diff --git a/docs/FAQ.md b/docs/FAQ.md index accdcb909..38583eb8a 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -3,8 +3,8 @@ If there is something goes wrong in installation like below. ```text -Downloading/unpacking PyUnitReport (from ApiTestEngine) - Could not find any downloads that satisfy the requirement PyUnitReport (from ApiTestEngine) +Downloading/unpacking PyUnitReport (from HttpRunner) + Could not find any downloads that satisfy the requirement PyUnitReport (from HttpRunner) ``` You could install `PyUnitReport` manully at first. @@ -13,8 +13,8 @@ You could install `PyUnitReport` manully at first. $ pip install git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport ``` -And then everything will be OK when you reinstall `ApiTestEngine`. +And then everything will be OK when you reinstall `HttpRunner`. ```bash -$ pip install git+https://github.com/debugtalk/ApiTestEngine.git#egg=ApiTestEngine +$ pip install git+https://github.com/debugtalk/HttpRunner.git#egg=HttpRunner ``` diff --git a/docs/background-CN.md b/docs/background-CN.md index f2659b703..87fa58740 100644 --- a/docs/background-CN.md +++ b/docs/background-CN.md @@ -41,4 +41,4 @@ 当然,每位工程师对`最佳工程实践`的理念或多或少都会存在一些差异,也希望大家能多多交流,在思维的碰撞中共同进步。 -[ApiTestEngine]: https://github.com/debugtalk/ApiTestEngine \ No newline at end of file +[ApiTestEngine]: https://github.com/debugtalk/HttpRunner \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md index c6b2783b9..b258341c6 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -2,7 +2,7 @@ ## Introduction to Sample Interface Service -Along with this project, I devised a sample interface service, and you can use it to familiarize how to play with `ApiTestEngine`. +Along with this project, I devised a sample interface service, and you can use it to familiarize how to play with `HttpRunner`. This sample service mainly has two parts: @@ -76,7 +76,7 @@ You may wonder why we use the `json` field other than `data`. That's because the Have you recalled some familiar scenes? -Yes! That's what we did in [`requests.request`](requests.request)! Since `ApiTestEngine` takes full reuse of [`Requests`][requests], it inherits all powerful features of [`Requests`][requests], and we can handle HTTP request as the way we do before. +Yes! That's what we did in [`requests.request`](requests.request)! Since `HttpRunner` takes full reuse of [`Requests`][requests], it inherits all powerful features of [`Requests`][requests], and we can handle HTTP request as the way we do before. ## Run test cases @@ -152,7 +152,7 @@ Let's look back to our test set `quickstart-demo-rev-1.yml`, and we can see the In actual scenarios, each user's `device_sn` is different, so we should parameterize the request parameters, which is also called `parameterization`. In the meanwhile, the `sign` field is calculated with other header fields, thus it may change significantly if any header field changes slightly. -However, the test cases are only `YAML` documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine `Python` scripts with `YAML/JSON` test cases in `ApiTestEngine`. +However, the test cases are only `YAML` documents, it is impossible to generate parameters dynamically in such text. Fortunately, we can combine `Python` scripts with `YAML/JSON` test cases in `HttpRunner`. To achieve this goal, we can utilize `debugtalk.py` plugin and `variables` mechanisms. @@ -228,7 +228,7 @@ In this revised test case, `variable reference` and `function invoke` mechanisms To make fields like `device_sn` can be used more than once, we bind values to variables in `variables` block. When we bind variables, we can not only bind exact value to a variable name, but also can call a function and bind the evaluated value to it. -When we want to reference a variable in the test case, we can do this with a escape character `$`. For example, `$user_agent` will not be taken as a normal string, and `ApiTestEngine` will consider it as a variable named `user_agent`, search and return its binding value. +When we want to reference a variable in the test case, we can do this with a escape character `$`. For example, `$user_agent` will not be taken as a normal string, and `HttpRunner` will consider it as a variable named `user_agent`, search and return its binding value. When we want to reference a function, we shall use another escape character `${}`. Any content in `${}` will be considered as function calling, so we should guarantee that we call functions in the right way. At the same time, variables can also be referenced as parameters of function. @@ -238,7 +238,7 @@ There is still one issue unsolved. The `device_sn` field is defined in the first API test case, thus it may be impossible to reference it in other test cases. Context separation is a well-designed mechanism, and we should obey this good practice. -To handle this case, overall `config` block is supported in `ApiTestEngine`. If we define variables or import functions in `config` block, these variables and functions will become global and can be referenced in the whole test set. +To handle this case, overall `config` block is supported in `HttpRunner`. If we define variables or import functions in `config` block, these variables and functions will become global and can be referenced in the whole test set. ```yaml # examples/quickstart-demo-rev-3.yml @@ -311,7 +311,7 @@ OK Generating HTML reports... Template is not specified, load default template instead. -Reports generated: /Users/Leo/MyProjects/ApiTestEngine/reports/quickstart-demo-rev-0/2017-08-01-16-51-51.html +Reports generated: /Users/Leo/MyProjects/HttpRunner/reports/quickstart-demo-rev-0/2017-08-01-16-51-51.html ``` Great! The test case runs successfully and generates a `HTML` test report. diff --git a/setup.py b/setup.py index 1339bae50..fcb497400 100644 --- a/setup.py +++ b/setup.py @@ -8,13 +8,13 @@ version = re.compile(r"__version__\s+=\s+'(.*)'", re.I).match(f.read()).group(1) setup( - name='ApiTestEngine', + name='HttpRunner', version=version, - description='API test engine.', - long_description="Best practice of API test, including automation test and performance test.", + description='HTTP test runner, not just about api test and load test.', + long_description="HTTP test runner, not just about api test and load test.", author='Leo Lee', author_email='mail@debugtalk.com', - url='https://github.com/debugtalk/ApiTestEngine', + url='https://github.com/debugtalk/HttpRunner', license='MIT', packages=find_packages(exclude=['test.*', 'test']), package_data={ From 477cbdf8df22a544e7eda759ca73b22a692924a3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 14:49:54 +0800 Subject: [PATCH 315/354] setup long description read from README --- setup.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index fcb497400..296f70510 100644 --- a/setup.py +++ b/setup.py @@ -1,17 +1,22 @@ #encoding: utf-8 +import io import os import re -from setuptools import setup, find_packages + +from setuptools import find_packages, setup # parse version from ate/__init__.py with open(os.path.join(os.path.dirname(__file__), 'ate', '__init__.py')) as f: version = re.compile(r"__version__\s+=\s+'(.*)'", re.I).match(f.read()).group(1) +with io.open("README.md", encoding='utf-8') as f: + long_description = f.read() + setup( name='HttpRunner', version=version, description='HTTP test runner, not just about api test and load test.', - long_description="HTTP test runner, not just about api test and load test.", + long_description=long_description, author='Leo Lee', author_email='mail@debugtalk.com', url='https://github.com/debugtalk/HttpRunner', From 97ac45a1d8961c0860bad9420366125b8e497f5e Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 15:02:46 +0800 Subject: [PATCH 316/354] add README --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..4bf448352 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md \ No newline at end of file From f74d6d4d92c981e67b74917061024305e50823c0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 15:27:04 +0800 Subject: [PATCH 317/354] update install method --- README.md | 6 +----- ate/__init__.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 12f67a84a..da7616a66 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,11 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], ## Installation/Upgrade ```bash -$ pip install git+https://github.com/debugtalk/HttpRunner.git#egg=HttpRunner --process-dependency-links +$ pip install HttpRunner ``` To upgrade all specified packages to the newest available version, you should add the `-U` option. -```bash -$ pip install -U git+https://github.com/debugtalk/HttpRunner.git#egg=HttpRunner --process-dependency-links -``` - If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). To ensure the installation or upgrade is successful, you can execute command `ate -V` to see if you can get the correct version number. diff --git a/ate/__init__.py b/ate/__init__.py index 707f5d34b..ccf9e6286 100644 --- a/ate/__init__.py +++ b/ate/__init__.py @@ -1 +1 @@ -__version__ = '0.7.9' \ No newline at end of file +__version__ = '0.8.0' \ No newline at end of file From 23e23c9697e36ac7b080381608373e4490acc023 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 19:23:20 +0800 Subject: [PATCH 318/354] remove PyUnitReport and locustio link --- setup.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/setup.py b/setup.py index 296f70510..199faaa64 100644 --- a/setup.py +++ b/setup.py @@ -34,15 +34,6 @@ "coverage", "PyUnitReport" ], - extras_require={ - 'locustio': [ - "locustio" - ] - }, - dependency_links=[ - "git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport-0", - "git+https://github.com/locustio/locust.git#egg=locust-0" - ], classifiers=[ "Development Status :: 3 - Alpha", 'Programming Language :: Python :: 2.7', From 8a16a7f123edcdf0ef211e287408a7a9891bf9c2 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 19:38:41 +0800 Subject: [PATCH 319/354] print PyUnitReport version --- ate/cli.py | 10 ++++++---- setup.py | 9 ++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/ate/cli.py b/ate/cli.py index decb80f10..0f1620ab6 100644 --- a/ate/cli.py +++ b/ate/cli.py @@ -4,12 +4,13 @@ import sys from collections import OrderedDict -from ate import __version__, exception +import PyUnitReport +from PyUnitReport import __version__ as pyu_version +from ate import __version__ as ate_version +from ate import exception from ate.task import TaskSuite from ate.utils import create_scaffold -import PyUnitReport - def main_ate(): """ API test: parse command line options and run commands. @@ -38,7 +39,8 @@ def main_ate(): args = parser.parse_args() if args.version: - print("HttpRunner version: {}".format(__version__)) + print("HttpRunner version: {}".format(ate_version)) + print("PyUnitReport version: {}".format(pyu_version)) exit(0) log_level = getattr(logging, args.log_level.upper()) diff --git a/setup.py b/setup.py index 199faaa64..264b6616c 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,15 @@ #encoding: utf-8 import io -import os -import re +from ate import __version__ from setuptools import find_packages, setup -# parse version from ate/__init__.py -with open(os.path.join(os.path.dirname(__file__), 'ate', '__init__.py')) as f: - version = re.compile(r"__version__\s+=\s+'(.*)'", re.I).match(f.read()).group(1) - with io.open("README.md", encoding='utf-8') as f: long_description = f.read() setup( name='HttpRunner', - version=version, + version=__version__, description='HTTP test runner, not just about api test and load test.', long_description=long_description, author='Leo Lee', From 56476442704cf16c66cc677ab3bbd5ef1b08539f Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 20:01:33 +0800 Subject: [PATCH 320/354] update install_requires --- README.md | 2 +- requirements.txt | 6 ++++++ requirements_dev.txt | 7 ------- setup.py | 11 +++-------- 4 files changed, 10 insertions(+), 16 deletions(-) create mode 100644 requirements.txt delete mode 100644 requirements_dev.txt diff --git a/README.md b/README.md index da7616a66..9416083c9 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Python `2.7`, `3.4`, `3.5` and `3.6`. To develop or debug `HttpRunner`, you can install relevant requirements and use `main-ate.py` or `main-locust.py` as entrances. ```bash -$ pip install -r requirements_dev.txt +$ pip install -r requirements.txt $ python main-ate -h $ python main-locust -h ``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..bafa7e701 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +requests[security] +flask +PyYAML +coveralls +coverage +PyUnitReport \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 8a7c8f00b..000000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,7 +0,0 @@ -requests[security] -flask -PyYAML -coveralls -coverage --e git+https://github.com/debugtalk/PyUnitReport.git#egg=PyUnitReport --e git+https://github.com/locustio/locust.git#egg=locustio \ No newline at end of file diff --git a/setup.py b/setup.py index 264b6616c..a9d201971 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,8 @@ with io.open("README.md", encoding='utf-8') as f: long_description = f.read() +install_requires = open("requirements.txt").readlines() + setup( name='HttpRunner', version=__version__, @@ -21,14 +23,7 @@ 'ate': ['locustfile_template'], }, keywords='api test', - install_requires=[ - "requests[security]", - "flask", - "PyYAML", - "coveralls", - "coverage", - "PyUnitReport" - ], + install_requires=install_requires, classifiers=[ "Development Status :: 3 - Alpha", 'Programming Language :: Python :: 2.7', From 91f1af46ab559165c1718178df6c3d7d50c72c78 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 20:04:21 +0800 Subject: [PATCH 321/354] fix requirements --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 56d05c582..c7aeb3f33 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - 3.5 - 3.6 install: - - pip install -r requirements_dev.txt + - pip install -r requirements.txt script: - coverage run --source=ate -m unittest discover after_success: From 15ee12af50f23c8c81369cbdd17e920b2e887de7 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 6 Nov 2017 23:07:48 +0800 Subject: [PATCH 322/354] add badges --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9416083c9..3a5314b52 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # HttpRunner -[![Build Status](https://travis-ci.org/debugtalk/HttpRunner.svg?branch=master)](https://travis-ci.org/debugtalk/HttpRunner) +[![license](https://img.shields.io/github/license/HttpRunner/HttpRunner.svg)](https://github.com/HttpRunner/HttpRunner/blob/master/LICENSE) +[![Build Status](https://travis-ci.org/debugtalk/HttpRunner.svg?branch=master)](https://travis-ci.org/HttpRunner/HttpRunner) [![Coverage Status](https://coveralls.io/repos/github/debugtalk/HttpRunner/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/HttpRunner?branch=master) +[![PyPI](https://img.shields.io/pypi/v/HttpRunner.svg)](https://pypi.python.org/pypi/HttpRunner) +[![PyPI](https://img.shields.io/pypi/pyversions/HttpRunner.svg)](https://pypi.python.org/pypi/HttpRunner) New name for `ApiTestEngine`. From e8db476867d3dae5bdc4b0221073479aaf0280f9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 7 Nov 2017 11:01:23 +0800 Subject: [PATCH 323/354] rename package name from ate to httprunner --- .travis.yml | 2 +- MANIFEST.in | 3 ++- README.md | 18 +++++++++--------- ate/__init__.py | 1 - httprunner/__init__.py | 1 + {ate => httprunner}/built_in.py | 2 +- {ate => httprunner}/cli.py | 10 +++++----- {ate => httprunner}/client.py | 2 +- {ate => httprunner}/context.py | 6 +++--- {ate => httprunner}/exception.py | 0 {ate => httprunner}/locustfile_template | 2 +- {ate => httprunner}/locusts.py | 2 +- {ate => httprunner}/response.py | 2 +- {ate => httprunner}/runner.py | 6 +++--- {ate => httprunner}/task.py | 2 +- {ate => httprunner}/testcase.py | 2 +- {ate => httprunner}/utils.py | 2 +- main-ate.py | 2 +- main-locust.py | 2 +- setup.py | 9 +++++---- tests/api_server.py | 2 +- tests/base.py | 2 +- tests/test_client.py | 2 +- tests/test_context.py | 6 +++--- tests/test_response.py | 2 +- tests/test_runner.py | 2 +- tests/test_task.py | 4 ++-- tests/test_testcase.py | 4 ++-- tests/test_utils.py | 4 ++-- 29 files changed, 53 insertions(+), 51 deletions(-) delete mode 100644 ate/__init__.py create mode 100644 httprunner/__init__.py rename {ate => httprunner}/built_in.py (94%) rename {ate => httprunner}/cli.py (94%) rename {ate => httprunner}/client.py (99%) rename {ate => httprunner}/context.py (97%) rename {ate => httprunner}/exception.py (100%) rename {ate => httprunner}/locustfile_template (94%) rename {ate => httprunner}/locusts.py (97%) rename {ate => httprunner}/response.py (99%) rename {ate => httprunner}/runner.py (98%) rename {ate => httprunner}/task.py (97%) rename {ate => httprunner}/testcase.py (99%) rename {ate => httprunner}/utils.py (99%) diff --git a/.travis.yml b/.travis.yml index c7aeb3f33..b16f44176 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,6 @@ python: install: - pip install -r requirements.txt script: - - coverage run --source=ate -m unittest discover + - coverage run --source=httprunner -m unittest discover after_success: - coveralls \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 4bf448352..3d387c348 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include README.md \ No newline at end of file +include README.md +include requirements.txt \ No newline at end of file diff --git a/README.md b/README.md index 3a5314b52..fc77c5685 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,18 @@ To upgrade all specified packages to the newest available version, you should ad If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). -To ensure the installation or upgrade is successful, you can execute command `ate -V` to see if you can get the correct version number. +To ensure the installation or upgrade is successful, you can execute command `httprunner -V` to see if you can get the correct version number. ```text -$ ate -V +$ httprunner -V HttpRunner version: 0.8.0 ``` -Execute the command `ate -h` to view command help. +Execute the command `httprunner -h` to view command help. ```text -$ ate -h -usage: ate [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] +$ httprunner -h +usage: httprunner [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] [--failfast] [--startproject STARTPROJECT] [testset_paths [testset_paths ...]] @@ -131,25 +131,25 @@ For detailed regulations of writing testcases, you can read the [`QuickStart`][q You can run single testset by specifying testset file path. ```text -$ ate filepath/testcase.yml +$ httprunner filepath/testcase.yml ``` You can also run several testsets by specifying multiple testset file paths. ```text -$ ate filepath1/testcase1.yml filepath2/testcase2.yml +$ httprunner filepath1/testcase1.yml filepath2/testcase2.yml ``` If you want to run testsets of a whole project, you can achieve this goal by specifying the project folder path. ```text -$ ate testcases_folder_path +$ httprunner testcases_folder_path ``` When you do continuous integration test or production environment monitoring with `Jenkins`, you may need to send test result notification. For instance, you can send email with mailgun service as below. ```text -$ ate filepath/testcase.yml --report-name ${BUILD_NUMBER} \ +$ httprunner filepath/testcase.yml --report-name ${BUILD_NUMBER} \ --mailgun-smtp-username "qa@debugtalk.com" \ --mailgun-smtp-password "12345678" \ --email-sender excited@samples.mailgun.org \ diff --git a/ate/__init__.py b/ate/__init__.py deleted file mode 100644 index ccf9e6286..000000000 --- a/ate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.8.0' \ No newline at end of file diff --git a/httprunner/__init__.py b/httprunner/__init__.py new file mode 100644 index 000000000..3376f842b --- /dev/null +++ b/httprunner/__init__.py @@ -0,0 +1 @@ +__version__ = '0.8.1a' \ No newline at end of file diff --git a/ate/built_in.py b/httprunner/built_in.py similarity index 94% rename from ate/built_in.py rename to httprunner/built_in.py index cf2726094..b1a5a32fe 100644 --- a/ate/built_in.py +++ b/httprunner/built_in.py @@ -7,7 +7,7 @@ import string import time -from ate.exception import ParamsError +from httprunner.exception import ParamsError def gen_random_string(str_len): diff --git a/ate/cli.py b/httprunner/cli.py similarity index 94% rename from ate/cli.py rename to httprunner/cli.py index 0f1620ab6..b2c488a4d 100644 --- a/ate/cli.py +++ b/httprunner/cli.py @@ -6,10 +6,10 @@ import PyUnitReport from PyUnitReport import __version__ as pyu_version -from ate import __version__ as ate_version -from ate import exception -from ate.task import TaskSuite -from ate.utils import create_scaffold +from httprunner import __version__ as ate_version +from httprunner import exception +from httprunner.task import TaskSuite +from httprunner.utils import create_scaffold def main_ate(): @@ -98,7 +98,7 @@ def main_locust(): """ Performance test with locust: parse command line options and run commands. """ try: - from ate import locusts + from httprunner import locusts except ImportError: print("Locust is not installed, exit.") exit(1) diff --git a/ate/client.py b/httprunner/client.py similarity index 99% rename from ate/client.py rename to httprunner/client.py index 8c1cdca16..610f6bb33 100644 --- a/ate/client.py +++ b/httprunner/client.py @@ -4,7 +4,7 @@ import time import requests -from ate.exception import ParamsError +from httprunner.exception import ParamsError from requests import Request, Response from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, RequestException) diff --git a/ate/context.py b/httprunner/context.py similarity index 97% rename from ate/context.py rename to httprunner/context.py index cda6c5a3f..e9e5bd835 100644 --- a/ate/context.py +++ b/httprunner/context.py @@ -4,8 +4,8 @@ import sys from collections import OrderedDict -from ate import utils -from ate.testcase import TestcaseParser +from httprunner import utils +from httprunner.testcase import TestcaseParser class Context(object): @@ -37,7 +37,7 @@ def init_context(self, level='testset'): self.testcase_parser.update_binded_variables(self.testcase_variables_mapping) if level == "testset": - self.import_module_items(["ate.built_in"], "testset") + self.import_module_items(["httprunner.built_in"], "testset") def config_context(self, config_dict, level): if level == "testset": diff --git a/ate/exception.py b/httprunner/exception.py similarity index 100% rename from ate/exception.py rename to httprunner/exception.py diff --git a/ate/locustfile_template b/httprunner/locustfile_template similarity index 94% rename from ate/locustfile_template rename to httprunner/locustfile_template index 6d4bf2c02..448a24c31 100644 --- a/ate/locustfile_template +++ b/httprunner/locustfile_template @@ -2,7 +2,7 @@ import zmq from locust import HttpLocust, TaskSet, task from locust.events import request_failure -from ate import runner +from httprunner import runner class WebPageTasks(TaskSet): def on_start(self): diff --git a/ate/locusts.py b/httprunner/locusts.py similarity index 97% rename from ate/locusts.py rename to httprunner/locusts.py index 1f808d2f6..11ade7a2f 100644 --- a/ate/locusts.py +++ b/httprunner/locusts.py @@ -3,7 +3,7 @@ import os import sys -from ate.testcase import load_test_file +from httprunner.testcase import load_test_file from locust.main import main diff --git a/ate/response.py b/httprunner/response.py similarity index 99% rename from ate/response.py rename to httprunner/response.py index cb38038bd..d39be0b17 100644 --- a/ate/response.py +++ b/httprunner/response.py @@ -2,7 +2,7 @@ import re from collections import OrderedDict -from ate import exception, utils +from httprunner import exception, utils from requests.structures import CaseInsensitiveDict text_extractor_regexp_compile = re.compile(r".*\(.*\).*") diff --git a/ate/runner.py b/httprunner/runner.py similarity index 98% rename from ate/runner.py rename to httprunner/runner.py index 856654b53..9f11a4e40 100644 --- a/ate/runner.py +++ b/httprunner/runner.py @@ -1,8 +1,8 @@ import logging -from ate import exception, response, testcase, utils -from ate.client import HttpSession -from ate.context import Context +from httprunner import exception, response, testcase, utils +from httprunner.client import HttpSession +from httprunner.context import Context class Runner(object): diff --git a/ate/task.py b/httprunner/task.py similarity index 97% rename from ate/task.py rename to httprunner/task.py index e35c361a0..4125a8185 100644 --- a/ate/task.py +++ b/httprunner/task.py @@ -1,7 +1,7 @@ import logging import unittest -from ate import exception, runner, testcase, utils +from httprunner import exception, runner, testcase, utils class ApiTestCase(unittest.TestCase): diff --git a/ate/testcase.py b/httprunner/testcase.py similarity index 99% rename from ate/testcase.py rename to httprunner/testcase.py index da2f04afd..33dc9cee7 100644 --- a/ate/testcase.py +++ b/httprunner/testcase.py @@ -6,7 +6,7 @@ import re import yaml -from ate import exception, utils +from httprunner import exception, utils variable_regexp = r"\$([\w_]+)" function_regexp = r"\$\{([\w_]+\([\$\w_ =,]*\))\}" diff --git a/ate/utils.py b/httprunner/utils.py similarity index 99% rename from ate/utils.py rename to httprunner/utils.py index e05541681..2a82279bc 100644 --- a/ate/utils.py +++ b/httprunner/utils.py @@ -11,7 +11,7 @@ from collections import OrderedDict import yaml -from ate import exception +from httprunner import exception from requests.structures import CaseInsensitiveDict try: diff --git a/main-ate.py b/main-ate.py index 4146b3179..dbebf9dfa 100644 --- a/main-ate.py +++ b/main-ate.py @@ -1,5 +1,5 @@ """ used for debugging """ -from ate.cli import main_ate +from httprunner.cli import main_ate main_ate() diff --git a/main-locust.py b/main-locust.py index e85d44102..ea79ce4d4 100644 --- a/main-locust.py +++ b/main-locust.py @@ -1,5 +1,5 @@ """ used for debugging """ -from ate.cli import main_locust +from httprunner.cli import main_locust main_locust() diff --git a/setup.py b/setup.py index a9d201971..583780732 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #encoding: utf-8 import io -from ate import __version__ +from httprunner import __version__ from setuptools import find_packages, setup with io.open("README.md", encoding='utf-8') as f: @@ -20,7 +20,7 @@ license='MIT', packages=find_packages(exclude=['test.*', 'test']), package_data={ - 'ate': ['locustfile_template'], + 'httprunner': ['locustfile_template'], }, keywords='api test', install_requires=install_requires, @@ -33,8 +33,9 @@ ], entry_points={ 'console_scripts': [ - 'ate=ate.cli:main_ate', - 'locusts=ate.cli:main_locust' + 'ate=httprunner.cli:main_ate', + 'httprunner=httprunner.cli:main_ate', + 'locusts=httprunner.cli:main_locust' ] } ) diff --git a/tests/api_server.py b/tests/api_server.py index 8517dc705..ae57c0001 100644 --- a/tests/api_server.py +++ b/tests/api_server.py @@ -2,7 +2,7 @@ import json from functools import wraps -from ate import utils +from httprunner import utils from flask import Flask, make_response, request app = Flask(__name__) diff --git a/tests/base.py b/tests/base.py index aa9d1b70a..cf5d85336 100644 --- a/tests/base.py +++ b/tests/base.py @@ -3,7 +3,7 @@ import unittest import requests -from ate import utils +from httprunner import utils from tests import api_server diff --git a/tests/test_client.py b/tests/test_client.py index bd169f26a..1c24557a7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -from ate.client import HttpSession, prepare_kwargs +from httprunner.client import HttpSession, prepare_kwargs from tests.base import ApiServerUnittest class TestHttpClient(ApiServerUnittest): diff --git a/tests/test_context.py b/tests/test_context.py index d56f6bdf2..413654d3d 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -2,9 +2,9 @@ import time import unittest -from ate import runner, testcase, utils -from ate.context import Context -from ate.exception import ParamsError +from httprunner import runner, testcase, utils +from httprunner.context import Context +from httprunner.exception import ParamsError class VariableBindsUnittest(unittest.TestCase): diff --git a/tests/test_response.py b/tests/test_response.py index cce3aca89..97680553e 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,5 +1,5 @@ import requests -from ate import response, exception +from httprunner import response, exception from tests.base import ApiServerUnittest class TestResponse(ApiServerUnittest): diff --git a/tests/test_runner.py b/tests/test_runner.py index f2d8e684b..d8f3d82ef 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,6 +1,6 @@ import os -from ate import exception, runner, testcase +from httprunner import exception, runner, testcase from tests.base import ApiServerUnittest diff --git a/tests/test_task.py b/tests/test_task.py index 61fe7f9e5..9f51fec31 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,7 +1,7 @@ import os -from ate import task -from ate.testcase import load_test_file +from httprunner import task +from httprunner.testcase import load_test_file from tests.base import ApiServerUnittest diff --git a/tests/test_testcase.py b/tests/test_testcase.py index 10d752d66..d9359061b 100644 --- a/tests/test_testcase.py +++ b/tests/test_testcase.py @@ -2,8 +2,8 @@ import time import unittest -from ate import testcase -from ate.exception import ApiNotFound, FileFormatError, ParamsError +from httprunner import testcase +from httprunner.exception import ApiNotFound, FileFormatError, ParamsError class TestcaseParserUnittest(unittest.TestCase): diff --git a/tests/test_utils.py b/tests/test_utils.py index bc546e5d8..f68ff4b2d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,7 @@ import shutil from collections import OrderedDict -from ate import exception, utils +from httprunner import exception, utils from tests.base import ApiServerUnittest @@ -155,7 +155,7 @@ def test_get_imported_module(self): self.assertIn("walk", dir(imported_module)) def test_filter_module_functions(self): - imported_module = utils.get_imported_module("ate.utils") + imported_module = utils.get_imported_module("httprunner.utils") self.assertIn("PYTHON_VERSION", dir(imported_module)) functions_dict = utils.filter_module(imported_module, "function") From ce70331871d48d9be0ff75cea0c479e1734c5f04 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 7 Nov 2017 11:50:38 +0800 Subject: [PATCH 324/354] update with PyUnitReport --- httprunner/__init__.py | 2 +- httprunner/cli.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 3376f842b..8e62f0169 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.1a' \ No newline at end of file +__version__ = '0.8.1b' \ No newline at end of file diff --git a/httprunner/cli.py b/httprunner/cli.py index b2c488a4d..f90b8ab67 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -4,8 +4,9 @@ import sys from collections import OrderedDict -import PyUnitReport -from PyUnitReport import __version__ as pyu_version +from pyunitreport import __version__ as pyu_version +from pyunitreport import HTMLTestRunner + from httprunner import __version__ as ate_version from httprunner import exception from httprunner.task import TaskSuite @@ -77,7 +78,7 @@ def main_ate(): "report_name": report_name, "failfast": args.failfast } - result = PyUnitReport.HTMLTestRunner(**kwargs).run(task_suite) + result = HTMLTestRunner(**kwargs).run(task_suite) results[testset_path] = OrderedDict({ "total": result.testsRun, "successes": len(result.successes), From 8a98719d7b7aca6d92000ecf11f3112b132c9dd5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 8 Nov 2017 17:53:42 +0800 Subject: [PATCH 325/354] add locust installation command --- httprunner/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index f90b8ab67..153388b7b 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -101,7 +101,9 @@ def main_locust(): try: from httprunner import locusts except ImportError: - print("Locust is not installed, exit.") + msg = "Locust is not installed, install first and try again.\n" + msg += "install command: pip install locustio" + print(msg) exit(1) sys.argv[0] = 'locust' From ff570e39734b7f710f1947d19a39051e8d91d81d Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 8 Nov 2017 19:15:54 +0800 Subject: [PATCH 326/354] make documents on readthedocs --- .gitignore | 6 +- README.md | 199 ------------------ docs/FAQ.rst | 19 ++ docs/Installation.rst | 74 +++++++ docs/Introduction.md | 28 +++ docs/Makefile | 20 ++ docs/README.rst | 0 docs/_static/my.css | 15 ++ docs/background-CN.md | 44 ---- docs/conf.py | 192 +++++++++++++++++ docs/development.md | 9 + docs/feature-descriptions-CN.md | 138 ------------ .../ate-quickstart-demo-report.jpg | Bin docs/{ => images}/ate-quickstart-http-1.jpg | Bin docs/{ => images}/ate-quickstart-http-2.jpg | Bin docs/{ => images}/locusts-full-speed.jpg | Bin docs/index.rst | 20 ++ docs/load-test.md | 45 ++++ docs/quickstart.md | 6 +- docs/run-testcases.md | 34 +++ docs/write-testcases.rst | 182 ++++++++++++++++ 21 files changed, 646 insertions(+), 385 deletions(-) create mode 100644 docs/FAQ.rst create mode 100644 docs/Installation.rst create mode 100644 docs/Introduction.md create mode 100644 docs/Makefile create mode 100644 docs/README.rst create mode 100644 docs/_static/my.css delete mode 100644 docs/background-CN.md create mode 100644 docs/conf.py create mode 100644 docs/development.md delete mode 100644 docs/feature-descriptions-CN.md rename docs/{ => images}/ate-quickstart-demo-report.jpg (100%) rename docs/{ => images}/ate-quickstart-http-1.jpg (100%) rename docs/{ => images}/ate-quickstart-http-2.jpg (100%) rename docs/{ => images}/locusts-full-speed.jpg (100%) create mode 100644 docs/index.rst create mode 100644 docs/load-test.md create mode 100644 docs/run-testcases.md create mode 100644 docs/write-testcases.rst diff --git a/.gitignore b/.gitignore index 5dc7a5ea8..51bb42d95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc __pycache__ .DS_Store +.vscode +.pypirc */tmp/* build/* dist/* @@ -8,4 +10,6 @@ dist/* .python-version logs/% .coverage -locustfile.py \ No newline at end of file +locustfile.py + +_build diff --git a/README.md b/README.md index fc77c5685..fb02169d9 100644 --- a/README.md +++ b/README.md @@ -23,211 +23,12 @@ Take full reuse of Python's existing powerful libraries: [`Requests`][requests], - With reuse of [`Locust`][Locust], you can run performance test without extra work. - CLI command supported, perfect combination with [Jenkins][Jenkins]. -[*`Background Introduction (中文版)`*](docs/background-CN.md) | [*`Feature Descriptions (中文版)`*](docs/feature-descriptions-CN.md) - -## Installation/Upgrade - -```bash -$ pip install HttpRunner -``` - -To upgrade all specified packages to the newest available version, you should add the `-U` option. - -If there is a problem with the installation or upgrade, you can check the [`FAQ`](docs/FAQ.md). - -To ensure the installation or upgrade is successful, you can execute command `httprunner -V` to see if you can get the correct version number. - -```text -$ httprunner -V -HttpRunner version: 0.8.0 -``` - -Execute the command `httprunner -h` to view command help. - -```text -$ httprunner -h -usage: httprunner [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] - [--failfast] [--startproject STARTPROJECT] - [testset_paths [testset_paths ...]] - -HttpRunner. - -positional arguments: - testset_paths testset file path - -optional arguments: - -h, --help show this help message and exit - -V, --version show version - --log-level LOG_LEVEL - Specify logging level, default is INFO. - --report-name REPORT_NAME - Specify report name, default is generated time. - --failfast Stop the test run on the first error or failure. - --startproject STARTPROJECT - Specify new project name. -``` - -## Write testcases - -It is recommended to write testcases in `YAML` format. - -And here is testset example of typical scenario: get `token` at the beginning, and each subsequent requests should take the `token` in the headers. - -```yaml -- config: - name: "create user testsets." - variables: - - user_agent: 'iOS/10.3' - - device_sn: ${gen_random_string(15)} - - os_platform: 'ios' - - app_version: '2.8.6' - request: - base_url: http://127.0.0.1:5000 - headers: - Content-Type: application/json - device_sn: $device_sn - -- test: - name: get token - request: - url: /api/get-token - method: POST - headers: - user_agent: $user_agent - device_sn: $device_sn - os_platform: $os_platform - app_version: $app_version - json: - sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} - extract: - - token: content.token - validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} - -- test: - name: create user which does not exist - request: - url: /api/users/1000 - method: POST - headers: - token: $token - json: - name: "user1" - password: "123456" - validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} -``` - -Function invoke is supported in `YAML/JSON` format testcases, such as `gen_random_string` and `get_sign` above. This mechanism relies on the `debugtak.py` hot plugin, with which we can define functions in `debugtak.py` file, and then functions can be auto discovered and invoked in runtime. - -For detailed regulations of writing testcases, you can read the [`QuickStart`][quickstart] documents. - -## Run testcases - -`HttpRunner` can run testcases in diverse ways. - -You can run single testset by specifying testset file path. - -```text -$ httprunner filepath/testcase.yml -``` - -You can also run several testsets by specifying multiple testset file paths. - -```text -$ httprunner filepath1/testcase1.yml filepath2/testcase2.yml -``` - -If you want to run testsets of a whole project, you can achieve this goal by specifying the project folder path. - -```text -$ httprunner testcases_folder_path -``` - -When you do continuous integration test or production environment monitoring with `Jenkins`, you may need to send test result notification. For instance, you can send email with mailgun service as below. - -```text -$ httprunner filepath/testcase.yml --report-name ${BUILD_NUMBER} \ - --mailgun-smtp-username "qa@debugtalk.com" \ - --mailgun-smtp-password "12345678" \ - --email-sender excited@samples.mailgun.org \ - --email-recepients ${MAIL_RECEPIENTS} \ - --jenkins-job-name ${JOB_NAME} \ - --jenkins-job-url ${JOB_URL} \ - --jenkins-build-number ${BUILD_NUMBER} -``` - -## Performance test - -With reuse of [`Locust`][Locust], you can run performance test without extra work. - -```bash -$ locusts -V -[2017-08-26 23:45:42,246] bogon/INFO/stdout: Locust 0.8a2 -[2017-08-26 23:45:42,246] bogon/INFO/stdout: -``` - -For full usage, you can run `locusts -h` to see help, and you will find that it is the same with `locust -h`. - -The only difference is the `-f` argument. If you specify `-f` with a Python locustfile, it will be the same as `locust`, while if you specify `-f` with a `YAML/JSON` testcase file, it will convert to Python locustfile first and then pass to `locust`. - -```bash -$ locusts -f examples/first-testcase.yml -[2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 -[2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 -``` - -In this case, you can reuse all features of [`Locust`][Locust]. - -That’s not all about it. With the argument `--full-speed`, you can even start locust with master and several slaves (default to cpu cores number) at one time, which means you can leverage all cpus of your machine. - -```bash -$ locusts -f examples/first-testcase.yml --full-speed -[2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8089 -[2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,080] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,083] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,084] bogon/INFO/locust.runners: Client 'bogon_656e0af8e968a8533d379dd252422ad3' reported as ready. Currently 1 clients ready to swarm. -[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_09f73850252ee4ec739ed77d3c4c6dba' reported as ready. Currently 2 clients ready to swarm. -[2017-08-26 23:51:47,084] bogon/INFO/locust.main: Starting Locust 0.8a2 -[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_869f7ed671b1a9952b56610f01e2006f' reported as ready. Currently 3 clients ready to swarm. -[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm. -``` - -![](docs/locusts-full-speed.jpg) - -Enjoy! - ## Supported Python Versions Python `2.7`, `3.4`, `3.5` and `3.6`. `HttpRunner` has been tested on `macOS`, `Linux` and `Windows` platforms. -## Development - -To develop or debug `HttpRunner`, you can install relevant requirements and use `main-ate.py` or `main-locust.py` as entrances. - -```bash -$ pip install -r requirements.txt -$ python main-ate -h -$ python main-locust -h -``` - -## To learn more ... - -- [《接口自动化测试的最佳工程实践(ApiTestEngine)》](http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/) -- [`ApiTestEngine QuickStart`][quickstart] -- [《ApiTestEngine 演进之路(0)开发未动,测试先行》](http://debugtalk.com/post/ApiTestEngine-0-setup-CI-test/) -- [《ApiTestEngine 演进之路(1)搭建基础框架》](http://debugtalk.com/post/ApiTestEngine-1-setup-basic-framework/) -- [《ApiTestEngine 演进之路(2)探索优雅的测试用例描述方式》](http://debugtalk.com/post/ApiTestEngine-2-best-testcase-description/) -- [《ApiTestEngine 演进之路(3)测试用例中实现 Python 函数的定义》](http://debugtalk.com/post/ApiTestEngine-3-define-functions-in-yaml-testcases/) -- [《ApiTestEngine 演进之路(4)测试用例中实现 Python 函数的调用》](http://debugtalk.com/post/ApiTestEngine-4-call-functions-in-yaml-testcases/) -- [《ApiTestEngine 集成 Locust 实现更好的性能测试体验》](http://debugtalk.com/post/apitestengine-supersede-locust/) -- [《约定大于配置:ApiTestEngine实现热加载机制》](http://debugtalk.com/post/apitestengine-hot-plugin/) [requests]: http://docs.python-requests.org/en/master/ [unittest]: https://docs.python.org/3/library/unittest.html diff --git a/docs/FAQ.rst b/docs/FAQ.rst new file mode 100644 index 000000000..bc510fb8c --- /dev/null +++ b/docs/FAQ.rst @@ -0,0 +1,19 @@ +FAQ +=== + +Unable to install PyUnitReport dependency library automatically +--------------------------------------------------------------- + +If there is something goes wrong in installation like below. :: + + Downloading/unpacking PyUnitReport (from HttpRunner) + Could not find any downloads that satisfy the requirement PyUnitReport (from HttpRunner) + +You could install ``PyUnitReport`` manully at first. :: + + pip install PyUnitReport + + +And then everything will be OK when you reinstall ``HttpRunner``. :: + + pip install HttpRunner diff --git a/docs/Installation.rst b/docs/Installation.rst new file mode 100644 index 000000000..4ebd96c02 --- /dev/null +++ b/docs/Installation.rst @@ -0,0 +1,74 @@ +.. default-role:: code + +Installation +============ + +``HttpRunner`` is available on `PyPI`_ and can be installed through pip or easy_install. :: + + $ pip install HttpRunner + +or :: + + $ easy_install HttpRunner + + +If you want to keep up with the latest version, you can install with github repository url. :: + + $ pip install git+https://github.com/HttpRunner/HttpRunner.git#egg=HttpRunner + + +Upgrade +------- + +If you have installed ``HttpRunner`` before and want to upgrade to the latest version, you can use the ``-U`` option. + +This option works on each installation method described above. :: + + $ pip install -U HttpRunner + $ easy_install -U HttpRunner + $ pip install -U git+https://github.com/HttpRunner/HttpRunner.git#egg=HttpRunner + + +Check Installation +------------------ + +When HttpRunner is installed, a **httprunner** command should be available in your shell (if you're not using +virtualenv—which you should—make sure your python script directory is on your path). + +To see ``HttpRunner`` version: :: + + $ httprunner -V + HttpRunner version: 0.8.1b + PyUnitReport version: 0.1.3b + +To see available options, run:: + + $ httprunner -h + usage: httprunner [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] + [--failfast] [--startproject STARTPROJECT] + [testset_paths [testset_paths ...]] + + HttpRunner. + + positional arguments: + testset_paths testset file path + + optional arguments: + -h, --help show this help message and exit + -V, --version show version + --log-level LOG_LEVEL + Specify logging level, default is INFO. + --report-name REPORT_NAME + Specify report name, default is generated time. + --failfast Stop the test run on the first error or failure. + --startproject STARTPROJECT + Specify new project name. + + +Supported Python Versions +------------------------- + +HttpRunner supports Python 2.7, 3.4, 3.5, and 3.6. And we strongly recommend you to use ``Python 3.6``. + + +.. _PyPI: https://pypi.python.org/pypi diff --git a/docs/Introduction.md b/docs/Introduction.md new file mode 100644 index 000000000..5da8c0590 --- /dev/null +++ b/docs/Introduction.md @@ -0,0 +1,28 @@ +# Introduction + +## Design Philosophy + +Take full reuse of Python's existing powerful libraries: [`Requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. + +## Key Features + +- Inherit all powerful features of [`Requests`][requests], just have fun to handle HTTP in human way. +- Define testcases in YAML or JSON format in concise and elegant manner. +- Supports `function`/`variable`/`extract`/`validate` mechanisms to create full test scenarios. +- With `debugtalk.py` plugin, module functions can be auto-discovered in recursive upward directories. +- Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. +- Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. +- With reuse of [`Locust`][Locust], you can run performance test without extra work. +- CLI command supported, perfect combination with [Jenkins][Jenkins]. + +## Learn more + +You can read this [blog][HttpRunner-blog] to learn more about the background and initial thoughts of `HttpRunner`. + + +[requests]: http://docs.python-requests.org/en/master/ +[unittest]: https://docs.python.org/3/library/unittest.html +[Locust]: http://locust.io/ +[PyUnitReport]: https://github.com/HttpRunner/PyUnitReport +[Jenkins]: https://jenkins.io/index.html +[HttpRunner-blog]: http://debugtalk.com/post/ApiTestEngine-api-test-best-practice/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..d55b4e2fd --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = python -msphinx +SPHINXPROJ = HttpRunner +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/README.rst b/docs/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/docs/_static/my.css b/docs/_static/my.css new file mode 100644 index 000000000..4ddd366fe --- /dev/null +++ b/docs/_static/my.css @@ -0,0 +1,15 @@ +@import 'https://media.readthedocs.org/css/sphinx_rtd_theme.css'; + +.wy-nav-content { + max-width: 1020px +} + +.rst-content .topic { + border: silver 1px solid; + margin: 10px auto; + padding: 10px; +} + +.rst-content .highlight>pre { + line-height: 1.5; +} diff --git a/docs/background-CN.md b/docs/background-CN.md deleted file mode 100644 index 87fa58740..000000000 --- a/docs/background-CN.md +++ /dev/null @@ -1,44 +0,0 @@ -## 背景 - -当前市面上存在的接口测试工具已经非常多,常见的如`Postman`、`JMeter`、`RobotFramework`等,相信大多数测试人员都有使用过,至少从接触到的大多数简历的描述上看是这样的。除了这些成熟的工具,也有很多有一定技术能力的测试(开发)人员自行开发了一些接口测试框架,质量也是参差不齐。 - -但是,当我打算在项目组中推行接口自动化测试时,搜罗了一圈,也没有找到一款特别满意的工具或框架,总是与理想中的构想存在一定的差距。 - -那么理想中的接口自动化测试框架应该是怎样的呢? - -测试工具(框架)脱离业务使用场景都是耍流氓!所以我们不妨先来看下日常工作中的一些常见场景。 - -- 测试或开发人员在定位问题的时候,想调用某个接口查看其是否响应正常; -- 测试人员在手工测试某个功能点的时候,需要一个订单号,而这个订单号可以通过顺序调用多个接口实现下单流程; -- 测试人员在开始版本功能测试之前,可以先检测下系统的所有接口是否工作正常,确保接口正常后再开始手工测试; -- 开发人员在提交代码前需要检测下新代码是否对系统的已有接口产生影响; -- 项目组需要每天定时检测下测试环境所有接口的工作情况,确保当天的提交代码没有对主干分支的代码造成破坏; -- 项目组需要定时(30分钟)检测下生产环境所有接口的工作情况,以便及时发现生产环境服务不可用的情况; -- 项目组需要不定期对核心业务场景进行性能测试,期望能减少人力投入,直接复用接口测试中的工作成果。 - -可以看到,以上罗列的场景大家应该都很熟悉,这都是我们在日常工作中经常需要去做的事情。但是在没有一款合适工具的情况下,效率往往十分低下,或者就是某些重要工作压根就没有开展,例如接口回归测试、线上接口监控等。 - -先说下最简单的手工调用接口测试。可能有人会说,`Postman`就可以满足需求啊。的确,`Postman`作为一款通用的接口测试工具,它可以构造接口请求,查看接口响应,从这个层面上来说,它是满足了接口测试的功能需求。但是在具体的项目中,使用`Postman`并不是那么高效。 - -不妨举个最常见的例子。 - -> 某个接口的请求参数非常多,并且接口请求要求有`MD5`签名校验;签名的方式为在Headers中包含一个`sign`参数,该参数值通过对`URL`、`Method`、`Body`的拼接字符串进行`MD5`计算后得到。 - -回想下我们要对这个接口进行测试时是怎么做的。首先,我们需要先参照接口文档的描述,手工填写完所有接口参数;然后,按照签名校验方式,对所有参数值进行拼接得到一个字符串,在另一个MD5计算工具计算得到其MD5值,将签名值填入`sign`参数;最后,才是发起接口请求,查看接口响应,并人工检测响应是否正常。最坑爹的是,我们每次需要调用这个接口的时候,以上工作就得重新来一遍。这样的实际结果是,面对参数较多或者需要签名验证的接口时,测试人员可能会选择忽略不进行接口测试。 - -除了单个接口的调用,很多时候我们也需要组合多个接口进行调用。例如测试人员在测试物流系统时,经常需要一个特定组合条件下生成的订单号。而由于订单号关联的业务较多,很难直接在数据库中生成,因此当前业务测试人员普遍采取的做法,就是每次需要订单号时模拟下单流程,顺序调用多个相应的接口来生成需要的订单号。可以想象,在手工调用单个接口都如此麻烦的情况下,每次都要手工调用多个接口会有多么的费时费力。 - -再说下接口自动化调用测试。这一块儿大多接口测试框架都支持,普遍的做法就是通过代码编写接口测试用例,或者采用数据驱动的方式,然后在支持命令行(CLI)调用的情况下,就可以结合`Jenkins`或者`crontab`实现持续集成,或者定时接口监控的功能。 - -思路是没有问题的,问题在于实际项目中的推动落实情况。要说自动化测试用例最靠谱的维护方式,还是直接通过代码编写测试用例,可靠且不失灵活性,这也是很多经历过惨痛教训的老手的感悟,甚至网络上还出现了一些反测试框架的言论。但问题在于项目中的测试人员并不是都会写代码,也不是对其强制要求就能马上学会的。这种情况下,要想在具体项目中推动接口自动化测试就很难,就算我可以帮忙写一部分,但是很多时候接口测试用例也是要结合业务逻辑场景的,我也的确是没法在这方面投入太多时间,毕竟对接的项目实在太多。所以也是基于这类原因,很多测试框架提倡采用数据驱动的方式,将业务测试用例和执行代码分离。不过由于很多时候业务场景比较复杂,大多数框架测试用例模板引擎的表达能力不足,很难采用简洁的方式对测试场景进行描述,从而也没法很好地得到推广使用。 - -可以列举的问题还有很多,这些也的确都是在互联网企业的日常测试工作中真实存在的痛点。 - -基于以上背景,我产生了开发[`ApiTestEngine`][ApiTestEngine]的想法。 - -对于[`ApiTestEngine`][ApiTestEngine]的定位,与其说它是一个工具或框架,它更多的应该是一套接口自动化测试的最佳工程实践,而`简洁优雅实用`应该是它最核心的特点。 - -当然,每位工程师对`最佳工程实践`的理念或多或少都会存在一些差异,也希望大家能多多交流,在思维的碰撞中共同进步。 - - -[ApiTestEngine]: https://github.com/debugtalk/HttpRunner \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..69f05f85f --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# HttpRunner documentation build configuration file, created by +# sphinx-quickstart on Wed Nov 8 14:28:04 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) +import os +on_rtd = os.environ.get('READTHEDOCS') == 'True' + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.githubpages' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +from recommonmark.parser import CommonMarkParser + +source_parsers = { + '.md': CommonMarkParser, +} +source_suffix = ['.rst', '.md'] + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'HttpRunner' +copyright = '2017, DebugTalk' +author = 'debugtalk' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.8' +# The full version, including alpha/beta/rc tags. +release = '0.8.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'zh' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +if on_rtd: + html_theme = 'default' + html_context = { + 'css_files': [ + 'https://media.readthedocs.org/css/sphinx_rtd_theme.css', + 'https://media.readthedocs.org/css/readthedocs-doc-embed.css', + '_static/my.css', + ], + } +else: + import sphinx_rtd_theme + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + html_style = 'my.css' + +html_show_sourcelink = False + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# This is required for the alabaster theme +# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars +# html_sidebars = { +# '**': [ +# 'about.html', +# 'navigation.html', +# 'relations.html', # needs 'show_related': True theme option to display +# 'searchbox.html', +# # 'donate.html', +# ] +# } + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'HttpRunnerdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'HttpRunner.tex', 'HttpRunner Documentation', + 'debugtalk', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'httprunner', 'HttpRunner Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'HttpRunner', 'HttpRunner Documentation', + author, 'HttpRunner', 'One line description of project.', + 'Miscellaneous'), +] diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..ac1800cc3 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,9 @@ +## Development + +To develop or debug `HttpRunner`, you can install relevant requirements and use `main-ate.py` or `main-locust.py` as entrances. + +```bash +$ pip install -r requirements.txt +$ python main-ate -h +$ python main-locust -h +``` diff --git a/docs/feature-descriptions-CN.md b/docs/feature-descriptions-CN.md deleted file mode 100644 index c4dbdb707..000000000 --- a/docs/feature-descriptions-CN.md +++ /dev/null @@ -1,138 +0,0 @@ -## 核心特性 - -- 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 -- 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML/JSON` -- 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 -- 接口测试用例具有可复用性,便于创建复杂测试场景 -- 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 -- 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等 -- 身兼多职,同时实现接口管理、接口自动化测试、接口性能测试(结合Locust) -- 具有可扩展性,便于扩展实现Web平台化 - -## 特性拆解介绍 - -> 支持API接口的多种请求方法,包括 GET/POST/HEAD/PUT/DELETE 等 - -个人偏好,编程语言选择Python。而采用Python实现HTTP请求,最好的方式就是采用[`Requests`][Requests]库了,简洁优雅,功能强大。 - -> 测试用例与代码分离,测试用例维护方式简洁优雅,支持`YAML` - -要实现测试用例与代码的分离,最好的做法就是做一个测试用例加载引擎和一个测试用例执行引擎,这也是之前在做[`AppiumBooster`][AppiumBooster]框架的时候总结出来的最优雅的实现方式。当然,这里需要事先对测试用例制定一个标准的数据结构规范,作为测试用例加载引擎和测试用例执行引擎的桥梁。 - -需要说明的是,测试用例数据结构必须包含接口测试用例完备的信息要素,包括接口请求的信息内容(URL、Headers、Method等参数),以及预期的接口请求响应结果(StatusCode、ResponseHeaders、ResponseContent)。 - -这样做的好处在于,不管测试用例采用什么形式进行描述([`YAML`][YAML]、JSON、CSV、Excel、XML等),也不管测试用例是否采用了业务分层的组织思想,只要在测试用例加载引擎中实现对应的转换器,都可以将业务测试用例转换为标准的测试用例数据结构。而对于测试用例执行引擎而言,它无需关注测试用例的具体描述形式,只需要从标准的测试用例数据结构中获取到测试用例信息要素,包括接口请求信息和预期接口响应信息,然后构造并发起HTTP请求,再将HTTP请求的响应结果与预期结果进行对比判断即可。 - -至于为什么明确说明支持[`YAML`][YAML],这是因为个人认为这是最佳的测试用例描述方式,表达简洁不累赘,同时也能包含非常丰富的信息。当然,这只是个人喜好,如果喜欢采用别的方式,只需要扩展实现对应的转换器即可。 - -> 测试用例描述方式具有表现力,可采用简洁的方式描述输入参数和预期输出结果 - -测试用例与框架代码分离以后,对业务逻辑测试场景的描述重任就落在测试用例上了。比如我们选择采用[`YAML`][YAML]来描述测试用例,那么我们就应该能在[`YAML`][YAML]中描述各种复杂的业务场景。 - -那么怎么理解这个“表现力”呢? - -简单的参数值传参应该都容易理解,我们举几个相对复杂但又比较常见的例子。 - -- 接口请求参数中要包含当前的时间戳; -- 接口请求参数中要包含一个16位的随机字符串; -- 接口请求参数中包含签名校验,需要对多个请求参数进行拼接后取md5值; -- 接口响应头(Headers)中要包含一个`X-ATE-V`头域,并且需要判断该值是否大于100; -- 接口响应结果中包含一个字符串,需要校验字符串中是否包含10位长度的订单号; -- 接口响应结果为一个多层嵌套的json结构体,需要判断某一层的某一个元素值是否为True。 - -可以看出,以上几个例子都是没法直接在测试用例里面描述参数值的。如果是采用Python脚本来编写测试用例还好解决,只需要通过Python函数实现即可。但是现在测试用例和框架代码分离了,我们没法在[`YAML`][YAML]里面执行Python函数,这该怎么办呢? - -答案就是,定义函数转义符,实现自定义模板。 - -这种做法其实也不难理解,也算是模板语言通用的方式。例如,我们将`${}`定义为转义符,那么在`{}`内的内容就不再当做是普通的字符串,而应该转义为变量值,或者执行函数得到实际结果。当然,这个需要我们在测试用例执行引擎进行适配实现,最简单方式就是提取出`${}`中的字符串,通过`eval`计算得到表达式的值。如果要实现更复杂的功能,我们也可以将接口测试中常用的一些功能封装为一套关键字,然后在编写测试用例的时候使用这些关键字。 - -> 接口测试用例具有可复用性,便于创建复杂测试场景 - -很多情况下,系统的接口都是有业务逻辑关联的。例如,要请求调用登录接口,需要先请求获取验证码的接口,然后在登录请求中带上获取到的验证码;而要请求数据查询的接口,又要在请求参数中包含登录接口返回的session值。这个时候,我们如果针对每一个要测的业务逻辑,都单独描述要请求的接口,那么就会造成大量的重复描述,测试用例的维护也十分臃肿。 - -比较好的做法是,将每一个接口调用单独封装为一条测试用例,然后在描述业务测试场景时,选择对应的接口,按照顺序拼接为业务场景测试用例,就像搭积木一般。如果你之前读过[`AppiumBooster`][AppiumBooster]的介绍,应该还会联想到,我们可以将常用的功能组成模块用例集,然后就可以在更高的层面对模块用例集进行组装,实现更复杂的测试场景。 - -不过,这里有一个非常关键的问题需要解决,就是如何在接口测试用例之前传参的问题。其实实现起来也不复杂,我们可以在接口请求响应结果中指定一个变量名,然后将接口返回关键值提取出来后赋值给那个变量;然后在其它接口请求参数中,传入这个`${变量名}`即可。 - -> 测试执行方式简单灵活,支持单接口调用测试、批量接口调用测试、定时任务执行测试 - -通过背景中的例子可以看出,需要使用接口测试工具的场景很多,除了定时地对所有接口进行自动化测试检测外,很多时候在手工测试的时候也需要采用接口测试工具进行辅助,也就是`半手工+半自动化`的模式。 - -而业务测试人员在使用测试工具的时候,遇到的最大问题在于除了需要关注业务功能本身,还需要花费很多时间去处理技术实现细节上的东西,例如签名校验这类情况,而且往往后者在重复操作中占用的时间更多。 - -这个问题的确是没法避免的,毕竟不同系统的接口千差万别,不可能存在一款工具可以自动处理所有情况。但是我们可以尝试将接口的技术细节实现和业务参数进行拆分,让业务测试人员只需要关注业务参数部分。 - -具体地,我们可以针对每一个接口配置一个模板,将其中与业务功能无关的参数以及技术细节封装起来,例如签名校验、时间戳、随机值等,而与业务功能相关的参数配置为可传参的模式。 - -这样做的好处在于,与业务功能无关的参数以及技术细节我们只需要封装配置一次,而且这个工作可以由开发人员或者测试开发人员来实现,减轻业务测试人员的压力;接口模板配置好后,测试人员只需要关注与业务相关的参数即可,结合业务测试用例,就可以在接口模板的基础上很方便地配置生成多个接口测试用例。 - -> 测试结果统计报告简洁清晰,附带详尽日志记录,包括接口请求耗时、请求响应数据等 - -测试结果统计报告,应该遵循简洁而不简单的原则。“简洁”,是因为大多数时候我们只需要在最短的时间内判断所有接口是否运行正常即可。而“不简单”,是因为当存在执行失败的测试用例时,我们期望能获得接口测试时尽可能详细的数据,包括测试时间、请求参数、响应内容、接口响应耗时等。 - -之前在读`locust`源码时,其对[`HTTP`客户端](https://github.com/locustio/locust/blob/master/locust/clients.py -)的封装方式给我留下了深刻的印象。它采用的做法是,继承`requests.Session`类,在子类`HttpSession`中重写覆盖了`request`方法,然后在`request`方法中对`requests.Session.request`进行了一层封装。 - -```python -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`][Requests]库的强大功能,同时也能将请求的响应时间、响应体大小等原始性能数据进行保存,实现可谓十分优雅。 - -受到该处启发,要保存接口的详细请求响应数据也可采用同样的方式。例如,要保存`Response`的`Headers`、`Body`只需要增加如下两行代码: - -```python -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]库做接口测试时,请求客户端其实也是`requests.Session`类的实例,只是我们通常用的是`requests`的简化用法。 - -以下两种用法是等价的。 - -```python -resp = requests.get('http://debugtalk.com') - -# 等价于 -client = requests.Session() -resp = client.get('http://debugtalk.com') -``` - -有了这一层关系以后,要在接口自动化测试和性能测试之间切换就很容易了。在接口测试框架内,可以通过如下方式初始化`HTTP`客户端。 - -```python -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平台功能。 - - -[AppiumBooster]: https://github.com/debugtalk/AppiumBooster -[Requests]: http://docs.python-requests.org/en/master/ -[YAML]: http://pyyaml.org/ \ No newline at end of file diff --git a/docs/ate-quickstart-demo-report.jpg b/docs/images/ate-quickstart-demo-report.jpg similarity index 100% rename from docs/ate-quickstart-demo-report.jpg rename to docs/images/ate-quickstart-demo-report.jpg diff --git a/docs/ate-quickstart-http-1.jpg b/docs/images/ate-quickstart-http-1.jpg similarity index 100% rename from docs/ate-quickstart-http-1.jpg rename to docs/images/ate-quickstart-http-1.jpg diff --git a/docs/ate-quickstart-http-2.jpg b/docs/images/ate-quickstart-http-2.jpg similarity index 100% rename from docs/ate-quickstart-http-2.jpg rename to docs/images/ate-quickstart-http-2.jpg diff --git a/docs/locusts-full-speed.jpg b/docs/images/locusts-full-speed.jpg similarity index 100% rename from docs/locusts-full-speed.jpg rename to docs/images/locusts-full-speed.jpg diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..eed83de56 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,20 @@ +.. HttpRunner documentation master file, created by + sphinx-quickstart on Wed Nov 8 14:28:04 2017. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to HttpRunner's documentation! +====================================== + +.. toctree:: + :maxdepth: 1 + :caption: Contents + + Introduction + Installation + quickstart + write-testcases + run-testcases + load-test + development + FAQ diff --git a/docs/load-test.md b/docs/load-test.md new file mode 100644 index 000000000..2ac5e8b5c --- /dev/null +++ b/docs/load-test.md @@ -0,0 +1,45 @@ + +## Load Test + +With reuse of [`Locust`][Locust], you can run performance test without extra work. + +```bash +$ locusts -V +[2017-08-26 23:45:42,246] bogon/INFO/stdout: Locust 0.8a2 +[2017-08-26 23:45:42,246] bogon/INFO/stdout: +``` + +For full usage, you can run `locusts -h` to see help, and you will find that it is the same with `locust -h`. + +The only difference is the `-f` argument. If you specify `-f` with a Python locustfile, it will be the same as `locust`, while if you specify `-f` with a `YAML/JSON` testcase file, it will convert to Python locustfile first and then pass to `locust`. + +```bash +$ locusts -f examples/first-testcase.yml +[2017-08-18 17:20:43,915] Leos-MacBook-Air.local/INFO/locust.main: Starting web monitor at *:8089 +[2017-08-18 17:20:43,918] Leos-MacBook-Air.local/INFO/locust.main: Starting Locust 0.8a2 +``` + +In this case, you can reuse all features of [`Locust`][Locust]. + +That’s not all about it. With the argument `--full-speed`, you can even start locust with master and several slaves (default to cpu cores number) at one time, which means you can leverage all cpus of your machine. + +```bash +$ locusts -f examples/first-testcase.yml --full-speed +[2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8089 +[2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,080] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,083] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,084] bogon/INFO/locust.runners: Client 'bogon_656e0af8e968a8533d379dd252422ad3' reported as ready. Currently 1 clients ready to swarm. +[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_09f73850252ee4ec739ed77d3c4c6dba' reported as ready. Currently 2 clients ready to swarm. +[2017-08-26 23:51:47,084] bogon/INFO/locust.main: Starting Locust 0.8a2 +[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_869f7ed671b1a9952b56610f01e2006f' reported as ready. Currently 3 clients ready to swarm. +[2017-08-26 23:51:47,085] bogon/INFO/locust.runners: Client 'bogon_80a804cda36b80fac17b57fd2d5e7cdb' reported as ready. Currently 4 clients ready to swarm. +``` + +![](images/locusts-full-speed.jpg) + +Enjoy! + + +[Locust]: http://locust.io/ \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md index b258341c6..a199f9466 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -30,9 +30,9 @@ Before we write testcases, we should know the details of the API. It is a good c For example, the image below illustrates getting token from the sample service first, and then creating one user successfully. -![](ate-quickstart-http-1.jpg) +![](images/ate-quickstart-http-1.jpg) -![](ate-quickstart-http-2.jpg) +![](images/ate-quickstart-http-2.jpg) After thorough understanding of the APIs, we can now begin to write testcases. @@ -316,7 +316,7 @@ Reports generated: /Users/Leo/MyProjects/HttpRunner/reports/quickstart-demo-rev- Great! The test case runs successfully and generates a `HTML` test report. -![](ate-quickstart-demo-report.jpg) +![](images/ate-quickstart-demo-report.jpg) ## Further more diff --git a/docs/run-testcases.md b/docs/run-testcases.md new file mode 100644 index 000000000..15b210eeb --- /dev/null +++ b/docs/run-testcases.md @@ -0,0 +1,34 @@ +## Run testcases + +`HttpRunner` can run testcases in diverse ways. + +You can run single testset by specifying testset file path. + +```text +$ httprunner filepath/testcase.yml +``` + +You can also run several testsets by specifying multiple testset file paths. + +```text +$ httprunner filepath1/testcase1.yml filepath2/testcase2.yml +``` + +If you want to run testsets of a whole project, you can achieve this goal by specifying the project folder path. + +```text +$ httprunner testcases_folder_path +``` + +When you do continuous integration test or production environment monitoring with `Jenkins`, you may need to send test result notification. For instance, you can send email with mailgun service as below. + +```text +$ httprunner filepath/testcase.yml --report-name ${BUILD_NUMBER} \ + --mailgun-smtp-username "qa@debugtalk.com" \ + --mailgun-smtp-password "12345678" \ + --email-sender excited@samples.mailgun.org \ + --email-recepients ${MAIL_RECEPIENTS} \ + --jenkins-job-name ${JOB_NAME} \ + --jenkins-job-url ${JOB_URL} \ + --jenkins-build-number ${BUILD_NUMBER} +``` diff --git a/docs/write-testcases.rst b/docs/write-testcases.rst new file mode 100644 index 000000000..b80300984 --- /dev/null +++ b/docs/write-testcases.rst @@ -0,0 +1,182 @@ +.. default-role:: code + +Write testcases +=============== + +It is recommended to write testcases in `YAML` format. + +demo +---- + +And here is testset example of typical scenario: get `token` at the beginning, and each subsequent requests should take the `token` in the headers. + +.. code-block:: yaml + + - config: + name: "create user testsets." + variables: + - user_agent: 'iOS/10.3' + - device_sn: ${gen_random_string(15)} + - os_platform: 'ios' + - app_version: '2.8.6' + request: + base_url: http://127.0.0.1:5000 + headers: + Content-Type: application/json + device_sn: $device_sn + + - test: + name: get token + request: + url: /api/get-token + method: POST + headers: + user_agent: $user_agent + device_sn: $device_sn + os_platform: $os_platform + app_version: $app_version + json: + sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} + extract: + - token: content.token + validate: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + + - test: + name: create user which does not exist + request: + url: /api/users/1000 + method: POST + headers: + token: $token + json: + name: "user1" + password: "123456" + validate: + - {"check": "status_code", "comparator": "eq", "expected": 201} + - {"check": "content.success", "comparator": "eq", "expected": true} + +Function invoke is supported in `YAML/JSON` format testcases, such as `gen_random_string` and `get_sign` above. This mechanism relies on the `debugtak.py` hot plugin, with which we can define functions in `debugtak.py` file, and then functions can be auto discovered and invoked in runtime. + +For detailed regulations of writing testcases, you can read the :doc:`quickstart` documents. + + +Comparator +---------- + +``HttpRunner`` currently supports the following comparators. + ++---------------------------+---------------------------+-------------------------+--------------------------+ +| comparator | Description | A(check), B(expected) | examples | ++===========================+===========================+=========================+==========================+ +| ``eq``, ``==`` | value is equal | A == B | 9 eq 9 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``lt`` | less than | A < B | 7 lt 8 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``le`` | less than or equals | A <= B | 7 le 8, 8 le 8 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``gt`` | greater than | A > B | 8 gt 7 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``ge`` | greater than or equals | A >= B | 8 ge 7, 8 ge 8 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``ne`` | not equals | A != B | 6 ne 9 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``str_eq`` | string equals | str(A) == str(B) | 123 str_eq '123' | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``len_eq``, ``count_eq`` | length or count equals | len(A) == B | | 'abc' len_eq 3 | +| | | | | [1,2] len_eq 2 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``len_gt``, ``count_gt`` | length greater than | len(A) > B | | 'abc' len_gt 2 | +| | | | | [1,2,3] len_gt 2 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``len_ge``, ``count_ge`` | length greater than | len(A) >= B | | 'abc' len_ge 3 | +| | or equals | | | [1,2,3] len_gt 3 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``len_lt``, ``count_lt`` | length less than | len(A) < B | | 'abc' len_lt 4 | +| | | | | [1,2,3] len_lt 4 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``len_le``, ``count_le`` | length less than | len(A) <= B | | 'abc' len_le 3 | +| | or equals | | | [1,2,3] len_le 3 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``contains`` | contains | [1, 2] contains 1 | | 'abc' contains 'a' | +| | | | | [1,2,3] len_lt 4 | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``contained_by`` | contained by | A in B | | 'a' contained_by 'abc' | +| | | | | 1 contained_by [1,2] | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``type`` | A is instance of B | isinstance(A, B) | 123 type 'int' | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``regex`` | regex matches | re.match(B, A) | 'abcdef' regex 'a\w+d' | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``startswith`` | starts with | A.startswith(B) is True | 'abc' startswith 'ab' | ++---------------------------+---------------------------+-------------------------+--------------------------+ +| ``endswith`` | ends with | A.endswith(B) is True | 'abc' endswith 'bc' | ++---------------------------+---------------------------+-------------------------+--------------------------+ + + +Extraction and Validation +------------------------- + +Suppose we get the following HTTP response. + +.. code-block:: javascript + + // 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"] + } + } + + +In `extract` and `validate`, we can do chain operation to extract data field in HTTP response. + +For instance, if we want to get `Content-Type` in response headers, then we can specify `headers.content-type`; if we want to get `first_name` in response content, we can specify `content.person.name.first_name`. + +There might be slight difference on list, cos we can use index to locate list item. For example, `Guangzhou` in response content can be specified by `content.person.cities.0`. + +.. code-block:: javascript + + // get status code + status_code + + // get headers field + headers.content-type + + // get content field + body.success + content.success + text.success + content.person.name.first_name + content.person.cities.1 + + +.. code-block:: yaml + + extract: + - content_type: headers.content-type + - first_name: content.person.name.first_name + validate: + - {"check": "status_code", "comparator": "eq", "expected": 200} + - {"check": "headers.content-type", "expected": "application/json"} + - {"check": "headers.content-length", "comparator": "gt", "expected": 40} + - {"check": "content.success", "comparator": "eq", "expected": True} + - {"check": "content.token", "comparator": "len_eq", "expected": 16} + + + +.. _QuickStart: http:// \ No newline at end of file From 49c389d1e7aa2a8e07598f4eefb399e3c4a1de86 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 8 Nov 2017 21:35:49 +0800 Subject: [PATCH 327/354] convert README from md to rst --- MANIFEST.in | 2 +- README.md | 39 ------------------------------- README.rst | 54 +++++++++++++++++++++++++++++++++++++++++++ docs/Installation.rst | 2 ++ setup.py | 2 +- 5 files changed, 58 insertions(+), 41 deletions(-) delete mode 100644 README.md create mode 100644 README.rst diff --git a/MANIFEST.in b/MANIFEST.in index 3d387c348..36c6bd73a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include README.md +include README.rst include requirements.txt \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index fb02169d9..000000000 --- a/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# HttpRunner - -[![license](https://img.shields.io/github/license/HttpRunner/HttpRunner.svg)](https://github.com/HttpRunner/HttpRunner/blob/master/LICENSE) -[![Build Status](https://travis-ci.org/debugtalk/HttpRunner.svg?branch=master)](https://travis-ci.org/HttpRunner/HttpRunner) -[![Coverage Status](https://coveralls.io/repos/github/debugtalk/HttpRunner/badge.svg?branch=master)](https://coveralls.io/github/debugtalk/HttpRunner?branch=master) -[![PyPI](https://img.shields.io/pypi/v/HttpRunner.svg)](https://pypi.python.org/pypi/HttpRunner) -[![PyPI](https://img.shields.io/pypi/pyversions/HttpRunner.svg)](https://pypi.python.org/pypi/HttpRunner) - -New name for `ApiTestEngine`. - -## Design Philosophy - -Take full reuse of Python's existing powerful libraries: [`Requests`][requests], [`unittest`][unittest] and [`Locust`][Locust]. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. - -## Key Features - -- Inherit all powerful features of [`Requests`][requests], just have fun to handle HTTP in human way. -- Define testcases in YAML or JSON format in concise and elegant manner. -- Supports `function`/`variable`/`extract`/`validate` mechanisms to create full test scenarios. -- With `debugtalk.py` plugin, module functions can be auto-discovered in recursive upward directories. -- Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. -- Test report is concise and clear, with detailed log records. See [`PyUnitReport`][PyUnitReport]. -- With reuse of [`Locust`][Locust], you can run performance test without extra work. -- CLI command supported, perfect combination with [Jenkins][Jenkins]. - -## Supported Python Versions - -Python `2.7`, `3.4`, `3.5` and `3.6`. - -`HttpRunner` has been tested on `macOS`, `Linux` and `Windows` platforms. - - -[requests]: http://docs.python-requests.org/en/master/ -[unittest]: https://docs.python.org/3/library/unittest.html -[Locust]: http://locust.io/ -[flask]: http://flask.pocoo.org/ -[PyUnitReport]: https://github.com/debugtalk/PyUnitReport -[Jenkins]: https://jenkins.io/index.html -[quickstart]: docs/quickstart.md \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..924b461bc --- /dev/null +++ b/README.rst @@ -0,0 +1,54 @@ +HttpRunner +========== + +.. image:: https://img.shields.io/github/license/HttpRunner/HttpRunner.svg + :target: https://github.com/HttpRunner/HttpRunner/blob/master/LICENSE + +.. image:: https://travis-ci.org/debugtalk/HttpRunner.svg?branch=master + :target: https://travis-ci.org/HttpRunner/HttpRunner + +.. image:: https://coveralls.io/repos/github/debugtalk/HttpRunner/badge.svg?branch=master + :target: https://coveralls.io/github/debugtalk/HttpRunner?branch=master + +.. image:: https://img.shields.io/pypi/v/HttpRunner.svg + :target: https://pypi.python.org/pypi/HttpRunner + +.. image:: https://img.shields.io/pypi/pyversions/HttpRunner.svg + :target: https://pypi.python.org/pypi/HttpRunner + + +New name for ``ApiTestEngine``. + +Design Philosophy +----------------- + +Take full reuse of Python's existing powerful libraries: `Requests`_, `unittest`_ and `Locust`_. And achieve the goal of API automation test, production environment monitoring, and API performance test, with a concise and elegant manner. + +Key Features +------------ + +- Inherit all powerful features of `Requests`_, just have fun to handle HTTP in human way. +- Define testcases in YAML or JSON format in concise and elegant manner. +- Supports ``function``/``variable``/``extract``/``validate`` mechanisms to create full test scenarios. +- With ``debugtalk.py`` plugin, module functions can be auto-discovered in recursive upward directories. +- Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. +- Test report is concise and clear, with detailed log records. See `PyUnitReport`_. +- With reuse of `Locust`_, you can run performance test without extra work. +- CLI command supported, perfect combination with `Jenkins`_. + +Documentation +------------- + +HttpRunner is rich documented. + +- `User documentation`_ helps you to make the most use of HttpRunner +- `Development process blogs`_ will make you fully understand HttpRunner + + +.. _Requests: http://docs.python-requests.org/en/master/ +.. _unittest: https://docs.python.org/3/library/unittest.html +.. _Locust: http://locust.io/ +.. _PyUnitReport: https://github.com/HttpRunner/PyUnitReport +.. _Jenkins: https://jenkins.io/index.html +.. _User documentation: http://httprunner.readthedocs.io/ +.. _Development process blogs: http://debugtalk.com/tags/ApiTestEngine/ diff --git a/docs/Installation.rst b/docs/Installation.rst index 4ebd96c02..f6a9439c0 100644 --- a/docs/Installation.rst +++ b/docs/Installation.rst @@ -70,5 +70,7 @@ Supported Python Versions HttpRunner supports Python 2.7, 3.4, 3.5, and 3.6. And we strongly recommend you to use ``Python 3.6``. +``HttpRunner`` has been tested on ``macOS``, ``Linux`` and ``Windows`` platforms. + .. _PyPI: https://pypi.python.org/pypi diff --git a/setup.py b/setup.py index 583780732..fdb593518 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from httprunner import __version__ from setuptools import find_packages, setup -with io.open("README.md", encoding='utf-8') as f: +with io.open("README.rst", encoding='utf-8') as f: long_description = f.read() install_requires = open("requirements.txt").readlines() From ad4037ee52642bd9547e925ab2a4eb430622364f Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 8 Nov 2017 21:55:40 +0800 Subject: [PATCH 328/354] update version --- httprunner/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 8e62f0169..58135234f 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.1b' \ No newline at end of file +__version__ = '0.8.1c' \ No newline at end of file From 5f05aa3d9bd2eb5caf714157a24c956a1197130e Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 10 Nov 2017 23:10:22 +0800 Subject: [PATCH 329/354] update parser description --- httprunner/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index 153388b7b..b4ca4e0b9 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -17,7 +17,7 @@ def main_ate(): """ API test: parse command line options and run commands. """ parser = argparse.ArgumentParser( - description='Api Test Engine.') + description='HTTP test runner, not just about api test and load test.') parser.add_argument( '-V', '--version', dest='version', action='store_true', help="show version") From a57d5370c5f897644b194a41c2961bd7273455f5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Fri, 10 Nov 2017 23:25:02 +0800 Subject: [PATCH 330/354] add next step scheme --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index 924b461bc..7e06df004 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,15 @@ HttpRunner is rich documented. - `User documentation`_ helps you to make the most use of HttpRunner - `Development process blogs`_ will make you fully understand HttpRunner +Next Step +--------- + +There are still too many awesome features to be implemented. Recent schedules includes: + +- Integrate with Charles and Fiddler: convert `HAR`_ (HTTP Archive) format to YAML testcases. +- Integrate with Swagger: convert exported API definition of `Swagger`_ to YAML testcases. +- Integrate with PostMan: convert `Postman Collection Format`_ to YAML testcases. + .. _Requests: http://docs.python-requests.org/en/master/ .. _unittest: https://docs.python.org/3/library/unittest.html @@ -52,3 +61,6 @@ HttpRunner is rich documented. .. _Jenkins: https://jenkins.io/index.html .. _User documentation: http://httprunner.readthedocs.io/ .. _Development process blogs: http://debugtalk.com/tags/ApiTestEngine/ +.. _HAR: http://httparchive.org/ +.. _Swagger: https://swagger.io/ +.. _Postman Collection Format : http://blog.getpostman.com/2015/06/05/travelogue-of-postman-collection-format-v2/ From 75b15f2a7948192f0f5400c647db2d14c5e2cdc9 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sun, 12 Nov 2017 08:56:00 +0800 Subject: [PATCH 331/354] add hrun command short for httprunner --- docs/Installation.rst | 6 +++--- setup.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Installation.rst b/docs/Installation.rst index f6a9439c0..6fbe266d2 100644 --- a/docs/Installation.rst +++ b/docs/Installation.rst @@ -32,18 +32,18 @@ This option works on each installation method described above. :: Check Installation ------------------ -When HttpRunner is installed, a **httprunner** command should be available in your shell (if you're not using +When HttpRunner is installed, a **httprunner** (**hrun** for short) command should be available in your shell (if you're not using virtualenv—which you should—make sure your python script directory is on your path). To see ``HttpRunner`` version: :: - $ httprunner -V + $ httprunner -V # same as: hrun -V HttpRunner version: 0.8.1b PyUnitReport version: 0.1.3b To see available options, run:: - $ httprunner -h + $ httprunner -h # same as: hrun -h usage: httprunner [-h] [-V] [--log-level LOG_LEVEL] [--report-name REPORT_NAME] [--failfast] [--startproject STARTPROJECT] [testset_paths [testset_paths ...]] diff --git a/setup.py b/setup.py index fdb593518..9bfc8268d 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ 'console_scripts': [ 'ate=httprunner.cli:main_ate', 'httprunner=httprunner.cli:main_ate', + 'hrun=httprunner.cli:main_ate', 'locusts=httprunner.cli:main_locust' ] } From b262ae5e5e301a3c8e3d6d54e4a4ce70248f60f3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 13 Nov 2017 20:56:46 +0800 Subject: [PATCH 332/354] validate: set expect as alias for expected --- docs/quickstart.md | 28 +++++----- docs/write-testcases.rst | 20 +++---- examples/quickstart-demo-rev-0.yml | 8 +-- examples/quickstart-demo-rev-1.yml | 8 +-- examples/quickstart-demo-rev-2.yml | 8 +-- examples/quickstart-demo-rev-3.yml | 8 +-- httprunner/response.py | 19 ++++--- tests/api/demo.yml | 4 +- tests/data/demo_testset_hardcode.json | 12 ++--- tests/data/demo_testset_hardcode.yml | 12 ++--- tests/data/demo_testset_layer.yml | 52 +++++++++---------- ...demo_testset_template_import_functions.yml | 12 ++--- ...demo_testset_template_lambda_functions.yml | 12 ++--- tests/data/demo_testset_variables.yml | 12 ++--- tests/test_response.py | 12 ++--- tests/test_runner.py | 4 +- 16 files changed, 117 insertions(+), 114 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index a199f9466..b14772d42 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -66,8 +66,8 @@ Open your favorite text editor and you can write test cases like this. name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} ``` As you see, each API request is described in a `test` block. And in the `request` field, it describes the detail of HTTP request, includes url, method, headers and data, which are in line with the captured traffic. @@ -123,8 +123,8 @@ To fix this problem, we should correlate `token` field in the second API test ca extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -138,8 +138,8 @@ To fix this problem, we should correlate `token` field in the second API test ca name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} ``` As you see, the `token` field is no longer hardcoded, instead it is extracted from the first API request with `extract` mechanism. In the meanwhile, it is assigned to `token` variable, which can be referenced by the subsequent API requests. @@ -205,8 +205,8 @@ And then, we can revise our demo test case and reference the functions. Suppose extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -220,8 +220,8 @@ And then, we can revise our demo test case and reference the functions. Suppose name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} ``` In this revised test case, `variable reference` and `function invoke` mechanisms are both used. @@ -269,8 +269,8 @@ To handle this case, overall `config` block is supported in `HttpRunner`. If we extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -283,8 +283,8 @@ To handle this case, overall `config` block is supported in `HttpRunner`. If we name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} ``` As you see, we define variables in `config` block. Also, we can set `base_url` in `config` block, thereby we can specify relative path in each API request url. Besides, we can also set common fields in `config` `request`, such as `device_sn` in headers. diff --git a/docs/write-testcases.rst b/docs/write-testcases.rst index b80300984..c3ea5dced 100644 --- a/docs/write-testcases.rst +++ b/docs/write-testcases.rst @@ -40,8 +40,8 @@ And here is testset example of typical scenario: get `token` at the beginning, a extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -54,8 +54,8 @@ And here is testset example of typical scenario: get `token` at the beginning, a name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} Function invoke is supported in `YAML/JSON` format testcases, such as `gen_random_string` and `get_sign` above. This mechanism relies on the `debugtak.py` hot plugin, with which we can define functions in `debugtak.py` file, and then functions can be auto discovered and invoked in runtime. @@ -68,7 +68,7 @@ Comparator ``HttpRunner`` currently supports the following comparators. +---------------------------+---------------------------+-------------------------+--------------------------+ -| comparator | Description | A(check), B(expected) | examples | +| comparator | Description | A(check), B(expect) | examples | +===========================+===========================+=========================+==========================+ | ``eq``, ``==`` | value is equal | A == B | 9 eq 9 | +---------------------------+---------------------------+-------------------------+--------------------------+ @@ -171,11 +171,11 @@ There might be slight difference on list, cos we can use index to locate list it - content_type: headers.content-type - first_name: content.person.name.first_name validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "headers.content-type", "expected": "application/json"} - - {"check": "headers.content-length", "comparator": "gt", "expected": 40} - - {"check": "content.success", "comparator": "eq", "expected": True} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "headers.content-type", "expect": "application/json"} + - {"check": "headers.content-length", "comparator": "gt", "expect": 40} + - {"check": "content.success", "comparator": "eq", "expect": True} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} diff --git a/examples/quickstart-demo-rev-0.yml b/examples/quickstart-demo-rev-0.yml index 5580b17da..63dadbd60 100644 --- a/examples/quickstart-demo-rev-0.yml +++ b/examples/quickstart-demo-rev-0.yml @@ -11,8 +11,8 @@ json: sign: 19067cf712265eb5426db8d3664026c1ccea02b9 validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -26,5 +26,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} diff --git a/examples/quickstart-demo-rev-1.yml b/examples/quickstart-demo-rev-1.yml index 0610615a6..7bd3446d0 100644 --- a/examples/quickstart-demo-rev-1.yml +++ b/examples/quickstart-demo-rev-1.yml @@ -13,8 +13,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -28,5 +28,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} diff --git a/examples/quickstart-demo-rev-2.yml b/examples/quickstart-demo-rev-2.yml index c41a37f8e..f8e2ccf95 100644 --- a/examples/quickstart-demo-rev-2.yml +++ b/examples/quickstart-demo-rev-2.yml @@ -18,8 +18,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -33,5 +33,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} \ No newline at end of file + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} \ No newline at end of file diff --git a/examples/quickstart-demo-rev-3.yml b/examples/quickstart-demo-rev-3.yml index 952d2a59f..7038d8e65 100644 --- a/examples/quickstart-demo-rev-3.yml +++ b/examples/quickstart-demo-rev-3.yml @@ -25,8 +25,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -39,5 +39,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} diff --git a/httprunner/response.py b/httprunner/response.py index d39be0b17..b59feae64 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -128,8 +128,8 @@ def validate(self, validators, variables_mapping): """ Bind named validators to value within the context. @param (list) validators [ - {"check": "status_code", "comparator": "eq", "expected": 201}, - {"check": "resp_body_success", "comparator": "eq", "expected": True} + {"check": "status_code", "comparator": "eq", "expect": 201}, + {"check": "resp_body_success", "comparator": "eq", "expect": True} ] @param (dict) variables_mapping { @@ -139,7 +139,7 @@ def validate(self, validators, variables_mapping): [ { "check": "status_code", - "comparator": "eq", "expected": 201, "value": 200 + "comparator": "eq", "expect": 201, "value": 200 } ] """ @@ -147,12 +147,15 @@ def validate(self, validators, variables_mapping): check_item = validator_dict.get("check") if not check_item: - raise exception.ParamsError("invalid check item in testcase validators!") + raise exception.ParamsError("check item invalid: {}".format(check_item)) - if "expected" not in validator_dict: - raise exception.ParamsError("expected item missed in testcase validators!") + if "expect" in validator_dict: + expect_value = validator_dict.get("expect") + elif "expected" in validator_dict: + expect_value = validator_dict.get("expected") + else: + raise exception.ParamsError("expected value missed in testcase validator!") - expected = validator_dict.get("expected") comparator = validator_dict.get("comparator", "eq") if check_item in variables_mapping: @@ -165,7 +168,7 @@ def validate(self, validators, variables_mapping): utils.match_expected( validator_dict["actual_value"], - expected, + expect_value, comparator, check_item ) diff --git a/tests/api/demo.yml b/tests/api/demo.yml index d256bc686..cd1637757 100644 --- a/tests/api/demo.yml +++ b/tests/api/demo.yml @@ -11,8 +11,8 @@ json: sign: ${get_sign($user_agent, $device_sn, $os_platform, $app_version)} validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - api: def: create_user($uid, $user_name, $user_password, $token) diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index 08d722615..df7b220c9 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -22,8 +22,8 @@ } ], "validate": [ - {"check": "status_code", "comparator": "eq", "expected": 200}, - {"check": "content.token", "comparator": "len_eq", "expected": 16} + {"check": "status_code", "comparator": "eq", "expect": 200}, + {"check": "content.token", "comparator": "len_eq", "expect": 16} ] } }, @@ -44,8 +44,8 @@ } }, "validate": [ - {"check": "status_code", "comparator": "eq", "expected": 201}, - {"check": "content.success", "comparator": "eq", "expected": true} + {"check": "status_code", "comparator": "eq", "expect": 201}, + {"check": "content.success", "comparator": "eq", "expect": true} ] } }, @@ -66,8 +66,8 @@ } }, "validate": [ - {"check": "status_code", "comparator": "eq", "expected": 500}, - {"check": "content.success", "comparator": "eq", "expected": false} + {"check": "status_code", "comparator": "eq", "expect": 500}, + {"check": "content.success", "comparator": "eq", "expect": false} ] } } diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index 7646e86f4..fd869cb90 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -14,8 +14,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -30,8 +30,8 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} - test: name: create user which existed @@ -46,5 +46,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} \ No newline at end of file + - {"check": "status_code", "comparator": "eq", "expect": 500} + - {"check": "content.success", "comparator": "eq", "expect": false} \ No newline at end of file diff --git a/tests/data/demo_testset_layer.yml b/tests/data/demo_testset_layer.yml index 50328b6fd..1b70b1705 100644 --- a/tests/data/demo_testset_layer.yml +++ b/tests/data/demo_testset_layer.yml @@ -23,15 +23,15 @@ name: reset all users api: reset_all($token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.success", "expected": true} + - {"check": "status_code", "expect": 200} + - {"check": "content.success", "expect": true} - test: name: get user that does not exist api: get_user(1000, $token) validate: - - {"check": "status_code", "expected": 404} - - {"check": "content.success", "expected": false} + - {"check": "status_code", "expect": 404} + - {"check": "content.success", "expect": false} - test: name: create user which does not exist @@ -40,16 +40,16 @@ - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validate: - - {"check": "status_code", "expected": 201} - - {"check": "content.success", "expected": true} + - {"check": "status_code", "expect": 201} + - {"check": "content.success", "expect": true} - test: name: get user that has been created api: get_user(1000, $token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.success", "expected": true} - - {"check": "content.data.password", "expected": "123456"} + - {"check": "status_code", "expect": 200} + - {"check": "content.success", "expect": true} + - {"check": "content.data.password", "expect": "123456"} - test: name: create user which exists @@ -58,8 +58,8 @@ - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validate: - - {"check": "status_code", "expected": 500} - - {"check": "content.success", "expected": false} + - {"check": "status_code", "expect": 500} + - {"check": "content.success", "expect": false} - test: name: update user which exists @@ -68,37 +68,37 @@ - user_password: "654321" api: update_user(1000, $user_name, $user_password, $token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.success", "expected": true} + - {"check": "status_code", "expect": 200} + - {"check": "content.success", "expect": true} - test: name: get user that has been updated api: get_user(1000, $token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.success", "expected": true} - - {"check": "content.data.password", "expected": "654321"} + - {"check": "status_code", "expect": 200} + - {"check": "content.success", "expect": true} + - {"check": "content.data.password", "expect": "654321"} - test: name: get users api: get_users($token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.count", "expected": 1} + - {"check": "status_code", "expect": 200} + - {"check": "content.count", "expect": 1} - test: name: delete user that exists api: delete_user(1000, $token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.success", "expected": true} + - {"check": "status_code", "expect": 200} + - {"check": "content.success", "expect": true} - test: name: get users api: get_users($token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.count", "expected": 0} + - {"check": "status_code", "expect": 200} + - {"check": "content.count", "expect": 0} - test: name: create user which has been deleted @@ -107,12 +107,12 @@ - user_password: "123456" api: create_user(1000, $user_name, $user_password, $token) validate: - - {"check": "status_code", "expected": 201} - - {"check": "content.success", "expected": true} + - {"check": "status_code", "expect": 201} + - {"check": "content.success", "expect": true} - test: name: get users api: get_users($token) validate: - - {"check": "status_code", "expected": 200} - - {"check": "content.count", "expected": 1} + - {"check": "status_code", "expect": 200} + - {"check": "content.count", "expect": 1} diff --git a/tests/data/demo_testset_template_import_functions.yml b/tests/data/demo_testset_template_import_functions.yml index d786ecdf9..05a6806bb 100644 --- a/tests/data/demo_testset_template_import_functions.yml +++ b/tests/data/demo_testset_template_import_functions.yml @@ -28,8 +28,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -45,8 +45,8 @@ name: $user_name password: $user_password validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} - test: name: create user which does not exist @@ -59,5 +59,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "status_code", "comparator": "eq", "expect": 500} + - {"check": "content.success", "comparator": "eq", "expect": false} diff --git a/tests/data/demo_testset_template_lambda_functions.yml b/tests/data/demo_testset_template_lambda_functions.yml index 983217842..2610161c0 100644 --- a/tests/data/demo_testset_template_lambda_functions.yml +++ b/tests/data/demo_testset_template_lambda_functions.yml @@ -38,8 +38,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -55,8 +55,8 @@ name: $user_name password: $user_password validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} - test: name: create user which does not exist @@ -69,5 +69,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "status_code", "comparator": "eq", "expect": 500} + - {"check": "content.success", "comparator": "eq", "expect": false} diff --git a/tests/data/demo_testset_variables.yml b/tests/data/demo_testset_variables.yml index 1efd6bb9e..987e3c45e 100644 --- a/tests/data/demo_testset_variables.yml +++ b/tests/data/demo_testset_variables.yml @@ -29,8 +29,8 @@ extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expected": 200} - - {"check": "content.token", "comparator": "len_eq", "expected": 16} + - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "content.token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -46,8 +46,8 @@ name: $user_name password: $user_password validate: - - {"check": "status_code", "comparator": "eq", "expected": 201} - - {"check": "content.success", "comparator": "eq", "expected": true} + - {"check": "status_code", "comparator": "eq", "expect": 201} + - {"check": "content.success", "comparator": "eq", "expect": true} - test: name: create user which does not exist @@ -60,5 +60,5 @@ name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expected": 500} - - {"check": "content.success", "comparator": "eq", "expected": false} + - {"check": "status_code", "comparator": "eq", "expect": 500} + - {"check": "content.success", "comparator": "eq", "expect": false} diff --git a/tests/test_response.py b/tests/test_response.py index 97680553e..3dd79ccd0 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -232,8 +232,8 @@ def test_validate(self): resp_obj = response.ResponseObject(resp) validators = [ - {"check": "resp_status_code", "comparator": "eq", "expected": 201}, - {"check": "resp_body_success", "comparator": "eq", "expected": True} + {"check": "resp_status_code", "comparator": "eq", "expect": 201}, + {"check": "resp_body_success", "comparator": "eq", "expect": True} ] variables_mapping = { "resp_status_code": 200, @@ -244,8 +244,8 @@ def test_validate(self): resp_obj.validate(validators, variables_mapping) validators = [ - {"check": "resp_status_code", "comparator": "eq", "expected": 201}, - {"check": "resp_body_success", "comparator": "eq", "expected": True} + {"check": "resp_status_code", "comparator": "eq", "expect": 201}, + {"check": "resp_body_success", "comparator": "eq", "expect": True} ] variables_mapping = { "resp_status_code": 201, @@ -261,7 +261,7 @@ def test_validate_exception(self): # expected value missed in validators validators = [ - {"check": "status_code", "comparator": "eq", "expected": 201}, + {"check": "status_code", "comparator": "eq", "expect": 201}, {"check": "body_success", "comparator": "eq"} ] variables_mapping = {} @@ -270,7 +270,7 @@ def test_validate_exception(self): # expected value missed in variables mapping validators = [ - {"check": "resp_status_code", "comparator": "eq", "expected": 201}, + {"check": "resp_status_code", "comparator": "eq", "expect": 201}, {"check": "body_success", "comparator": "eq"} ] variables_mapping = { diff --git a/tests/test_runner.py b/tests/test_runner.py index d8f3d82ef..ad530fa26 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -56,8 +56,8 @@ def test_run_single_testcase_fail(self): {"token": "content.token"} ], "validate": [ - {"check": "status_code", "comparator": "eq", "expected": 205}, - {"check": "content.token", "comparator": "len_eq", "expected": 19} + {"check": "status_code", "expect": 205}, + {"check": "content.token", "comparator": "len_eq", "expect": 19} ] } From 6c00dacc56e859cc7cc268133c3fc002dae745a1 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 13 Nov 2017 21:03:01 +0800 Subject: [PATCH 333/354] remove unused import --- httprunner/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/httprunner/utils.py b/httprunner/utils.py index 2a82279bc..228490249 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -10,7 +10,6 @@ import types from collections import OrderedDict -import yaml from httprunner import exception from requests.structures import CaseInsensitiveDict From ad90b4c78760097a3639cb9e70c13f7111f3a663 Mon Sep 17 00:00:00 2001 From: httprunner Date: Mon, 13 Nov 2017 22:53:09 +0800 Subject: [PATCH 334/354] bugfix #53: ensure teardown functions executed when test fail --- httprunner/__init__.py | 2 +- httprunner/runner.py | 4 ++-- tests/test_runner.py | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 58135234f..df85fade7 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.1c' \ No newline at end of file +__version__ = '0.8.1d' \ No newline at end of file diff --git a/httprunner/runner.py b/httprunner/runner.py index 9f11a4e40..e74f466fb 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -138,8 +138,8 @@ def setup_teardown(actions): err_msg += u"HTTP response content: \n{}".format(resp.text) logging.error(err_msg) raise - - setup_teardown(teardown_actions) + finally: + setup_teardown(teardown_actions) return True diff --git a/tests/test_runner.py b/tests/test_runner.py index ad530fa26..6c45df2df 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,4 +1,5 @@ import os +import time from httprunner import exception, runner, testcase @@ -58,11 +59,16 @@ def test_run_single_testcase_fail(self): "validate": [ {"check": "status_code", "expect": 205}, {"check": "content.token", "comparator": "len_eq", "expect": 19} - ] + ], + "teardown": ["${sleep(2)}"] } with self.assertRaises(exception.ValidationError): + start_time = time.time() self.test_runner._run_test(test) + end_time = time.time() + # check if teardown function executed + self.assertGreater(end_time - start_time, 2) def test_run_testset_hardcode(self): for testcase_file_path in self.testcase_file_path_list: From d5f534bceb2cd75dff1f0e4448780d3c127f69a3 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 14 Nov 2017 16:01:31 +0800 Subject: [PATCH 335/354] update coveralls --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 7e06df004..300257f3d 100644 --- a/README.rst +++ b/README.rst @@ -4,11 +4,11 @@ HttpRunner .. image:: https://img.shields.io/github/license/HttpRunner/HttpRunner.svg :target: https://github.com/HttpRunner/HttpRunner/blob/master/LICENSE -.. image:: https://travis-ci.org/debugtalk/HttpRunner.svg?branch=master +.. image:: https://travis-ci.org/HttpRunner/HttpRunner.svg?branch=master :target: https://travis-ci.org/HttpRunner/HttpRunner -.. image:: https://coveralls.io/repos/github/debugtalk/HttpRunner/badge.svg?branch=master - :target: https://coveralls.io/github/debugtalk/HttpRunner?branch=master +.. image:: https://coveralls.io/repos/github/HttpRunner/HttpRunner/badge.svg?branch=master + :target: https://coveralls.io/github/HttpRunner/HttpRunner?branch=master .. image:: https://img.shields.io/pypi/v/HttpRunner.svg :target: https://pypi.python.org/pypi/HttpRunner From 64e22eb8288629091512f0e9c5c187c4a73ee3c6 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 14 Nov 2017 16:15:03 +0800 Subject: [PATCH 336/354] reduce installing dependent libs for user --- .travis.yml | 2 ++ httprunner/__init__.py | 2 +- requirements.txt | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index b16f44176..fd94171a9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,8 @@ python: - 3.5 - 3.6 install: + - pip install coverage + - pip install coveralls - pip install -r requirements.txt script: - coverage run --source=httprunner -m unittest discover diff --git a/httprunner/__init__.py b/httprunner/__init__.py index df85fade7..78bbd491e 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.1d' \ No newline at end of file +__version__ = '0.8.2' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bafa7e701..4c6ecb9c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ requests[security] flask PyYAML -coveralls -coverage PyUnitReport \ No newline at end of file From 107530373c388f035147a7efba1f7561a9a85f42 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 15 Nov 2017 18:17:36 +0800 Subject: [PATCH 337/354] update --- README.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 300257f3d..53fe3c0d7 100644 --- a/README.rst +++ b/README.rst @@ -17,7 +17,7 @@ HttpRunner :target: https://pypi.python.org/pypi/HttpRunner -New name for ``ApiTestEngine``. +Former name: ``ApiTestEngine``. Design Philosophy ----------------- @@ -29,6 +29,7 @@ Key Features - Inherit all powerful features of `Requests`_, just have fun to handle HTTP in human way. - Define testcases in YAML or JSON format in concise and elegant manner. +- Record and generate testcases with ``HAR`` support. see `har2case`_. - Supports ``function``/``variable``/``extract``/``validate`` mechanisms to create full test scenarios. - With ``debugtalk.py`` plugin, module functions can be auto-discovered in recursive upward directories. - Testcases can be run in diverse ways, with single testset, multiple testsets, or entire project folder. @@ -49,9 +50,9 @@ Next Step There are still too many awesome features to be implemented. Recent schedules includes: -- Integrate with Charles and Fiddler: convert `HAR`_ (HTTP Archive) format to YAML testcases. -- Integrate with Swagger: convert exported API definition of `Swagger`_ to YAML testcases. -- Integrate with PostMan: convert `Postman Collection Format`_ to YAML testcases. +- [x] Integrate with Charles and Fiddler: convert `HAR`_ (HTTP Archive) format to YAML testcases. +- [ ] Integrate with Swagger: convert exported API definition of `Swagger`_ to YAML testcases. +- [ ] Integrate with PostMan: convert `Postman Collection Format`_ to YAML testcases. .. _Requests: http://docs.python-requests.org/en/master/ @@ -63,4 +64,5 @@ There are still too many awesome features to be implemented. Recent schedules in .. _Development process blogs: http://debugtalk.com/tags/ApiTestEngine/ .. _HAR: http://httparchive.org/ .. _Swagger: https://swagger.io/ -.. _Postman Collection Format : http://blog.getpostman.com/2015/06/05/travelogue-of-postman-collection-format-v2/ +.. _Postman Collection Format: http://blog.getpostman.com/2015/06/05/travelogue-of-postman-collection-format-v2/ +.. _har2case: https://github.com/HttpRunner/har2case From 32f95b0fa7a691e5670c907f5f606719feb22bd0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 15 Nov 2017 18:46:57 +0800 Subject: [PATCH 338/354] add print exception --- httprunner/runner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/httprunner/runner.py b/httprunner/runner.py index e74f466fb..7e6fd927a 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -197,6 +197,9 @@ def _run_testset(self, testset, variables_mapping=None): response_time=0, exception=ex ) + else: + logging.exception( + "Exception occured in testcase: {}".format(testcase_dict.get("name"))) break output_variables_list = config_dict.get("output", []) From b7eddf687d875a6ad1063d243499114c22c9e740 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 15 Nov 2017 21:49:41 +0800 Subject: [PATCH 339/354] disable InsecureRequestWarning --- httprunner/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/httprunner/client.py b/httprunner/client.py index 610f6bb33..9a0dcc296 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -4,11 +4,14 @@ import time import requests +import urllib3 from httprunner.exception import ParamsError from requests import Request, Response from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, RequestException) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + absolute_http_url_regexp = re.compile(r"^https?://", re.I) From e36f1e6d1cd0a03c6e3d0988f42f5138158b7f98 Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 16 Nov 2017 10:49:35 +0800 Subject: [PATCH 340/354] set request timeout default to 120 seconds --- httprunner/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/httprunner/client.py b/httprunner/client.py index 9a0dcc296..0da645667 100644 --- a/httprunner/client.py +++ b/httprunner/client.py @@ -114,6 +114,8 @@ def request(self, method, url, name=None, **kwargs): kwargs["auth"] = HttpNtlmAuth( auth_account["username"], auth_account["password"]) + kwargs.setdefault("timeout", 120) + response = self._send_request_safe_mode(method, url, **kwargs) request_meta["url"] = (response.history and response.history[0] or response)\ .request.path_url From 8c19212c6fa099569130cc8b3641ee0cf1a99e15 Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Dec 2017 12:08:55 +0800 Subject: [PATCH 341/354] support new validator format, e.g. {'eq': ['status_code', 200]} --- httprunner/__init__.py | 2 +- httprunner/response.py | 81 +++++++++++++++++---------- tests/data/demo_testset_hardcode.json | 6 ++ tests/data/demo_testset_hardcode.yml | 6 ++ 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 78bbd491e..d2825abd9 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.2' \ No newline at end of file +__version__ = '0.8.3' \ No newline at end of file diff --git a/httprunner/response.py b/httprunner/response.py index b59feae64..84beaca9d 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -124,50 +124,69 @@ def extract_response(self, extractors): return extracted_variables_mapping - def validate(self, validators, variables_mapping): - """ Bind named validators to value within the context. - @param (list) validators - [ - {"check": "status_code", "comparator": "eq", "expect": 201}, + def parse_validator(self, validator, variables_mapping): + """ parse validator, validator maybe in two format + @param (dict) validator + format1: this is kept for compatiblity with the previous versions. + {"check": "status_code", "comparator": "eq", "expect": 201} {"check": "resp_body_success", "comparator": "eq", "expect": True} - ] + format2: recommended new version + {'eq': ['status_code', 201]} + {'eq': ['resp_body_success', True]} @param (dict) variables_mapping { "resp_body_success": True } - @return (list) content differences - [ - { - "check": "status_code", - "comparator": "eq", "expect": 201, "value": 200 - } - ] + @return validator info + check_item, check_value, expect_value, comparator """ - for validator_dict in validators: + if not isinstance(validator, dict): + raise exception.ParamsError("invalid validator: {}".format(validator)) - check_item = validator_dict.get("check") - if not check_item: - raise exception.ParamsError("check item invalid: {}".format(check_item)) + if "check" in validator and len(validator) > 1: + # format1 + check_item = validator.get("check") - if "expect" in validator_dict: - expect_value = validator_dict.get("expect") - elif "expected" in validator_dict: - expect_value = validator_dict.get("expected") + if "expect" in validator: + expect_value = validator.get("expect") + elif "expected" in validator: + expect_value = validator.get("expected") else: - raise exception.ParamsError("expected value missed in testcase validator!") + raise exception.ParamsError("invalid validator: {}".format(validator)) - comparator = validator_dict.get("comparator", "eq") + comparator = validator.get("comparator", "eq") - if check_item in variables_mapping: - validator_dict["actual_value"] = variables_mapping[check_item] - else: - try: - validator_dict["actual_value"] = self.extract_field(check_item) - except exception.ParseResponseError: - raise exception.ParseResponseError("failed to extract check item in response!") + elif len(validator) == 1: + # format2 + comparator = list(validator.keys())[0] + compare_values = validator[comparator] + + if not isinstance(compare_values, list) or len(compare_values) != 2: + raise exception.ParamsError("invalid validator: {}".format(validator)) + + check_item, expect_value = compare_values + + else: + raise exception.ParamsError("invalid validator: {}".format(validator)) + + if check_item in variables_mapping: + check_value = variables_mapping[check_item] + else: + try: + check_value = self.extract_field(check_item) + except exception.ParseResponseError: + raise exception.ParseResponseError("failed to extract check item in response!") + + return check_item, check_value, expect_value, comparator + + def validate(self, validators, variables_mapping): + """ check validators with the context variable mapping. + """ + for validator in validators: + check_item, check_value, expect_value, comparator = self.parse_validator(validator, variables_mapping) utils.match_expected( - validator_dict["actual_value"], + check_value, expect_value, comparator, check_item diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index df7b220c9..d0ae92b7d 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -22,6 +22,8 @@ } ], "validate": [ + {"eq": ["status_code", 200]}, + {"len_eq": ["content.token", 16]}, {"check": "status_code", "comparator": "eq", "expect": 200}, {"check": "content.token", "comparator": "len_eq", "expect": 16} ] @@ -44,6 +46,8 @@ } }, "validate": [ + {"eq": ["status_code", 201]}, + {"eq": ["content.success", true]}, {"check": "status_code", "comparator": "eq", "expect": 201}, {"check": "content.success", "comparator": "eq", "expect": true} ] @@ -66,6 +70,8 @@ } }, "validate": [ + {"eq": ["status_code", 500]}, + {"eq": ["content.success", false]}, {"check": "status_code", "comparator": "eq", "expect": 500}, {"check": "content.success", "comparator": "eq", "expect": false} ] diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index fd869cb90..c22627ac5 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -14,6 +14,8 @@ extract: - token: content.token validate: + - eq: ["status_code", 200] + - len_eq: ["content.token", 16] - {"check": "status_code", "comparator": "eq", "expect": 200} - {"check": "content.token", "comparator": "len_eq", "expect": 16} @@ -30,6 +32,8 @@ name: "user1" password: "123456" validate: + - eq: ["status_code", 201] + - eq: ["content.success", True] - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "content.success", "comparator": "eq", "expect": true} @@ -46,5 +50,7 @@ name: "user1" password: "123456" validate: + - "eq": ["status_code", 500] + - "eq": ["content.success", false] - {"check": "status_code", "comparator": "eq", "expect": 500} - {"check": "content.success", "comparator": "eq", "expect": false} \ No newline at end of file From 155ad0167e29ac3a4f6763b7ef9c3fe2b7cb3d3d Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Dec 2017 20:06:54 +0800 Subject: [PATCH 342/354] #29: refactor validator, now support custom defined validators --- httprunner/__init__.py | 2 +- httprunner/built_in.py | 68 +++++++++++++++++++++++++ httprunner/context.py | 3 ++ httprunner/response.py | 56 +++++++++++++++----- httprunner/runner.py | 6 ++- httprunner/utils.py | 102 +++++++++++-------------------------- tests/test_response.py | 29 +++++++++-- tests/test_utils.py | 113 ++++++++++++++++++++++++----------------- 8 files changed, 242 insertions(+), 137 deletions(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index d2825abd9..d1091ea20 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.3' \ No newline at end of file +__version__ = '0.8.4' \ No newline at end of file diff --git a/httprunner/built_in.py b/httprunner/built_in.py index b1a5a32fe..e28f5efb2 100644 --- a/httprunner/built_in.py +++ b/httprunner/built_in.py @@ -4,10 +4,12 @@ import datetime import random +import re import string import time from httprunner.exception import ParamsError +from httprunner.utils import string_type def gen_random_string(str_len): @@ -33,3 +35,69 @@ def sleep(sec): """ sleep specified seconds """ time.sleep(sec) + + +""" built-in comparators +""" +def equals(check_value, expect_value): + assert check_value == expect_value + +def less_than(check_value, expect_value): + assert check_value < expect_value + +def less_than_or_equals(check_value, expect_value): + assert check_value <= expect_value + +def greater_than(check_value, expect_value): + assert check_value > expect_value + +def greater_than_or_equals(check_value, expect_value): + assert check_value >= expect_value + +def not_equals(check_value, expect_value): + assert check_value != expect_value + +def string_equals(check_value, expect_value): + assert str(check_value) == str(expect_value) + +def length_equals(check_value, expect_value): + assert isinstance(expect_value, int) + assert len(check_value) == expect_value + +def length_greater_than(check_value, expect_value): + assert isinstance(expect_value, int) + assert len(check_value) > expect_value + +def length_greater_than_or_equals(check_value, expect_value): + assert isinstance(expect_value, int) + assert len(check_value) >= expect_value + +def length_less_than(check_value, expect_value): + assert isinstance(expect_value, int) + assert len(check_value) < expect_value + +def length_less_than_or_equals(check_value, expect_value): + assert isinstance(expect_value, int) + assert len(check_value) <= expect_value + +def contains(check_value, expect_value): + assert isinstance(check_value, (list, tuple, dict, string_type)) + assert expect_value in check_value + +def contained_by(check_value, expect_value): + assert isinstance(expect_value, (list, tuple, dict, string_type)) + assert check_value in expect_value + +def type_match(check_value, expect_value): + assert isinstance(check_value, expect_value) + +def regex_match(check_value, expect_value): + assert isinstance(expect_value, string_type) + assert isinstance(check_value, string_type) + assert re.match(expect_value, check_value) + +def startswith(check_value, expect_value): + assert str(check_value).startswith(str(expect_value)) + +def endswith(check_value, expect_value): + assert str(check_value).endswith(str(expect_value)) diff --git a/httprunner/context.py b/httprunner/context.py index e9e5bd835..26f65c382 100644 --- a/httprunner/context.py +++ b/httprunner/context.py @@ -165,6 +165,9 @@ def get_parsed_request(self, request_dict, level="testcase"): def get_testcase_variables_mapping(self): return self.testcase_variables_mapping + def get_testcase_functions_mapping(self): + return self.testcase_functions_config + def exec_content_functions(self, content): """ execute functions in content. """ diff --git a/httprunner/response.py b/httprunner/response.py index 84beaca9d..586d9e67b 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -137,8 +137,13 @@ def parse_validator(self, validator, variables_mapping): { "resp_body_success": True } - @return validator info - check_item, check_value, expect_value, comparator + @return (dict) validator info + { + "check_item": check_item, + "check_value": check_value, + "expect_value": expect_value, + "comparator": comparator + } """ if not isinstance(validator, dict): raise exception.ParamsError("invalid validator: {}".format(validator)) @@ -177,19 +182,46 @@ def parse_validator(self, validator, variables_mapping): except exception.ParseResponseError: raise exception.ParseResponseError("failed to extract check item in response!") - return check_item, check_value, expect_value, comparator + validator_dict = { + "check_item": check_item, + "check_value": check_value, + "expect_value": expect_value, + "comparator": comparator + } + return validator_dict + + def do_validation(self, validator_dict, functions_mapping): + """ validate with functions + """ + comparator = utils.get_uniform_comparator(validator_dict["comparator"]) + + validate_func = functions_mapping.get(comparator) + if not validate_func: + raise exception.FunctionNotFound("comparator not found: {}".format(comparator)) - def validate(self, validators, variables_mapping): + check_item = validator_dict["check_item"] + check_value = validator_dict["check_value"] + expect_value = validator_dict["expect_value"] + + try: + if check_value is None or expect_value is None: + assert comparator in ["is", "eq", "equals", "=="] + + validate_func(validator_dict["check_value"], validator_dict["expect_value"]) + except (AssertionError, TypeError): + err_msg = "\n" + "\n".join([ + "\tcheck item name: %s;" % check_item, + "\tcheck item value: %s (%s);" % (check_value, type(check_value).__name__), + "\tcomparator: %s;" % comparator, + "\texpected value: %s (%s)." % (expect_value, type(expect_value).__name__) + ]) + raise exception.ValidationError(err_msg) + + def validate(self, validators, variables_mapping, functions_mapping): """ check validators with the context variable mapping. """ for validator in validators: - check_item, check_value, expect_value, comparator = self.parse_validator(validator, variables_mapping) - - utils.match_expected( - check_value, - expect_value, - comparator, - check_item - ) + validator_dict = self.parse_validator(validator, variables_mapping) + self.do_validation(validator_dict, functions_mapping) return True diff --git a/httprunner/runner.py b/httprunner/runner.py index 7e6fd927a..ad6bca776 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -129,7 +129,11 @@ def setup_teardown(actions): self.context.bind_extracted_variables(extracted_variables_mapping) try: - resp_obj.validate(validators, self.context.get_testcase_variables_mapping()) + resp_obj.validate( + validators, + self.context.get_testcase_variables_mapping(), + self.context.get_testcase_functions_mapping() + ) except (exception.ParamsError, exception.ResponseError, exception.ValidationError): err_msg = u"Exception occured.\n" err_msg += u"HTTP request url: {}\n".format(url) diff --git a/httprunner/utils.py b/httprunner/utils.py index 228490249..caf9c2b63 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -117,79 +117,37 @@ def query_json(json_content, query, delimiter='.'): return json_content -def match_expected(value, expected, comparator="eq", check_item=""): - """ check if value matches expected value. - @param value: actual value that get from response. - @param expected: expected result described in testcase - @param comparator: compare method - @param check_item: check item name +def get_uniform_comparator(comparator): + """ convert comparator alias to uniform name """ - try: - if value is None or expected is None: - assert comparator in ["is", "eq", "equals", "=="] - assert value is None - assert expected is None - - if comparator in ["eq", "equals", "=="]: - assert value == expected - elif comparator in ["lt", "less_than"]: - assert value < expected - elif comparator in ["le", "less_than_or_equals"]: - assert value <= expected - elif comparator in ["gt", "greater_than"]: - assert value > expected - elif comparator in ["ge", "greater_than_or_equals"]: - assert value >= expected - elif comparator in ["ne", "not_equals"]: - assert value != expected - elif comparator in ["str_eq", "string_equals"]: - assert str(value) == str(expected) - elif comparator in ["len_eq", "length_equals", "count_eq"]: - assert isinstance(expected, int) - assert len(value) == expected - elif comparator in ["len_gt", "count_gt", "length_greater_than", "count_greater_than"]: - assert isinstance(expected, int) - assert len(value) > expected - elif comparator in ["len_ge", "count_ge", "length_greater_than_or_equals", \ - "count_greater_than_or_equals"]: - assert isinstance(expected, int) - assert len(value) >= expected - elif comparator in ["len_lt", "count_lt", "length_less_than", "count_less_than"]: - assert isinstance(expected, int) - assert len(value) < expected - elif comparator in ["len_le", "count_le", "length_less_than_or_equals", \ - "count_less_than_or_equals"]: - assert isinstance(expected, int) - assert len(value) <= expected - elif comparator in ["contains"]: - assert isinstance(value, (list, tuple, dict, string_type)) - assert expected in value - elif comparator in ["contained_by"]: - assert isinstance(expected, (list, tuple, dict, string_type)) - assert value in expected - elif comparator in ["type"]: - assert isinstance(value, expected) - elif comparator in ["regex"]: - assert isinstance(expected, string_type) - assert isinstance(value, string_type) - assert re.match(expected, value) - elif comparator in ["startswith"]: - assert str(value).startswith(str(expected)) - elif comparator in ["endswith"]: - assert str(value).endswith(str(expected)) - else: - raise exception.ParamsError("comparator not supported!") - - return True - - except (AssertionError, TypeError): - err_msg = "\n".join([ - "check item name: %s;" % check_item, - "check item value: %s (%s);" % (value, type(value).__name__), - "comparator: %s;" % comparator, - "expected value: %s (%s)." % (expected, type(expected).__name__) - ]) - raise exception.ValidationError(err_msg) + if comparator in ["eq", "equals", "=="]: + return "equals" + elif comparator in ["lt", "less_than"]: + return "less_than" + elif comparator in ["le", "less_than_or_equals"]: + return "less_than_or_equals" + elif comparator in ["gt", "greater_than"]: + return "greater_than" + elif comparator in ["ge", "greater_than_or_equals"]: + return "greater_than_or_equals" + elif comparator in ["ne", "not_equals"]: + return "not_equals" + elif comparator in ["str_eq", "string_equals"]: + return "string_equals" + elif comparator in ["len_eq", "length_equals", "count_eq"]: + return "length_equals" + elif comparator in ["len_gt", "count_gt", "length_greater_than", "count_greater_than"]: + return "length_greater_than" + elif comparator in ["len_ge", "count_ge", "length_greater_than_or_equals", \ + "count_greater_than_or_equals"]: + return "length_greater_than_or_equals" + elif comparator in ["len_lt", "count_lt", "length_less_than", "count_less_than"]: + return "length_less_than" + elif comparator in ["len_le", "count_le", "length_less_than_or_equals", \ + "count_less_than_or_equals"]: + return "length_less_than_or_equals" + else: + return comparator def deep_update_dict(origin_dict, override_dict): """ update origin dict with override dict recursively diff --git a/tests/test_response.py b/tests/test_response.py index 3dd79ccd0..8fd97b047 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,9 +1,14 @@ import requests -from httprunner import response, exception +from httprunner import exception, response, utils from tests.base import ApiServerUnittest + class TestResponse(ApiServerUnittest): + def setUp(self): + imported_module = utils.get_imported_module("httprunner.built_in") + self.functions_mapping = utils.filter_module(imported_module, "function") + def test_parse_response_object_json(self): url = "http://127.0.0.1:5000/api/users" resp = requests.get(url) @@ -226,6 +231,20 @@ def test_extract_response_empty(self): with self.assertRaises(exception.ParamsError): resp_obj.extract_response(extract_binds_list) + def test_do_validation(self): + url = "http://127.0.0.1:5000/" + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + + resp_obj.do_validation( + {"check_item": "check_item", "check_value": 1, "expect_value": 1, "comparator": "eq"}, + self.functions_mapping + ) + resp_obj.do_validation( + {"check_item": "check_item", "check_value": "abc", "expect_value": "abc", "comparator": "=="}, + self.functions_mapping + ) + def test_validate(self): url = "http://127.0.0.1:5000/" resp = requests.get(url) @@ -241,7 +260,7 @@ def test_validate(self): } with self.assertRaises(exception.ValidationError): - resp_obj.validate(validators, variables_mapping) + resp_obj.validate(validators, variables_mapping, self.functions_mapping) validators = [ {"check": "resp_status_code", "comparator": "eq", "expect": 201}, @@ -252,7 +271,7 @@ def test_validate(self): "resp_body_success": True } - self.assertTrue(resp_obj.validate(validators, variables_mapping)) + self.assertTrue(resp_obj.validate(validators, variables_mapping, self.functions_mapping)) def test_validate_exception(self): url = "http://127.0.0.1:5000/" @@ -266,7 +285,7 @@ def test_validate_exception(self): ] variables_mapping = {} with self.assertRaises(exception.ValidationError): - resp_obj.validate(validators, variables_mapping) + resp_obj.validate(validators, variables_mapping, self.functions_mapping) # expected value missed in variables mapping validators = [ @@ -277,4 +296,4 @@ def test_validate_exception(self): "resp_status_code": 200 } with self.assertRaises(exception.ValidationError): - resp_obj.validate(validators, variables_mapping) + resp_obj.validate(validators, variables_mapping, self.functions_mapping) diff --git a/tests/test_utils.py b/tests/test_utils.py index f68ff4b2d..7007c369d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -94,52 +94,73 @@ def test_query_json_content_is_text(self): with self.assertRaises(exception.ParseResponseError): utils.query_json(json_content, query) - def test_match_expected(self): - self.assertTrue(utils.match_expected(1, 1, "eq")) - self.assertTrue(utils.match_expected("abc", "abc", "==")) - self.assertTrue(utils.match_expected("abc", "abc")) - - with self.assertRaises(exception.ValidationError): - utils.match_expected(123, "123", "eq") - with self.assertRaises(exception.ValidationError): - utils.match_expected(123, "123") - - self.assertTrue(utils.match_expected(1, 2, "lt")) - self.assertTrue(utils.match_expected(1, 1, "le")) - self.assertTrue(utils.match_expected(2, 1, "gt")) - self.assertTrue(utils.match_expected(1, 1, "ge")) - self.assertTrue(utils.match_expected(123, "123", "ne")) - - self.assertTrue(utils.match_expected("123", 3, "len_eq")) - self.assertTrue(utils.match_expected("123", 2, "len_gt")) - self.assertTrue(utils.match_expected("123", 3, "len_ge")) - self.assertTrue(utils.match_expected("123", 4, "len_lt")) - self.assertTrue(utils.match_expected("123", 3, "len_le")) - - self.assertTrue(utils.match_expected("123abc456", "3ab", "contains")) - self.assertTrue(utils.match_expected(['1', '2'], "1", "contains")) - self.assertTrue(utils.match_expected({'a':1, 'b':2}, "a", "contains")) - self.assertTrue(utils.match_expected("3ab", "123abc456", "contained_by")) - - self.assertTrue(utils.match_expected("123abc456", "^123\w+456$", "regex")) - with self.assertRaises(exception.ValidationError): - utils.match_expected("123abc456", "^12b.*456$", "regex") - - with self.assertRaises(exception.ParamsError): - utils.match_expected(1, 2, "not_supported_comparator") - - self.assertTrue(utils.match_expected("abc123", "ab", "startswith")) - self.assertTrue(utils.match_expected("123abc", 12, "startswith")) - self.assertTrue(utils.match_expected(12345, 123, "startswith")) - self.assertTrue(utils.match_expected("abc123", 23, "endswith")) - self.assertTrue(utils.match_expected("123abc", "abc", "endswith")) - self.assertTrue(utils.match_expected(12345, 45, "endswith")) - - self.assertTrue(utils.match_expected(None, None, "eq")) - with self.assertRaises(exception.ValidationError): - utils.match_expected(None, 3, "len_eq") - with self.assertRaises(exception.ValidationError): - utils.match_expected("abc", None, "gt") + def test_get_uniform_comparator(self): + self.assertEqual(utils.get_uniform_comparator("eq"), "equals") + self.assertEqual(utils.get_uniform_comparator("=="), "equals") + self.assertEqual(utils.get_uniform_comparator("lt"), "less_than") + self.assertEqual(utils.get_uniform_comparator("le"), "less_than_or_equals") + self.assertEqual(utils.get_uniform_comparator("gt"), "greater_than") + self.assertEqual(utils.get_uniform_comparator("ge"), "greater_than_or_equals") + self.assertEqual(utils.get_uniform_comparator("ne"), "not_equals") + + self.assertEqual(utils.get_uniform_comparator("str_eq"), "string_equals") + self.assertEqual(utils.get_uniform_comparator("len_eq"), "length_equals") + self.assertEqual(utils.get_uniform_comparator("count_eq"), "length_equals") + + self.assertEqual(utils.get_uniform_comparator("len_gt"), "length_greater_than") + self.assertEqual(utils.get_uniform_comparator("count_gt"), "length_greater_than") + self.assertEqual(utils.get_uniform_comparator("count_greater_than"), "length_greater_than") + + self.assertEqual(utils.get_uniform_comparator("len_ge"), "length_greater_than_or_equals") + self.assertEqual(utils.get_uniform_comparator("count_ge"), "length_greater_than_or_equals") + self.assertEqual(utils.get_uniform_comparator("count_greater_than_or_equals"), "length_greater_than_or_equals") + + self.assertEqual(utils.get_uniform_comparator("len_lt"), "length_less_than") + self.assertEqual(utils.get_uniform_comparator("count_lt"), "length_less_than") + self.assertEqual(utils.get_uniform_comparator("count_less_than"), "length_less_than") + + self.assertEqual(utils.get_uniform_comparator("len_le"), "length_less_than_or_equals") + self.assertEqual(utils.get_uniform_comparator("count_le"), "length_less_than_or_equals") + self.assertEqual(utils.get_uniform_comparator("count_less_than_or_equals"), "length_less_than_or_equals") + + def test_validators(self): + imported_module = utils.get_imported_module("httprunner.built_in") + functions_mapping = utils.filter_module(imported_module, "function") + + functions_mapping["equals"](None, None) + functions_mapping["equals"](1, 1) + functions_mapping["equals"]("abc", "abc") + with self.assertRaises(AssertionError): + functions_mapping["equals"]("123", 123) + + functions_mapping["less_than"](1, 2) + functions_mapping["less_than_or_equals"](2, 2) + + functions_mapping["greater_than"](2, 1) + functions_mapping["greater_than_or_equals"](2, 2) + + functions_mapping["not_equals"](123, "123") + + functions_mapping["length_equals"]("123", 3) + functions_mapping["length_greater_than"]("123", 2) + functions_mapping["length_greater_than_or_equals"]("123", 3) + + functions_mapping["contains"]("123abc456", "3ab") + functions_mapping["contains"](['1', '2'], "1") + functions_mapping["contains"]({'a':1, 'b':2}, "a") + functions_mapping["contained_by"]("3ab", "123abc456") + + functions_mapping["regex_match"]("123abc456", "^123\w+456$") + with self.assertRaises(AssertionError): + functions_mapping["regex_match"]("123abc456", "^12b.*456$") + + functions_mapping["startswith"]("abc123", "ab") + functions_mapping["startswith"]("123abc", 12) + functions_mapping["startswith"](12345, 123) + + functions_mapping["endswith"]("abc123", 23) + functions_mapping["endswith"]("123abc", "abc") + functions_mapping["endswith"](12345, 45) def test_deep_update_dict(self): origin_dict = {'a': 1, 'b': {'c': 3, 'd': 4}, 'f': 6} From 0e1ef7f78ff327e05fb78098590405b9dcf5040b Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Dec 2017 20:14:59 +0800 Subject: [PATCH 343/354] update docs for validator --- docs/write-testcases.rst | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/write-testcases.rst b/docs/write-testcases.rst index c3ea5dced..e184611b8 100644 --- a/docs/write-testcases.rst +++ b/docs/write-testcases.rst @@ -40,8 +40,8 @@ And here is testset example of typical scenario: get `token` at the beginning, a extract: - token: content.token validate: - - {"check": "status_code", "comparator": "eq", "expect": 200} - - {"check": "content.token", "comparator": "len_eq", "expect": 16} + - eq: ["status_code", 200] + - len_eq: ["content.token", 16] - test: name: create user which does not exist @@ -54,8 +54,8 @@ And here is testset example of typical scenario: get `token` at the beginning, a name: "user1" password: "123456" validate: - - {"check": "status_code", "comparator": "eq", "expect": 201} - - {"check": "content.success", "comparator": "eq", "expect": true} + - eq: ["status_code", 201] + - eq: ["content.success", true] Function invoke is supported in `YAML/JSON` format testcases, such as `gen_random_string` and `get_sign` above. This mechanism relies on the `debugtak.py` hot plugin, with which we can define functions in `debugtak.py` file, and then functions can be auto discovered and invoked in runtime. @@ -105,9 +105,9 @@ Comparator | ``contained_by`` | contained by | A in B | | 'a' contained_by 'abc' | | | | | | 1 contained_by [1,2] | +---------------------------+---------------------------+-------------------------+--------------------------+ -| ``type`` | A is instance of B | isinstance(A, B) | 123 type 'int' | +| ``type_match`` | A is instance of B | isinstance(A, B) | 123 type_match 'int' | +---------------------------+---------------------------+-------------------------+--------------------------+ -| ``regex`` | regex matches | re.match(B, A) | 'abcdef' regex 'a\w+d' | +| ``regex_match`` | regex matches | re.match(B, A) | 'abcdef' regex_match 'a\w+d' | +---------------------------+---------------------------+-------------------------+--------------------------+ | ``startswith`` | starts with | A.startswith(B) is True | 'abc' startswith 'ab' | +---------------------------+---------------------------+-------------------------+--------------------------+ @@ -171,12 +171,11 @@ There might be slight difference on list, cos we can use index to locate list it - content_type: headers.content-type - first_name: content.person.name.first_name validate: - - {"check": "status_code", "comparator": "eq", "expect": 200} - - {"check": "headers.content-type", "expect": "application/json"} - - {"check": "headers.content-length", "comparator": "gt", "expect": 40} - - {"check": "content.success", "comparator": "eq", "expect": True} - - {"check": "content.token", "comparator": "len_eq", "expect": 16} - + - eq: ["status_code", 200] + - eq: ["headers.content-type", "application/json"] + - gt: ["headers.content-length", 40] + - eq: ["content.success", true] + - len_eq: ["content.token", 16] .. _QuickStart: http:// \ No newline at end of file From 6ac43e39eaee03aaef796e407762c9f83c5e4b5c Mon Sep 17 00:00:00 2001 From: httprunner Date: Tue, 12 Dec 2017 23:59:54 +0800 Subject: [PATCH 344/354] #29: refactor validator: 1, relocate validate functions; 2, add unittest for custom defined validators. --- httprunner/context.py | 45 ++++++++++++++-- httprunner/response.py | 36 ------------- httprunner/runner.py | 6 +-- tests/data/debugtalk.py | 10 ++++ tests/data/demo_testset_hardcode.json | 3 ++ tests/data/demo_testset_hardcode.yml | 6 ++- tests/test_context.py | 77 +++++++++++++++++++++++++-- tests/test_response.py | 67 ----------------------- tests/test_runner.py | 6 +++ 9 files changed, 139 insertions(+), 117 deletions(-) diff --git a/httprunner/context.py b/httprunner/context.py index 26f65c382..8d2cefe07 100644 --- a/httprunner/context.py +++ b/httprunner/context.py @@ -4,7 +4,7 @@ import sys from collections import OrderedDict -from httprunner import utils +from httprunner import exception, utils from httprunner.testcase import TestcaseParser @@ -165,10 +165,47 @@ def get_parsed_request(self, request_dict, level="testcase"): def get_testcase_variables_mapping(self): return self.testcase_variables_mapping - def get_testcase_functions_mapping(self): - return self.testcase_functions_config - def exec_content_functions(self, content): """ execute functions in content. """ self.testcase_parser.eval_content_functions(content) + + def do_validation(self, validator_dict): + """ validate with functions + """ + comparator = utils.get_uniform_comparator(validator_dict["comparator"]) + validate_func = self.testcase_parser.get_bind_item("function", comparator) + + if not validate_func: + raise exception.FunctionNotFound("comparator not found: {}".format(comparator)) + + check_item = validator_dict["check_item"] + check_value = validator_dict["check_value"] + expect_value = validator_dict["expect_value"] + + try: + if check_value is None or expect_value is None: + assert comparator in ["is", "eq", "equals", "=="] + + validate_func(validator_dict["check_value"], validator_dict["expect_value"]) + except (AssertionError, TypeError): + err_msg = "\n" + "\n".join([ + "\tcheck item name: %s;" % check_item, + "\tcheck item value: %s (%s);" % (check_value, type(check_value).__name__), + "\tcomparator: %s;" % comparator, + "\texpected value: %s (%s)." % (expect_value, type(expect_value).__name__) + ]) + raise exception.ValidationError(err_msg) + + def validate(self, validators, resp_obj): + """ check validators with the context variable mapping. + @param (list) validators + @param (object) resp_obj + """ + variables_mapping = self.get_testcase_variables_mapping() + + for validator in validators: + validator_dict = resp_obj.parse_validator(validator, variables_mapping) + self.do_validation(validator_dict) + + return True diff --git a/httprunner/response.py b/httprunner/response.py index 586d9e67b..b6ed2a6d2 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -189,39 +189,3 @@ def parse_validator(self, validator, variables_mapping): "comparator": comparator } return validator_dict - - def do_validation(self, validator_dict, functions_mapping): - """ validate with functions - """ - comparator = utils.get_uniform_comparator(validator_dict["comparator"]) - - validate_func = functions_mapping.get(comparator) - if not validate_func: - raise exception.FunctionNotFound("comparator not found: {}".format(comparator)) - - check_item = validator_dict["check_item"] - check_value = validator_dict["check_value"] - expect_value = validator_dict["expect_value"] - - try: - if check_value is None or expect_value is None: - assert comparator in ["is", "eq", "equals", "=="] - - validate_func(validator_dict["check_value"], validator_dict["expect_value"]) - except (AssertionError, TypeError): - err_msg = "\n" + "\n".join([ - "\tcheck item name: %s;" % check_item, - "\tcheck item value: %s (%s);" % (check_value, type(check_value).__name__), - "\tcomparator: %s;" % comparator, - "\texpected value: %s (%s)." % (expect_value, type(expect_value).__name__) - ]) - raise exception.ValidationError(err_msg) - - def validate(self, validators, variables_mapping, functions_mapping): - """ check validators with the context variable mapping. - """ - for validator in validators: - validator_dict = self.parse_validator(validator, variables_mapping) - self.do_validation(validator_dict, functions_mapping) - - return True diff --git a/httprunner/runner.py b/httprunner/runner.py index ad6bca776..603da4e3c 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -129,11 +129,7 @@ def setup_teardown(actions): self.context.bind_extracted_variables(extracted_variables_mapping) try: - resp_obj.validate( - validators, - self.context.get_testcase_variables_mapping(), - self.context.get_testcase_functions_mapping() - ) + self.context.validate(validators, resp_obj) except (exception.ParamsError, exception.ResponseError, exception.ValidationError): err_msg = u"Exception occured.\n" err_msg += u"HTTP request url: {}\n".format(url) diff --git a/tests/data/debugtalk.py b/tests/data/debugtalk.py index 491e41036..1c5d1889f 100644 --- a/tests/data/debugtalk.py +++ b/tests/data/debugtalk.py @@ -30,3 +30,13 @@ def get_sign(*args): def gen_md5(*args): return hashlib.md5("".join(args).encode('utf-8')).hexdigest() + +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 diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index d0ae92b7d..1dbd8c942 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -25,6 +25,7 @@ {"eq": ["status_code", 200]}, {"len_eq": ["content.token", 16]}, {"check": "status_code", "comparator": "eq", "expect": 200}, + {"sum_status_code": ["status_code", 2]}, {"check": "content.token", "comparator": "len_eq", "expect": 16} ] } @@ -49,6 +50,7 @@ {"eq": ["status_code", 201]}, {"eq": ["content.success", true]}, {"check": "status_code", "comparator": "eq", "expect": 201}, + {"sum_status_code": ["status_code", 3]}, {"check": "content.success", "comparator": "eq", "expect": true} ] } @@ -73,6 +75,7 @@ {"eq": ["status_code", 500]}, {"eq": ["content.success", false]}, {"check": "status_code", "comparator": "eq", "expect": 500}, + {"sum_status_code": ["status_code", 5]}, {"check": "content.success", "comparator": "eq", "expect": false} ] } diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index c22627ac5..d16543de2 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -15,9 +15,11 @@ - token: content.token validate: - eq: ["status_code", 200] + - len_eq: ["token", 16] - len_eq: ["content.token", 16] - {"check": "status_code", "comparator": "eq", "expect": 200} - - {"check": "content.token", "comparator": "len_eq", "expect": 16} + - sum_status_code: ["status_code", 2] + - {"check": "token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -33,6 +35,7 @@ password: "123456" validate: - eq: ["status_code", 201] + - sum_status_code: ["status_code", 3] - eq: ["content.success", True] - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "content.success", "comparator": "eq", "expect": true} @@ -51,6 +54,7 @@ password: "123456" validate: - "eq": ["status_code", 500] + - sum_status_code: ["status_code", 5] - "eq": ["content.success", false] - {"check": "status_code", "comparator": "eq", "expect": 500} - {"check": "content.success", "comparator": "eq", "expect": false} \ No newline at end of file diff --git a/tests/test_context.py b/tests/test_context.py index 413654d3d..a75051b49 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,13 +1,13 @@ import os import time -import unittest -from httprunner import runner, testcase, utils +import requests +from httprunner import exception, response, runner, testcase, utils from httprunner.context import Context -from httprunner.exception import ParamsError +from tests.base import ApiServerUnittest -class VariableBindsUnittest(unittest.TestCase): +class VariableBindsUnittest(ApiServerUnittest): def setUp(self): self.context = Context() @@ -217,3 +217,72 @@ def test_exec_content_functions(self): end_time = time.time() elapsed_time = end_time - start_time self.assertGreater(elapsed_time, 1) + + def test_do_validation(self): + self.context.do_validation( + {"check_item": "check_item", "check_value": 1, "expect_value": 1, "comparator": "eq"} + ) + self.context.do_validation( + {"check_item": "check_item", "check_value": "abc", "expect_value": "abc", "comparator": "=="} + ) + + config_dict = { + "path": 'tests/data/demo_testset_hardcode.yml' + } + self.context.config_context(config_dict, "testset") + self.context.do_validation( + {"check_item": "status_code", "check_value": "201", "expect_value": 3, "comparator": "sum_status_code"} + ) + + def test_validate(self): + url = "http://127.0.0.1:5000/" + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + + validators = [ + {"eq": ["resp_status_code", 201]}, + {"check": "resp_status_code", "comparator": "eq", "expect": 201}, + {"check": "resp_body_success", "comparator": "eq", "expect": True} + ] + variables = [ + {"resp_status_code": 200}, + {"resp_body_success": True} + ] + self.context.bind_variables(variables) + + with self.assertRaises(exception.ValidationError): + self.context.validate(validators, resp_obj) + + variables = [ + {"resp_status_code": 201}, + {"resp_body_success": True} + ] + self.context.bind_variables(variables) + + self.assertTrue(self.context.validate(validators, resp_obj)) + + def test_validate_exception(self): + url = "http://127.0.0.1:5000/" + resp = requests.get(url) + resp_obj = response.ResponseObject(resp) + + # expected value missed in validators + validators = [ + {"eq": ["resp_status_code", 201]}, + {"check": "resp_status_code", "comparator": "eq", "expect": 201}, + {"check": "resp_body_success", "comparator": "eq", "expect": True} + ] + variables = [] + self.context.bind_variables(variables) + + with self.assertRaises(exception.ParamsError): + self.context.validate(validators, resp_obj) + + # expected value missed in variables mapping + variables = [ + {"resp_status_code": 200} + ] + self.context.bind_variables(variables) + + with self.assertRaises(exception.ValidationError): + self.context.validate(validators, resp_obj) diff --git a/tests/test_response.py b/tests/test_response.py index 8fd97b047..cfa587895 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -230,70 +230,3 @@ def test_extract_response_empty(self): resp_obj = response.ResponseObject(resp) with self.assertRaises(exception.ParamsError): resp_obj.extract_response(extract_binds_list) - - def test_do_validation(self): - url = "http://127.0.0.1:5000/" - resp = requests.get(url) - resp_obj = response.ResponseObject(resp) - - resp_obj.do_validation( - {"check_item": "check_item", "check_value": 1, "expect_value": 1, "comparator": "eq"}, - self.functions_mapping - ) - resp_obj.do_validation( - {"check_item": "check_item", "check_value": "abc", "expect_value": "abc", "comparator": "=="}, - self.functions_mapping - ) - - def test_validate(self): - url = "http://127.0.0.1:5000/" - resp = requests.get(url) - resp_obj = response.ResponseObject(resp) - - validators = [ - {"check": "resp_status_code", "comparator": "eq", "expect": 201}, - {"check": "resp_body_success", "comparator": "eq", "expect": True} - ] - variables_mapping = { - "resp_status_code": 200, - "resp_body_success": True - } - - with self.assertRaises(exception.ValidationError): - resp_obj.validate(validators, variables_mapping, self.functions_mapping) - - validators = [ - {"check": "resp_status_code", "comparator": "eq", "expect": 201}, - {"check": "resp_body_success", "comparator": "eq", "expect": True} - ] - variables_mapping = { - "resp_status_code": 201, - "resp_body_success": True - } - - self.assertTrue(resp_obj.validate(validators, variables_mapping, self.functions_mapping)) - - def test_validate_exception(self): - url = "http://127.0.0.1:5000/" - resp = requests.get(url) - resp_obj = response.ResponseObject(resp) - - # expected value missed in validators - validators = [ - {"check": "status_code", "comparator": "eq", "expect": 201}, - {"check": "body_success", "comparator": "eq"} - ] - variables_mapping = {} - with self.assertRaises(exception.ValidationError): - resp_obj.validate(validators, variables_mapping, self.functions_mapping) - - # expected value missed in variables mapping - validators = [ - {"check": "resp_status_code", "comparator": "eq", "expect": 201}, - {"check": "body_success", "comparator": "eq"} - ] - variables_mapping = { - "resp_status_code": 200 - } - with self.assertRaises(exception.ValidationError): - resp_obj.validate(validators, variables_mapping, self.functions_mapping) diff --git a/tests/test_runner.py b/tests/test_runner.py index 6c45df2df..3382ee23c 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -27,6 +27,12 @@ def reset_all(self): def test_run_single_testcase(self): for testcase_file_path in self.testcase_file_path_list: testcases = testcase._load_file(testcase_file_path) + + config_dict = { + "path": testcase_file_path + } + self.test_runner.init_config(config_dict, "testset") + test = testcases[0]["test"] self.assertTrue(self.test_runner._run_test(test)) From ca9f793fb359deb4f405b38eafc2466373afc81f Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 13 Dec 2017 01:12:57 +0800 Subject: [PATCH 345/354] validator: check item support reference variable --- httprunner/__init__.py | 2 +- httprunner/context.py | 78 +++++++++++++++++++++++++--- httprunner/response.py | 68 +----------------------- tests/data/demo_testset_hardcode.yml | 9 ++-- tests/test_context.py | 12 ++--- 5 files changed, 86 insertions(+), 83 deletions(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index d1091ea20..db07b46cd 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.4' \ No newline at end of file +__version__ = '0.8.5' \ No newline at end of file diff --git a/httprunner/context.py b/httprunner/context.py index 8d2cefe07..552517cca 100644 --- a/httprunner/context.py +++ b/httprunner/context.py @@ -4,8 +4,7 @@ import sys from collections import OrderedDict -from httprunner import exception, utils -from httprunner.testcase import TestcaseParser +from httprunner import exception, testcase, utils class Context(object): @@ -15,7 +14,7 @@ class Context(object): def __init__(self): self.testset_shared_variables_mapping = OrderedDict() self.testcase_variables_mapping = OrderedDict() - self.testcase_parser = TestcaseParser() + self.testcase_parser = testcase.TestcaseParser() self.init_context() def init_context(self, level='testset'): @@ -170,6 +169,75 @@ def exec_content_functions(self, content): """ self.testcase_parser.eval_content_functions(content) + def parse_validator(self, validator, resp_obj): + """ parse validator, validator maybe in two format + @param (dict) validator + format1: this is kept for compatiblity with the previous versions. + {"check": "status_code", "comparator": "eq", "expect": 201} + {"check": "$resp_body_success", "comparator": "eq", "expect": True} + format2: recommended new version + {'eq': ['status_code', 201]} + {'eq': ['$resp_body_success', True]} + @param (object) resp_obj + @return (dict) validator info + { + "check_item": check_item, + "check_value": check_value, + "expect_value": expect_value, + "comparator": comparator + } + """ + if not isinstance(validator, dict): + raise exception.ParamsError("invalid validator: {}".format(validator)) + + if "check" in validator and len(validator) > 1: + # format1 + check_item = validator.get("check") + + if "expect" in validator: + expect_value = validator.get("expect") + elif "expected" in validator: + expect_value = validator.get("expected") + else: + raise exception.ParamsError("invalid validator: {}".format(validator)) + + comparator = validator.get("comparator", "eq") + + elif len(validator) == 1: + # format2 + comparator = list(validator.keys())[0] + compare_values = validator[comparator] + + if not isinstance(compare_values, list) or len(compare_values) != 2: + raise exception.ParamsError("invalid validator: {}".format(validator)) + + check_item, expect_value = compare_values + + else: + raise exception.ParamsError("invalid validator: {}".format(validator)) + + # check_item should only be in 3 type: + # 1, variable reference, e.g. $token + # 2, string joined by delimiter. e.g. "status_code", "headers.content-type" + # 3, regex string, e.g. "LB[\d]*(.*)RB[\d]*" + if testcase.extract_variables(check_item): + # type 1 + check_value = self.testcase_parser.eval_content_variables(check_item) + else: + try: + # type 2 or type 3 + check_value = resp_obj.extract_field(check_item) + except exception.ParseResponseError: + raise exception.ParseResponseError("failed to extract check item in response!") + + validator_dict = { + "check_item": check_item, + "check_value": check_value, + "expect_value": expect_value, + "comparator": comparator + } + return validator_dict + def do_validation(self, validator_dict): """ validate with functions """ @@ -202,10 +270,8 @@ def validate(self, validators, resp_obj): @param (list) validators @param (object) resp_obj """ - variables_mapping = self.get_testcase_variables_mapping() - for validator in validators: - validator_dict = resp_obj.parse_validator(validator, variables_mapping) + validator_dict = self.parse_validator(validator, resp_obj) self.do_validation(validator_dict) return True diff --git a/httprunner/response.py b/httprunner/response.py index b6ed2a6d2..0d8d5564b 100644 --- a/httprunner/response.py +++ b/httprunner/response.py @@ -2,7 +2,7 @@ import re from collections import OrderedDict -from httprunner import exception, utils +from httprunner import exception, utils, testcase from requests.structures import CaseInsensitiveDict text_extractor_regexp_compile = re.compile(r".*\(.*\).*") @@ -123,69 +123,3 @@ def extract_response(self, extractors): extracted_variables_mapping[key] = self.extract_field(field) return extracted_variables_mapping - - def parse_validator(self, validator, variables_mapping): - """ parse validator, validator maybe in two format - @param (dict) validator - format1: this is kept for compatiblity with the previous versions. - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "resp_body_success", "comparator": "eq", "expect": True} - format2: recommended new version - {'eq': ['status_code', 201]} - {'eq': ['resp_body_success', True]} - @param (dict) variables_mapping - { - "resp_body_success": True - } - @return (dict) validator info - { - "check_item": check_item, - "check_value": check_value, - "expect_value": expect_value, - "comparator": comparator - } - """ - if not isinstance(validator, dict): - raise exception.ParamsError("invalid validator: {}".format(validator)) - - if "check" in validator and len(validator) > 1: - # format1 - check_item = validator.get("check") - - if "expect" in validator: - expect_value = validator.get("expect") - elif "expected" in validator: - expect_value = validator.get("expected") - else: - raise exception.ParamsError("invalid validator: {}".format(validator)) - - comparator = validator.get("comparator", "eq") - - elif len(validator) == 1: - # format2 - comparator = list(validator.keys())[0] - compare_values = validator[comparator] - - if not isinstance(compare_values, list) or len(compare_values) != 2: - raise exception.ParamsError("invalid validator: {}".format(validator)) - - check_item, expect_value = compare_values - - else: - raise exception.ParamsError("invalid validator: {}".format(validator)) - - if check_item in variables_mapping: - check_value = variables_mapping[check_item] - else: - try: - check_value = self.extract_field(check_item) - except exception.ParseResponseError: - raise exception.ParseResponseError("failed to extract check item in response!") - - validator_dict = { - "check_item": check_item, - "check_value": check_value, - "expect_value": expect_value, - "comparator": comparator - } - return validator_dict diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index d16543de2..336df6783 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -15,11 +15,11 @@ - token: content.token validate: - eq: ["status_code", 200] - - len_eq: ["token", 16] + - len_eq: ["$token", 16] - len_eq: ["content.token", 16] - {"check": "status_code", "comparator": "eq", "expect": 200} - sum_status_code: ["status_code", 2] - - {"check": "token", "comparator": "len_eq", "expect": 16} + - {"check": "$token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist @@ -33,10 +33,13 @@ json: name: "user1" password: "123456" + extract: + - success: content.success validate: - eq: ["status_code", 201] - sum_status_code: ["status_code", 3] - - eq: ["content.success", True] + - eq: ["$success", True] + - eq: ["abc$success", "abcTrue"] - {"check": "status_code", "comparator": "eq", "expect": 201} - {"check": "content.success", "comparator": "eq", "expect": true} diff --git a/tests/test_context.py b/tests/test_context.py index a75051b49..b4f88a068 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -240,9 +240,9 @@ def test_validate(self): resp_obj = response.ResponseObject(resp) validators = [ - {"eq": ["resp_status_code", 201]}, - {"check": "resp_status_code", "comparator": "eq", "expect": 201}, - {"check": "resp_body_success", "comparator": "eq", "expect": True} + {"eq": ["$resp_status_code", 201]}, + {"check": "$resp_status_code", "comparator": "eq", "expect": 201}, + {"check": "$resp_body_success", "comparator": "eq", "expect": True} ] variables = [ {"resp_status_code": 200}, @@ -268,9 +268,9 @@ def test_validate_exception(self): # expected value missed in validators validators = [ - {"eq": ["resp_status_code", 201]}, - {"check": "resp_status_code", "comparator": "eq", "expect": 201}, - {"check": "resp_body_success", "comparator": "eq", "expect": True} + {"eq": ["$resp_status_code", 201]}, + {"check": "$resp_status_code", "comparator": "eq", "expect": 201}, + {"check": "$resp_body_success", "comparator": "eq", "expect": True} ] variables = [] self.context.bind_variables(variables) From 7a45b4078bcb138a5dd2551a73addf8aab5e4acc Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 13 Dec 2017 01:22:43 +0800 Subject: [PATCH 346/354] #52: validator expect field support reference variable --- httprunner/context.py | 2 ++ tests/data/demo_testset_hardcode.yml | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/httprunner/context.py b/httprunner/context.py index 552517cca..ac9b5b3c5 100644 --- a/httprunner/context.py +++ b/httprunner/context.py @@ -230,6 +230,8 @@ def parse_validator(self, validator, resp_obj): except exception.ParseResponseError: raise exception.ParseResponseError("failed to extract check item in response!") + expect_value = self.testcase_parser.eval_content_variables(expect_value) + validator_dict = { "check_item": check_item, "check_value": check_value, diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index 336df6783..1a7b17582 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -11,11 +11,14 @@ app_version: '2.8.6' json: sign: f1219719911caae89ccc301679857ebfda115ca2 + variables: + - expect_status_code: 200 + - token_len: 16 extract: - token: content.token validate: - - eq: ["status_code", 200] - - len_eq: ["$token", 16] + - eq: ["status_code", $expect_status_code] + - len_eq: ["$token", $token_len] - len_eq: ["content.token", 16] - {"check": "status_code", "comparator": "eq", "expect": 200} - sum_status_code: ["status_code", 2] From 184d515608615e6675cf3f7f95f10f54b1f94624 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 13 Dec 2017 14:17:55 +0800 Subject: [PATCH 347/354] check conflict parameter args: --full-speed --no-web --- httprunner/cli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/httprunner/cli.py b/httprunner/cli.py index b4ca4e0b9..935d1f1d9 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -103,7 +103,7 @@ def main_locust(): except ImportError: msg = "Locust is not installed, install first and try again.\n" msg += "install command: pip install locustio" - print(msg) + logging.info(msg) exit(1) sys.argv[0] = 'locust' @@ -118,13 +118,18 @@ def main_locust(): testcase_index = sys.argv.index('-f') + 1 assert testcase_index < len(sys.argv) except (ValueError, AssertionError): - print("Testcase file is not specified, exit.") + logging.error("Testcase file is not specified, exit.") sys.exit(1) testcase_file_path = sys.argv[testcase_index] sys.argv[testcase_index] = locusts.parse_locustfile(testcase_file_path) if "--full-speed" in sys.argv: + + if "--no-web" in sys.argv: + logging.warning("conflict parameter args: --full-speed --no-web. \nexit.") + sys.exit(1) + locusts.run_locusts_at_full_speed(sys.argv) else: locusts.main() From 9401719a1f43840bf6777ca006824a047fa3656f Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 13 Dec 2017 15:42:37 +0800 Subject: [PATCH 348/354] fix docs: comparator table --- docs/write-testcases.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/write-testcases.rst b/docs/write-testcases.rst index e184611b8..872f4d6ff 100644 --- a/docs/write-testcases.rst +++ b/docs/write-testcases.rst @@ -68,7 +68,7 @@ Comparator ``HttpRunner`` currently supports the following comparators. +---------------------------+---------------------------+-------------------------+--------------------------+ -| comparator | Description | A(check), B(expect) | examples | +| comparator | Description | A(check), B(expect) | examples | +===========================+===========================+=========================+==========================+ | ``eq``, ``==`` | value is equal | A == B | 9 eq 9 | +---------------------------+---------------------------+-------------------------+--------------------------+ @@ -105,9 +105,9 @@ Comparator | ``contained_by`` | contained by | A in B | | 'a' contained_by 'abc' | | | | | | 1 contained_by [1,2] | +---------------------------+---------------------------+-------------------------+--------------------------+ -| ``type_match`` | A is instance of B | isinstance(A, B) | 123 type_match 'int' | +| ``type_match`` | A is instance of B | isinstance(A, B) | 123 type_match 'int' | +---------------------------+---------------------------+-------------------------+--------------------------+ -| ``regex_match`` | regex matches | re.match(B, A) | 'abcdef' regex_match 'a\w+d' | +| ``regex_match`` | regex matches | re.match(B, A) | 'abcdef' regex 'a\w+d' | +---------------------------+---------------------------+-------------------------+--------------------------+ | ``startswith`` | starts with | A.startswith(B) is True | 'abc' startswith 'ab' | +---------------------------+---------------------------+-------------------------+--------------------------+ From 5fb87f00f67dd019dec568c149195f2e0657ce68 Mon Sep 17 00:00:00 2001 From: httprunner Date: Wed, 13 Dec 2017 16:29:42 +0800 Subject: [PATCH 349/354] update tests for custom validator --- tests/data/demo_testset_hardcode.json | 14 ++++++++++---- tests/data/demo_testset_hardcode.yml | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/data/demo_testset_hardcode.json b/tests/data/demo_testset_hardcode.json index 1dbd8c942..6d1731ad0 100644 --- a/tests/data/demo_testset_hardcode.json +++ b/tests/data/demo_testset_hardcode.json @@ -16,17 +16,23 @@ "sign": "f1219719911caae89ccc301679857ebfda115ca2" } }, + "variables": [ + {"expect_status_code": 200}, + {"token_len": 16} + ], "extract": [ { "token": "content.token" } ], "validate": [ - {"eq": ["status_code", 200]}, - {"len_eq": ["content.token", 16]}, {"check": "status_code", "comparator": "eq", "expect": 200}, - {"sum_status_code": ["status_code", 2]}, - {"check": "content.token", "comparator": "len_eq", "expect": 16} + {"eq": ["status_code", "$expect_status_code"]}, + {"check": "$token", "comparator": "len_eq", "expect": 16}, + {"len_eq": ["$token", "$token_len"]}, + {"len_eq": ["content.token", 16]}, + {"check": "status_code", "comparator": "sum_status_code", "expect": 2}, + {"sum_status_code": ["status_code", 2]} ] } }, diff --git a/tests/data/demo_testset_hardcode.yml b/tests/data/demo_testset_hardcode.yml index 1a7b17582..eed5e38e6 100644 --- a/tests/data/demo_testset_hardcode.yml +++ b/tests/data/demo_testset_hardcode.yml @@ -17,12 +17,13 @@ extract: - token: content.token validate: + - {"check": "status_code", "comparator": "eq", "expect": 200} - eq: ["status_code", $expect_status_code] + - {"check": "$token", "comparator": "len_eq", "expect": 16} - len_eq: ["$token", $token_len] - len_eq: ["content.token", 16] - - {"check": "status_code", "comparator": "eq", "expect": 200} + - {"check": "status_code", "comparator": "sum_status_code", "expect": 2} - sum_status_code: ["status_code", 2] - - {"check": "$token", "comparator": "len_eq", "expect": 16} - test: name: create user which does not exist From 18fbffed877b20ab5209b7871fd1ea1e9d3f806f Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 23 Dec 2017 12:41:00 +0800 Subject: [PATCH 350/354] fix #64: when headers in test is None, it should inherit from config --- httprunner/utils.py | 6 ++++++ tests/data/test_bugfix.yml | 13 +++++++++++++ tests/test_runner.py | 16 ++++++++++++++-- tests/test_utils.py | 6 +++--- 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 tests/data/test_bugfix.yml diff --git a/httprunner/utils.py b/httprunner/utils.py index caf9c2b63..f515f70a3 100644 --- a/httprunner/utils.py +++ b/httprunner/utils.py @@ -155,10 +155,16 @@ def deep_update_dict(origin_dict, override_dict): override_dict = {'b': {'c': 3}} return: {'a': 1, 'b': {'c': 3, 'd': 4}} """ + if not override_dict: + return origin_dict + for key, val in override_dict.items(): if isinstance(val, dict): tmp = deep_update_dict(origin_dict.get(key, {}), val) origin_dict[key] = tmp + elif val is None: + # fix #64: when headers in test is None, it should inherit from config + continue else: origin_dict[key] = override_dict[key] diff --git a/tests/data/test_bugfix.yml b/tests/data/test_bugfix.yml new file mode 100644 index 000000000..b00e5c7d2 --- /dev/null +++ b/tests/data/test_bugfix.yml @@ -0,0 +1,13 @@ +- config: + name: "bugfix testcases." + request: + base_url: http://127.0.0.1:5000 + headers: + Content-Type: application/json + +- test: + name: get headers from config + request: + url: /api/users/1000 + method: GET + headers: diff --git a/tests/test_runner.py b/tests/test_runner.py index 3382ee23c..03dc34b4a 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,8 +1,7 @@ import os import time -from httprunner import exception, runner, testcase - +from httprunner import exception, runner, testcase, utils from tests.base import ApiServerUnittest @@ -132,3 +131,16 @@ def test_run_testset_with_variables_mapping(self): result = self.test_runner.run(testcase_file_path, variables_mapping) self.assertTrue(result["success"]) self.assertIn("token", result["output"]) + + def test_run_testcase_with_empty_header(self): + testcase_file_path = os.path.join( + os.getcwd(), 'tests/data/test_bugfix.yml') + testsets = testcase.load_testcases_by_path(testcase_file_path) + testset = testsets[0] + config_dict_headers = testset["config"]["request"]["headers"] + test_dict_headers = testset["testcases"][0]["request"]["headers"] + headers = utils.deep_update_dict( + config_dict_headers, + test_dict_headers + ) + self.assertEqual(headers["Content-Type"], "application/json") diff --git a/tests/test_utils.py b/tests/test_utils.py index 7007c369d..046058f6b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -163,12 +163,12 @@ def test_validators(self): functions_mapping["endswith"](12345, 45) def test_deep_update_dict(self): - origin_dict = {'a': 1, 'b': {'c': 3, 'd': 4}, 'f': 6} - override_dict = {'a': 2, 'b': {'c': 33, 'e': 5}, 'g': 7} + origin_dict = {'a': 1, 'b': {'c': 3, 'd': 4}, 'f': 6, 'h': 123} + override_dict = {'a': 2, 'b': {'c': 33, 'e': 5}, 'g': 7, 'h': None} updated_dict = utils.deep_update_dict(origin_dict, override_dict) self.assertEqual( updated_dict, - {'a': 2, 'b': {'c': 33, 'd': 4, 'e': 5}, 'f': 6, 'g': 7} + {'a': 2, 'b': {'c': 33, 'd': 4, 'e': 5}, 'f': 6, 'g': 7, 'h': 123} ) def test_get_imported_module(self): From a86113ea97cb96c8c796dd6a5d7d52f628f9b6e5 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 23 Dec 2017 12:46:00 +0800 Subject: [PATCH 351/354] fix #66: Permission denied error when testset file is read only. --- httprunner/testcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httprunner/testcase.py b/httprunner/testcase.py index 33dc9cee7..9652d4123 100644 --- a/httprunner/testcase.py +++ b/httprunner/testcase.py @@ -22,7 +22,7 @@ def _load_yaml_file(yaml_file): """ load yaml file and check file content format """ - with codecs.open(yaml_file, 'r+', encoding='utf-8') as stream: + with codecs.open(yaml_file, 'r', encoding='utf-8') as stream: yaml_content = yaml.load(stream) check_format(yaml_file, yaml_content) return yaml_content From a30dc4706778d00f27b9b21fb6bb89f359e2fce0 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 23 Dec 2017 12:49:34 +0800 Subject: [PATCH 352/354] replace codecs with io --- httprunner/__init__.py | 2 +- httprunner/locusts.py | 6 +++--- httprunner/testcase.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/httprunner/__init__.py b/httprunner/__init__.py index db07b46cd..dd499f474 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.5' \ No newline at end of file +__version__ = '0.8.6' \ No newline at end of file diff --git a/httprunner/locusts.py b/httprunner/locusts.py index 11ade7a2f..5611efb22 100644 --- a/httprunner/locusts.py +++ b/httprunner/locusts.py @@ -1,4 +1,4 @@ -import codecs +import io import multiprocessing import os import sys @@ -39,8 +39,8 @@ def gen_locustfile(testcase_file_path): testset = load_test_file(testcase_file_path) host = testset.get("config", {}).get("request", {}).get("base_url", "") - with codecs.open(template_path, encoding='utf-8') as template: - with codecs.open(locustfile_path, 'w', encoding='utf-8') as locustfile: + with io.open(template_path, encoding='utf-8') as template: + with io.open(locustfile_path, 'w', encoding='utf-8') as locustfile: template_content = template.read() template_content = template_content.replace("$HOST", host) template_content = template_content.replace("$TESTCASE_FILE", testcase_file_path) diff --git a/httprunner/testcase.py b/httprunner/testcase.py index 9652d4123..c83204ace 100644 --- a/httprunner/testcase.py +++ b/httprunner/testcase.py @@ -1,5 +1,5 @@ import ast -import codecs +import io import json import logging import os @@ -22,7 +22,7 @@ def _load_yaml_file(yaml_file): """ load yaml file and check file content format """ - with codecs.open(yaml_file, 'r', encoding='utf-8') as stream: + with io.open(yaml_file, 'r', encoding='utf-8') as stream: yaml_content = yaml.load(stream) check_format(yaml_file, yaml_content) return yaml_content @@ -30,7 +30,7 @@ def _load_yaml_file(yaml_file): def _load_json_file(json_file): """ load json file and check file content format """ - with codecs.open(json_file, encoding='utf-8') as data_file: + with io.open(json_file, encoding='utf-8') as data_file: try: json_content = json.load(data_file) except exception.JSONDecodeError: From 62712bc1771838e51d50398b6c8d1d191f760a78 Mon Sep 17 00:00:00 2001 From: httprunner Date: Sat, 23 Dec 2017 15:19:44 +0800 Subject: [PATCH 353/354] update doc for rename --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 53fe3c0d7..1754ea01a 100644 --- a/README.rst +++ b/README.rst @@ -61,7 +61,7 @@ There are still too many awesome features to be implemented. Recent schedules in .. _PyUnitReport: https://github.com/HttpRunner/PyUnitReport .. _Jenkins: https://jenkins.io/index.html .. _User documentation: http://httprunner.readthedocs.io/ -.. _Development process blogs: http://debugtalk.com/tags/ApiTestEngine/ +.. _Development process blogs: http://debugtalk.com/tags/HttpRunner/ .. _HAR: http://httparchive.org/ .. _Swagger: https://swagger.io/ .. _Postman Collection Format: http://blog.getpostman.com/2015/06/05/travelogue-of-postman-collection-format-v2/ From cf9043e81c046aa10979026829fbb924d8f4484c Mon Sep 17 00:00:00 2001 From: httprunner Date: Thu, 11 Jan 2018 23:03:13 +0800 Subject: [PATCH 354/354] replace --cpu-cores with --full-speed: now support specify slaves number --- docs/load-test.md | 4 ++-- httprunner/__init__.py | 2 +- httprunner/cli.py | 40 ++++++++++++++++++++++++++++++++-------- httprunner/locusts.py | 7 ++----- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs/load-test.md b/docs/load-test.md index 2ac5e8b5c..89e135a5e 100644 --- a/docs/load-test.md +++ b/docs/load-test.md @@ -21,10 +21,10 @@ $ locusts -f examples/first-testcase.yml In this case, you can reuse all features of [`Locust`][Locust]. -That’s not all about it. With the argument `--full-speed`, you can even start locust with master and several slaves (default to cpu cores number) at one time, which means you can leverage all cpus of your machine. +That’s not all about it. With the argument `--cpu-cores`, you can even start locust with master and specified number of slaves (default to cpu cores number) at one time, which means you can leverage all cpus of your machine. ```bash -$ locusts -f examples/first-testcase.yml --full-speed +$ locusts -f examples/first-testcase.yml --cpu-cores 4 [2017-08-26 23:51:47,071] bogon/INFO/locust.main: Starting web monitor at *:8089 [2017-08-26 23:51:47,075] bogon/INFO/locust.main: Starting Locust 0.8a2 [2017-08-26 23:51:47,078] bogon/INFO/locust.main: Starting Locust 0.8a2 diff --git a/httprunner/__init__.py b/httprunner/__init__.py index dd499f474..ff27c5be0 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1 +1 @@ -__version__ = '0.8.6' \ No newline at end of file +__version__ = '0.8.7' \ No newline at end of file diff --git a/httprunner/cli.py b/httprunner/cli.py index 935d1f1d9..ac3a84219 100644 --- a/httprunner/cli.py +++ b/httprunner/cli.py @@ -1,16 +1,16 @@ import argparse import logging +import multiprocessing import os import sys from collections import OrderedDict -from pyunitreport import __version__ as pyu_version -from pyunitreport import HTMLTestRunner - from httprunner import __version__ as ate_version from httprunner import exception from httprunner.task import TaskSuite -from httprunner.utils import create_scaffold +from httprunner.utils import create_scaffold, string_type +from pyunitreport import __version__ as pyu_version +from pyunitreport import HTMLTestRunner def main_ate(): @@ -98,6 +98,8 @@ def main_ate(): def main_locust(): """ Performance test with locust: parse command line options and run commands. """ + logging.basicConfig(level="INFO") + try: from httprunner import locusts except ImportError: @@ -124,12 +126,34 @@ def main_locust(): testcase_file_path = sys.argv[testcase_index] sys.argv[testcase_index] = locusts.parse_locustfile(testcase_file_path) - if "--full-speed" in sys.argv: - + if "--cpu-cores" in sys.argv: + """ locusts -f locustfile.py --cpu-cores 4 + """ if "--no-web" in sys.argv: - logging.warning("conflict parameter args: --full-speed --no-web. \nexit.") + logging.error("conflict parameter args: --cpu-cores & --no-web. \nexit.") sys.exit(1) - locusts.run_locusts_at_full_speed(sys.argv) + cpu_cores_index = sys.argv.index('--cpu-cores') + + cpu_cores_num_index = cpu_cores_index + 1 + + if cpu_cores_num_index >= len(sys.argv): + """ do not specify cpu cores explicitly + locusts -f locustfile.py --cpu-cores + """ + cpu_cores_num_value = multiprocessing.cpu_count() + logging.warning("cpu cores number not specified, use {} by default.".format(cpu_cores_num_value)) + else: + try: + """ locusts -f locustfile.py --cpu-cores 4 """ + cpu_cores_num_value = int(sys.argv[cpu_cores_num_index]) + sys.argv.pop(cpu_cores_num_index) + except ValueError: + """ locusts -f locustfile.py --cpu-cores -P 8888 """ + cpu_cores_num_value = multiprocessing.cpu_count() + logging.warning("cpu cores number not specified, use {} by default.".format(cpu_cores_num_value)) + + sys.argv.pop(cpu_cores_index) + locusts.run_locusts_on_cpu_cores(sys.argv, cpu_cores_num_value) else: locusts.main() diff --git a/httprunner/locusts.py b/httprunner/locusts.py index 5611efb22..73de4e892 100644 --- a/httprunner/locusts.py +++ b/httprunner/locusts.py @@ -58,12 +58,9 @@ def start_slave(sys_argv): sys.argv = sys_argv main() -def run_locusts_at_full_speed(sys_argv): - sys_argv.pop(sys_argv.index("--full-speed")) - slaves_num = multiprocessing.cpu_count() - +def run_locusts_on_cpu_cores(sys_argv, cpu_cores_num_value): processes = [] - for _ in range(slaves_num): + for _ in range(cpu_cores_num_value): p_slave = multiprocessing.Process(target=start_slave, args=(sys_argv,)) p_slave.daemon = True p_slave.start()