现在前端 Web、移动端 APP 开发基本上是面向接口编程,后端各种 HTTP 接口已经提供好了,大家只要实现逻辑交互和展示就行。那么问题来了,这么多接口如何方便的进行测试呢?根据个人经验,我认为需要解决三个问题:1. HTTP 接口多种多样,测试程序如何统一?2. 测试程序如何规范组织?3. 接口间有上下文依赖,如何解决?
HTTP 接口多种多样,测试程序如何统一?
后端接口可能来自各个系统,GET/POST 协议的、遵循 RESTful 规范的,使用 session 认证、token 认证的,各式各样的接口都存在。但无论怎么变都无法脱离 HTTP 协议。因为组内的技术栈是 Python,这就很容易想到使用 Python 的 requests 库。首先我们使用requests.Session()会话对象,来进行会话保持和 Header、Cookie 等处理。这里我们可以简单封装一个 HttpSession 类,把一些常用操作和设置封装一下:
#!/usr/bin/env python # -*- coding: utf-8 -*- import requests # 屏蔽https安全警告 # https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings from requests.packages.urllib3.exceptions import InsecurePlatformWarning from requests.packages.urllib3.exceptions import InsecureRequestWarning from requests.packages.urllib3.exceptions import SNIMissingWarning requests.packages.urllib3.disable_warnings(InsecurePlatformWarning) requests.packages.urllib3.disable_warnings(InsecureRequestWarning) requests.packages.urllib3.disable_warnings(SNIMissingWarning) class HttpSession(requests.Session): """ http请求会话 """ def __init__(self): super(HttpSession, self).__init__() # 不校验SSL证书 self.verify = False # 超时时间 self.timeout = (60, 600) def update_cookies(self, cookies): """ 更新当前会话cookie :param cookies: cookie字典 """ requests.utils.add_dict_to_cookiejar(self.cookies, cookies) def update_headers(self, headers): """ 更新当前会话的header :param headers: header字典 """ self.headers.update(headers) |
通过HttpSession()来获取一个requests session对象,后续的 HTTP 请求都通过它发起。requests 提供 get()、post() 等各种方法调用,但这会让测试代码显得不统一,这里我们直接调用底层的 request 方法,该方法几乎提供了发起一个 HTTP 请求需要的所有参数,非常强大。
http_session = HttpSession() def test_get_access_token(self): """ 获取access_token """ params = { "corpid": 'corp_id', "corpsecret": 'corp_secret', } response = http_session.request( 'GET', config['dingtalk_oapi'] + '/gettoken', params=params ).json() data = response['access_token'] print data is not None |
这样每一个 HTTP 接口的测试都可以通过准备参数、发起请求、断言结果三板斧来解决。
测试程序如何规范组织?
前面我们已经解决了如何快捷的发起一个 HTTP 请求的问题,这让我们几行代码就可以测试一个接口的返回值。但你会发现新的问题又来了,各种脚本散乱在一堆,返回值解析各种中间变量,各种配置硬编码在代码中,测试结果全靠 print,这时 nose 框架就派上用场了。nose 是 Python 最流行的一个单测框架,提供测试 case 设置标签、测试 case 查找、强大的断言函数,格式化/可视化报告输出等功能。这样就可以像写单测一样写 HTTP 接口测试了。我们可以把一类接口的测试放在一个类里面,甚至可以把基础变量的定义放在基础测试类里面,其它测试类继承这个基类。
#!/usr/bin/env python # -*- coding: utf-8 -*- import ConfigParser import os import sys import unittest import nose.config import ruamel.yaml from py_http_api_test.http_session import HttpSession class HttpTest(unittest.TestCase): """ http接口测试类 """ # 配置信息 config = None # case执行环境(配置文件路径) env = None # http会话对象 http_session = HttpSession() def __init__(self, *args, **kwargs): """ 初始化公有变量 :return: """ unittest.TestCase.__init__(self, *args, **kwargs) if self.__class__.config is None: env = None nose_cfg = None argvs = sys.argv[1:] for idx, arg in enumerate(argvs): if '-env=' in arg: env = arg.split('=')[-1] # 获取nose的配置文件 if '--config' in arg: nose_cfg = arg.split('=')[-1] if '-c' in arg: nose_cfg = argvs[idx + 1] # 尝试从用户指定或者工作目录下的nose配置文件中获取环境参数 nose_config_files = nose.config.all_config_files() if env is None and (nose_cfg is not None or len(nose_config_files) > 0): if nose_cfg is None: nose_cfg = nose_config_files[-1] if not os.path.isabs(nose_cfg): nose_cfg = os.getcwd() + '/' + nose_cfg cf = ConfigParser.ConfigParser() cf.read(nose_cfg) try: env = cf.get('others', 'env') except ConfigParser.Error: env = None # 运行参数未传入而且有代码注入的配置文件路径 if env is None and self.env is not None: env = self.env if env is not None: # 参数不是绝对路径 if not os.path.isabs(env): # 根据当前工作路径获取到绝对路径 env = os.getcwd() + '/' + env with open(env) as f: inp = f.read() self.__class__.config = ruamel.yaml.safe_load(inp) |
HttpTest基类只做了两个工作:1. 创建http会话对象;2. 读取配置文件到类变量config中。配置文件是一个很好的编程实践,这让我们测试程序和数据能够分离,可以通过调用不同的配置文件来测试不同环境的接口,让我们测试程序不光能做线下测试,还能做线上回归和监控,切换成本非常低。
我这里选择 yaml 语法来组织配置信息,yaml 语法风格类似 json,自带基础数据结构,但更加易于书写和阅读,非常适合做配置文件。通过-env=xxx指定配置文件路径(或者 nose.cfg 配置文件中指定),使用 ruamel.yaml 来解析 yaml,转换为 Python 中能直接操作的数据结构。
%YAML 1.2 --- # http://open-dev.dingtalk.com/#/corpAuthInfo dingtalk_oapi: 'https://oapi.dingtalk.com' corpid: 'dingc2ac01025c48fb7635c2f4657eb6378f' corpsecret: 'RTw5Vbsh6uKSOu2G25wOmQjQK6SA2NOHNyfHtlEgzQ-CWmvjoGH3c4a-MWTzaJ6Q' department_parentid: 52711158 |
其实这个测试基类有一定违反编程规范,这些测试准备工作应该定义在setUp方法中。但这样的话测试子类每次都需要调用一下父类的setUp方法,比较麻烦。
另外,接口返回值解析我这里使用的是 json 查询语言:JMESPath,类似 XML 中的 XPath,通过直观的描述语言来表达 json 值的解析,下图是官网的例子,非常强大,此描述语言能帮助我们规范化的解析接口返回的 json,提取我们想要断言的值。
而且此描述语言支持主流编程语言,这里使用的是其对应的 Python库:jmespath.py,接口非常简单:
>>> import jmespath >>> path = jmespath.search('foo.bar', {'foo': {'bar': 'baz'}}) 'baz' |
现在我们一个测试 case 就变成这个样子了:
#!/usr/bin/env python # -*- coding: utf-8 -*- from nose.tools import assert_greater_equal from nose.tools import assert_is_not_none from nose.tools import eq_ from nosedep import depends from py_http_api_test.http_test import HttpTest from py_http_api_test.jmespath_custom import search as jq_ class ContactsApiTest(HttpTest): """ 通讯录接口测试 https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.8zqGzC&treeId=172&articleId=104979&docType=1 """ access_token = None def test_gettoken(self): """ 获取access_token :return: """ params = { 'corpid': self.__class__.config['corpid'], 'corpsecret': self.__class__.config['corpsecret'] } response = self.__class__.http_session.request( 'GET', self.__class__.config['dingtalk_oapi'] + '/gettoken', params=params ).json() access_token = jq_('access_token', response) eq_(jq_('errcode', response), 0) assert_is_not_none(access_token) |
是不是非常简洁,而且功能非常强大。
上面的 jmespath_custom 模块主要是扩展了 jmespath.py,新增了辅助函数,并自动注入:
#!/usr/bin/env python # -*- coding: utf-8 -*- """ 自定义扩展版本jmespath """ import jmespath from jmespath import functions class Functions(functions.Functions): """ jmespath自定义方法 """ @functions.signature({'types': ['string']}) def _func_str_to_unicode(self, s): """ str转为unicode,方便中文对比 https://github.com/jmespath/jmespath.py/issues/132 :param s: :return: """ return s.decode('utf-8') def search(expression, data): """ 自定义search方法,自动传入options参数 同时规避这个bug https://github.com/jmespath/jmespath.py/issues/133 v0.9.3已修复 :param expression: :param data: :return: """ options = jmespath.Options(custom_functions=Functions()) # 使用自定义方法后,所有操作都需要带上options参数 return jmespath.search(expression, data, options) |
接口间有上下文依赖,如何解决?
这里细化的话是两个需求:
1. 我们都知道单测执行时每个 case 间都是相互独立的(每次都重新实例化测试类),每次都会执行 setUp、tearDown,那么如何共享数据呢(比如上面那个 case 获取到的 access_token 怎么给后续的 case 使用)?
这里可以使用一个小技巧:类变量(类变量存储于全局区在整个实例化的对象中是公用的,类变量定义在类中且在函数体之外),可以使用类变量保存一些跨越类实例的全局变量。下面这个例子可以感受下:
#!/usr/bin/env python # -*- coding: utf-8 -*- class A(object): var_a = None var_b = None new_a = A() new_b = A() # 类成员变量直接相互不影响 print new_a.var_a new_a.var_a = 'aaa' print new_a.var_a print new_b.var_a print '-' * 10 new_a.__class__.var_b = 'bbb' # 类内成员变量未赋值时,会指向对应的类变量内存地址 print new_a.var_b, new_b.var_b print new_a.__class__.var_b, new_b.__class__.var_b # 各个变量实际指向的地址一样 print id(new_a.var_b), id(new_b.var_b) print id(new_a.__class__.var_b), id(new_b.__class__.var_b) print '-' * 10 # 赋值后脱离类变量 new_a.var_b = 'ccc' print new_a.var_b, new_a.__class__.var_b |
输出:
None aaa None ---------- bbb bbb bbb bbb 4390873528 4390873528 4390873528 4390873528 ---------- ccc bbb |
这样获取到的 access_token 可以存储在 self.__class__.access_token 类变量中(HttpTest 测试基类中的 self.__class__.http_session 也是这样)。
2. 如何保证 case 的只需顺序,比如我要先调用获取 AccessToken 的接口之后,其它的 case 通过拿到的 AccessToken 才能够继续执行。依托 nose 生态的强大,我们可以通过 nose-dep 插件来实现这个需求。
@depends(after=test_b) def test_a: pass def test_b: pass |
这样我们的测试 case 优化成了:
class ContactsApiTest(HttpTest): """ 通讯录接口测试 https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.8zqGzC&treeId=172&articleId=104979&docType=1 """ access_token = None def test_gettoken(self): """ 获取access_token :return: """ params = { 'corpid': self.__class__.config['corpid'], 'corpsecret': self.__class__.config['corpsecret'] } response = self.__class__.http_session.request( 'GET', self.__class__.config['dingtalk_oapi'] + '/gettoken', params=params ).json() self.__class__.access_token = jq_('access_token', response) eq_(jq_('errcode', response), 0) assert_is_not_none(self.__class__.access_token) @depends(after='test_gettoken') def test_department_list(self): """ 获取部门列表 :return: """ params = { 'access_token': self.__class__.access_token, 'id': self.__class__.config['department_parentid'] } response = self.__class__.http_session.request( 'GET', self.__class__.config['dingtalk_oapi'] + '/department/list', params=params ).json() eq_(jq_('errcode', response), 0) assert_greater_equal(len(jq_('department', response)), 1) |
运行结果:
$ nosetests -c demo/nose.cfg demo 获取access_token ... ok 获取部门列表 ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.275s OK |
基于此理论(思路),实现了一个简洁、实用的轻量级 http 接口测试框架(https://github.com/iyaozhen/py-http-test-framework),实际应用效果还不错,希望能帮助到有需要的同学。