api测试工具集,宗旨是不造轮子,尽可能多的集成、组装轮子,以及降低轮子的使用难度,让同学们集中精力把时间花在测试用例的设计上,完成领导们的任务,欢迎大家多提宝贵意见
walnuts的意思是核桃,并不是因为我喜欢吃核桃,也不是因为这跟测试有什么关系,只因为我儿子的小名叫核桃而已
使用pip安装和更新
pip install -U walnuts
注意:安装完成后,需要重新打开一个命令行才可以使用
walnuts命令
E:\PycharmProjects>walnuts init
请输入项目名称,如order-api-test: user-api-test
开始创建user-api-test项目
开始渲染...
生成 .gitignore [√]
生成 .walnuts [√]
生成 api [√]
生成 app_for_test.py [√]
生成 common [√]
生成 config.yaml [√]
生成 db [√]
生成 README.md [√]
生成 report [√]
生成 requirements.txt [√]
生成 test_suites [√]
生成 __pycache__ [√]
生成成功,请使用编辑器打开该项目
会生成如下项目
├── README.md # 帮助文档
├── .gitignore # 配置忽略不想被GIT管理的文件
├── .walnuts # 项目根目录标识
├── app_for_test.py # 演示使用的web服务文件
├── config.yaml # 配置文件
├── requirements.txt # 项目依赖文件
├── report # 测试报告存放文件夹
├── api # api定义包
│ ├── __init__.py
│ ├── demo.py
│ └── ...
├── common # 通用工具包
│ ├── __init__.py
│ ├── assert_tools.py
│ └── ...
├── db # db包
│ ├── __init__.py
│ └── ...
└── test_suites # 测试用例包
├── __init__.py
├── test_book_list.py
├── test_login.py
└── ...
进入到项目文件夹下,使用pip install -r requirements.txt命令安装项目依赖,过程如下所示
E:\PycharmProjects>cd user-api-test E:\PycharmProjects\user-api-test>pip install -r requirements.txt Looking in indexes: https://pypi.douban.com/simple Requirement already satisfied: walnuts in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from -r requirements.txt (line 1)) (0.0.2) Requirement already satisfied: flask in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from -r requirements.txt (line 2)) (1.1.2) Requirement already satisfied: flask_login in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from -r requirements.txt (line 3)) (0.5.0) Requirement already satisfied: pytest in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from -r requirements.txt (line 4)) (5.4.3) Requirement already satisfied: pytest-html in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from -r requirements.txt (line 5)) (2.1.1) Requirement already satisfied: pyyaml in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from walnuts->-r requirements.txt (line 1)) (5.3.1) Requirement already satisfied: requests in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from walnuts->-r requirements.txt (line 1)) (2.23.0) Requirement already satisfied: jinja2 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from walnuts->-r requirements.txt (line 1)) (2.11.2) Requirement already satisfied: click in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from walnuts->-r requirements.txt (line 1)) (7.1.2) Requirement already satisfied: configobj in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from walnuts->-r requirements.txt (line 1)) (5.0.6) Requirement already satisfied: itsdangerous>=0.24 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from flask->-r requirements.txt (line 2)) (1.1.0) Requirement already satisfied: Werkzeug>=0.15 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from flask->-r requirements.txt (line 2)) (1.0.1) Requirement already satisfied: colorama; sys_platform == "win32" in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (0.4.3) Requirement already satisfied: wcwidth in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (0.1.9) Requirement already satisfied: attrs>=17.4.0 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (19.3.0) Requirement already satisfied: py>=1.5.0 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (1.8.2) Requirement already satisfied: atomicwrites>=1.0; sys_platform == "win32" in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (1.4.0) Requirement already satisfied: packaging in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (20.4) Requirement already satisfied: pluggy<1.0,>=0.12 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (0.13.1) Requirement already satisfied: more-itertools>=4.0.0 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest->-r requirements.txt (line 4)) (8.4.0) Requirement already satisfied: pytest-metadata in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from pytest-html->-r requirements.txt (line 5)) (1.9.0) Requirement already satisfied: idna<3,>=2.5 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from requests->walnuts->-r requirements.txt (line 1)) (2.9) Requirement already satisfied: chardet<4,>=3.0.2 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from requests->walnuts->-r requirements.txt (line 1)) (3.0.4) Requirement already satisfied: certifi>=2017年4月17日 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from requests->walnuts->-r requirements.txt (line 1)) (2020年4月5日.1) Requirement already satisfied: urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from requests->walnuts->-r requirements.txt (line 1)) (1.25.9) Requirement already satisfied: MarkupSafe>=0.23 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from jinja2->walnuts->-r requirements.txt (line 1)) (1.1.1) Requirement already satisfied: six in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from configobj->walnuts->-r requirements.txt (line 1)) (1.14.0) Requirement already satisfied: pyparsing>=2.0.2 in c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages (from packaging->pytest->-r requirements.txt (line 4)) (2.4.7) WARNING: You are using pip version 19.2.3, however version 20.1.1 is available. You should consider upgrading via the 'python -m pip install --upgrade pip' command.
找到项目根目录下的config.yaml文件,修改里面的邮件相关配置,report.email下的email和password是你用来发送测试报告邮件的账号和密码,to_list是接收的邮件列表,具体配置可以参考下面的配置
默认是不需要配置 port的,但如果邮件服务器的端口号比较特殊,也可以配置,配置方法同服务器地址
注意:需要开启SMTP服务,如果你开启了授权码的话,password需要填入授权码,而不是你的密码
如果yaml配置不太熟的话,可以参考阮一峰大佬的这篇入门教程,http://www.ruanyifeng.com/blog/2016/07/yaml.html
配置钉钉通知的方式如下
1、打开要通知的钉钉群,打开群助手,点击添加机器人
2、选择群机器人中的自定义
3、输入机器人名字,勾选自定义关键词,关键词输入TEST(暂时只支持关键词配置),勾选协议,点击完成
4、添加成功后,把webhook复制出来
5、把webhook url配置到report.dingtalk下的hook_url处,如下面配置所示
app: host: http://127.0.0.1:5000 user: account: admin@admin.com password: 111111 report: report_folder: report email: trigger: fail # 只在失败时发送 email: xxxx@126.com password: xxxx to_list: - xxxx@.com - xxxx@qq.com dingtalk: trigger: fail # 只在失败时发送 hook_url: https://oapi.dingtalk.com/robot/send?access_token=XXXX
项目初始化后,会有一些示例,现在需要启动一下这些示例调用的接口的服务,这个跟我们的测试是没有关系的,执行过程如下:
E:\PycharmProjects\user-api-test>python app_for_test.py * Serving Flask app "app_for_test" (lazy loading) * Environment: production WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Debug mode: off * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
在项目根目录下执行 walnuts run命令,会执行测试,并在测试完成后自动发送邮件测试报告,过程如下:
E:\PycharmProjects\order-api-test>walnuts run ========================================================================================================= test session starts ========================================================================================================= platform win32 -- Python 3.8.2, pytest-5.4.3, py-1.8.2, pluggy-0.13.1 rootdir: E:\PycharmProjects\order-api-test plugins: html-2.1.1, metadata-1.9.0 collected 3 items test_suites\test_book_list.py . [ 33%] test_suites\test_login.py .. [100%] ========================================================================================================== warnings summary =========================================================================================================== c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages\_pytest\junitxml.py:417 c:\users\administrator\appdata\local\programs\python\python38\lib\site-packages\_pytest\junitxml.py:417: PytestDeprecationWarning: The 'junit_family' default value will change to 'xunit2' in pytest 6.0. Add 'junit_family=xunit1' to your pytest.ini file to keep the current format in future versions of pytest and silence this warning. _issue_warning_captured(deprecated.JUNIT_XML_DEFAULT_FAMILY, config.hook, 2) -- Docs: https://docs.pytest.org/en/latest/warnings.html ------------------------------------------------------------------ generated xml file: E:\PycharmProjects\order-api-test\report\junit_report-2020年06月23日-22-51-45.xml ------------------------------------------------------------------ -------------------------------------------------------------- generated html file: file://E:\PycharmProjects\order-api-test\report\html_report-2020年06月23日-22-51-45.html -------------------------------------------------------------- ==================================================================================================== 3 passed, 1 warning in 0.92s ===================================================================================================== 邮件发送成功,请查收
除了python之外,你还需要学习一下requests库和pytest的使用,我们的工具是基于这两个库的,相关资料如下
- https://requests.readthedocs.io/zh_CN/latest/user/quickstart.html
- https://docs.pytest.org/en/stable/
- 这里对
requests库进行了封装,使其更容易使用类组织HTTP请求,并在调用过程中打印相关日志 - 使用类组织时,该类下所定义的所有方法使用同一个
session,这样就可以保存调用过程中设置的cookie,同时也可以通过add_header方法,给所有的请求添加请求头,可以很方便实现登录后设置token的功能 - 除url和请求方法已在
RequestMapping装饰器中定义之外,使用requests时的其它参数均可传到Requester中,效果是一样的
这里需要理解一下session的概念,https://requests.readthedocs.io/en/master/user/advanced/#session-objects
requests已经很简单,很好用的,为什么还要封装呢?
requests的确非常好用,但它只是一个http请求库,如果用来做测试的话,势必要解决很多测试中遇到的问题,比如:
- 需要在每次发请求都打印出相关报文,每个请求里都需要写一遍
- 接口请求需要签名,每个请求里都需要单独写一遍
- 需要对请求响应后的结果做下初步断言,同样也需要在每个请求里写一遍
- ...
所以我们还需要封装,加以改造一下,改造完可以实现如下功能
- 集成打印报文功能
- 增加多层级请求前响应后回调功能
- 提取url和方法,使我们的api定义看起来更清晰
- 简化使用,隐藏session,一般操作不需要接触session
- 包装响应结果,使其更容易取值,如自动解析xml,自动集成json的objectpath取值功能
- ...
下面是使用形式上的对比,具体功能我们后面再介绍
import requests from walnuts import RequestMapping, Method, Requester # 使用requests url = 'http://www.baidu.com' params = {'a': 1, 'b': 2} headers = {'a': '1', 'b': '2'} data = {'a': 1, 'b': 2} res = requests.post(url, params=params, headers=headers, data=data) @RequestMapping(path='http://www.baidu.com', method=Method.POST) def post_baidu(): params = {'a': 1, 'b': 2} headers = {'a': '1', 'b': '2'} data = {'a': 1, 'b': 2} return Requester(params=params, headers=headers, data=data)
from walnuts import RequestMapping, add_header, Method, Requester, add_headers, get_session @RequestMapping(path='http://httpbin.org') class HTTPBin: """ 使用类组织http请求 """ @RequestMapping('/post', method=Method.POST) def post_form(self): """ form表单格式请求示例 """ return Requester(data={'a': 1, 'b': 2}) @RequestMapping('/post', method=Method.POST) def post_json(self): """ json格式请求示例 """ return Requester(json={'a': 1, 'b': 2}) @RequestMapping('/post', method=Method.POST) def with_header(self): """ 带headers示例 """ return Requester(headers={'a': '1', 'b': '2'}) @RequestMapping('/{secret}', method=Method.POST) def path_var(self): """ 路径变量示例 """ return Requester(path_var={'secret': 'post'}) @RequestMapping('http://www.baidu.com') def other_site(self): """ 请求组下面的其它站点 """ return Requester() def add_header_to_all(self): """ 调用此方法后,以后所有的请求都会带上{'walnuts': 'header'}这个header 可以用此方法作登录后添加token的操作 """ add_header(self, 'walnuts', 'header') def add_headers_to_all(self): """ 调用此方法后,以后所有的请求都会带上这个方法所添加的headers """ headers = {'a': 'a', 'b': 'b', 'c': 'c'} add_headers(self, headers) def get_request_session(self): """ 获取session的方式 """ return get_session(self) @RequestMapping('http://httpbin.org/post', method=Method.POST) def post_json(): """ 使用函数组织 """ return Requester(json={'a': 1, 'b': 2}) if __name__ == '__main__': http_bin = HTTPBin() http_bin.add_header_to_all() http_bin.post_form().json() http_bin.post_json().json() http_bin.path_var().json() post_json()
我们有时需要在http请求前做一些事情,比如计算签名,或者在http响应后增加补步响应断言等,walnuts提供了BeforeRuqest和AfterResponse两个装饰器,可以协助完成这个工作
这里提供3个级别,分别是全局、类、方法,模块加载完成后,会分别注册,当执行http请求里,会按照 全局 -> 类 -> 方法的顺序依次执行,全局和类级别可定义多个回调函数,方法级别只能定义一个
talk is cheap, show me the code
from pprint import pprint from walnuts import RequestMapping, Requester, BeforeRequest, AfterResponse, Method, RequestObject, ResponseObject @BeforeRequest def global_hook_func1(request: RequestObject): """ 全局请求前回调函数1 :param request: 请求对象,后面的RequestObject用来辅助IDE提示功能,该参数在请求前会自动注入,无需要自己调用 """ # 请求前给headers添加值 request.headers['global_hook_func1'] = 'global_hook_func1' # 查看request对象内容 pprint(request) # 获取编码后的url query参数 print(request.get_encoded_params()) # 获取json字符串,直接通过request.json获取到的是字典 print(request.get_dumped_json()) # 获取编码后的data参数,即form表单形式提交时的body数据 print(request.get_encoded_data()) @BeforeRequest def global_hook_func2(request: RequestObject): """ 全局请求前回调函数2 """ request.headers['global_hook_func2'] = 'global_hook_func2' @AfterResponse def global_response_hook_func(response: ResponseObject): """ 全局响应后回调函数 :param response:响应对象,同requests的响应对象 """ print('断言响应状态码是200\n') assert response.status_code == 200 def post1_before_func(request: RequestObject): """ 给HTTPBin1 post1方法使用的回调函数 """ request.headers['post1_before_func'] = 'post1_before_func' def post2_before_func(request: RequestObject): """ 给HTTPBin1 post2方法使用的回调函数 """ request.headers['post2_before_func'] = 'post2_before_func' @RequestMapping('http://httpbin.org/') class HTTPBin1: @BeforeRequest def class_hook_func(self, request: RequestObject): """ 类级别的请求前回调函数 """ request.headers['HTTPBin1_class_hook_func1'] = 'HTTPBin1_class_hook_func1' @RequestMapping(path='/post', method=Method.POST, before_request=post1_before_func) def post_1(self): """ HTTPBin1 post_1 """ return Requester() @RequestMapping(path='/post', method=Method.POST, before_request=post2_before_func) def post_2(self): """ HTTPBin1 post_2 """ return Requester() @RequestMapping('http://httpbin.org/') class HTTPBin2: @BeforeRequest def class_hook_func(self, request: RequestObject): """ 类级别的请求前回调函数 """ request.headers['HTTPBin2_class_hook_func1'] = 'HTTPBin2_class_hook_func1' @RequestMapping(path='/post', method=Method.POST) def post(self): """ HTTPBin2 post """ return Requester() if __name__ == '__main__': HTTPBin1().post_1() HTTPBin1().post_2() HTTPBin2().post()
在如上示例中,HTTPBin1的post_1请求,会添加如下header
global_hook_func1: global_hook_func1全局级别global_hook_func2: global_hook_func2全局级别HTTPBin1_class_hook_func1: HTTPBin1_class_hook_func1HTTPBin1类级别post1_before_func: post1_before_func HTTPBinpost_1方法级别
HTTPBin1的post_2请求,会添加如下header
global_hook_func1: global_hook_func1全局级别global_hook_func2: global_hook_func2全局级别HTTPBin1_class_hook_func1: HTTPBin1_class_hook_func1HTTPBin1类级别post2_before_func: post2_before_func HTTPBinpost_2方法级别
HTTPBin2的post请求,会添加如下header
global_hook_func1: global_hook_func1全局级别global_hook_func2: global_hook_func2全局级别HTTPBin2_class_hook_func1: HTTPBin2_class_hook_func1HTTPBin1类级别
同时,所以请求都会执行global_response_hook_func函数里定义的断方
需要注意的是,当同一级别有多个回调函数时,执行是按照加载的顺序,所以同一级别多个回调函数之前不要有关联,加载顺序有时并不是你看的的那样
配置文件名约定为config,可以使用ini、json、yaml,优先级为:yaml > json > ini
如果是多环境的话,可以在后面加上环境名,如config-test.yaml,需要有.walnuts中写入test标识,测试环境的配置大于默认配置,如有同名参数,测试环境配置会覆盖默认环境配置,但如果同名的为map,则为智能合并
默认配置文件全部在项目根目录下,但如果配置文件较多的情况下,也可放置于系统根目录的 config目录下
所有配置加载完之后会放到变量v里,可以通过[]或()取值,如下示例:
假设有配置如下
app: host: http://127.0.0.1:5000 user: account: admin@admin.com password: 111111
则可通过如下代码获取配置
from walnuts import v host =v['app']['host'] # 通过字典方式取值 account = v['user.account'] # 通过[x.x]方式取值 password = v('user.password') # 通过(x.x)方式取值
这里主要提供一些在测试中常用到的一些数据处理工具,不断完善中,欢迎提需求^_^
import time from datetime import datetime, date from walnuts import HumanDateTime # 解析时间戳 print(repr(HumanDateTime(1490842267))) print(HumanDateTime(1490842267000)) print(HumanDateTime(1490842267.11111)) print(HumanDateTime(1490842267111.01)) # 解析字符串格式日期 print(HumanDateTime('2017-02-02')) print(HumanDateTime('Thu Mar 30 14:21:20 2017')) print(HumanDateTime(time.ctime())) print(HumanDateTime('2017-3-3')) print(HumanDateTime('3/3/2016')) print(HumanDateTime('2017-02-02 00:00:00')) # 解析datetime或date类型时间 print(HumanDateTime(datetime(year=2018, month=11, day=30, hour=11))) print(HumanDateTime(date(year=2018, month=11, day=30))) # 增加减少时间 print(HumanDateTime('2017-02-02').add_day(1)) print(HumanDateTime('2017-02-02').sub_day(1)) print(HumanDateTime('2017-02-02').add_hour(1)) print(HumanDateTime('2017-02-02').sub_hour(1)) print(HumanDateTime('2017-02-02').add(days=1, hours=1, weeks=1, minutes=1, seconds=6)) print(HumanDateTime('2017-02-02').sub(days=1, hours=1, weeks=1, minutes=1, seconds=6)) # 转换为时间戳 print(HumanDateTime(1490842267.11111).timestamp_second) print(HumanDateTime(1490842267.11111).timestamp_microsecond) print(HumanDateTime('2017-02-02 12:12:12.1111').add_day(1).timestamp_microsecond) print(HumanDateTime('2017-02-02 12:12:12 1111').add_day(1).timestamp_microsecond) # 比较大小 print(HumanDateTime('2017-02-02 12:12:12 1111') < HumanDateTime('2017-02-02 12:12:11 1111')) print(HumanDateTime('2017-02-02 12:12:12 1111') < HumanDateTime('2017-02-02 12:13:11 1111')) print(HumanDateTime('2017-02-02 12:12:12 1111') < '2017-02-02 12:11:11') print(HumanDateTime('2017-02-02 12:12:12 1111') < '2017-02-02 12:13:11 1111') print(HumanDateTime('2017-02-02 12:12:12 1111') == '2017-02-02 12:13:11 1111') print(HumanDateTime('2017-02-02 12:12:12 1111') == '2017-02-02 12:13:12 1111') print(HumanDateTime('2017-02-02 12:12:12 1111') <= '2017-02-02 12:13:11 1111') print(HumanDateTime('2017-02-02 12:12:12 1111') >= '2017-02-02 12:13:11 1111') print(HumanDateTime('2017-02-02 12:12:12 1111') != time.time()) print(HumanDateTime('2017-02-02 12:12:12 1111') <= time.time()) print(HumanDateTime('2017-02-02 12:12:12 1111') >= time.time()) # 约等于或者接近 print(HumanDateTime('2017-02-02 12:12:12 1111').approach('2017-02-02 12:12:11 1111')) print(HumanDateTime('2017-02-02 12:12:12 1111').approach('2017-02-02 12:12:10 1111')) print(HumanDateTime('2017-02-02 12:12:12 1111').approach('2017-02-02 12:12:10 1111', offset=2)) print(HumanDateTime('2017-02-02 12:12:12 1111').approach('2017-02-02 12:12:14 1111', offset=2)) # 调用datetime的方法和属性 print(HumanDateTime('2017-02-02 12:12:12 1111').day) print(HumanDateTime('2017-02-02 12:12:12 1111').year) print(HumanDateTime('2017-02-02 12:12:12 1111').second) print(HumanDateTime('2017-02-02 12:12:12 1111').date())
- 数据库使用的封装
- 命令行工具的完善
- jenkins集成相关文档及脚本
- 数据解析相关工具封装
- 其它常用工具
QQ群:563337437