现在前端 Web、移动端 APP 开发基本上是面向接口编程,后端各种 HTTP 接口已经提供好了,大家只要实现逻辑交互和展示就行。那么问题来了,这么多接口如何方便的进行测试呢?根据个人经验,我认为需要解决三个问题:1. HTTP 接口多种多样,测试程序如何统一?2. 测试程序如何规范组织?3. 接口间有上下文依赖,如何解决?
目录
HTTP 接口多种多样,测试程序如何统一?
后端接口可能来自各个系统,GET/POST 协议的、遵循 RESTful 规范的,使用 session 认证、token 认证的,各式各样的接口都存在。但无论怎么变都无法脱离 HTTP 协议。因为组内的技术栈是 Python,这就很容易想到使用 Python 的 requests 库。首先我们使用requests.Session()
会话对象,来进行会话保持和 Header、Cookie 等处理。这里我们可以简单封装一个 HttpSession 类,把一些常用操作和设置封装一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | #!/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 请求需要的所有参数,非常强大。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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 接口测试了。我们可以把一类接口的测试放在一个类里面,甚至可以把基础变量的定义放在基础测试类里面,其它测试类继承这个基类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | #!/usr/bin/env python # -*- coding: utf-8 -*- try: import ConfigParser except ImportError: import configparser as 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接口测试类 """ # case配置信息(执行环境) config = [] # http会话对象 http_session = HttpSession() @classmethod def setUpClass(cls): """ 初始化配置信息和http_session对象 :return: """ env = None nose_cfg = None argvs = sys.argv[1:] for idx, arg in enumerate(argvs): # 直接获取case配置文件路径 if '-env=' in arg: env = arg.split('=')[-1] break # 获取nose的配置文件 if '--config=' in arg: nose_cfg = arg.split('=')[-1] if '-c' == arg: nose_cfg = argvs[idx + 1] # 未获取到case配置文件路径,尝试从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配置文件,则从系统全局配置文件中获取 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 not None: # 参数不是绝对路径 if not os.path.isabs(env): # 根据当前工作路径获取到绝对路径 env = os.getcwd() + '/' + env with open(env) as f: inp = f.read() cls.config = ruamel.yaml.safe_load(inp) @classmethod def tearDownClass(cls): cls.http_session.close() |
HttpTest基类只做了两个工作:1. 创建http会话对象;2. 读取配置文件到类变量config
中。配置文件是一个很好的编程实践,这让我们测试程序和数据能够分离,可以通过调用不同的配置文件来测试不同环境的接口,让我们测试程序不光能做线下测试,还能做线上回归和监控,切换成本非常低。
我这里选择 yaml 语法来组织配置信息,yaml 语法风格类似 json,自带基础数据结构,但更加易于书写和阅读,非常适合做配置文件。通过-env=xxx
指定配置文件路径(或者 nose.cfg 配置文件中指定),使用 ruamel.yaml 来解析 yaml,转换为 Python 中能直接操作的数据结构。
1 2 3 4 5 6 7 | %YAML 1.2 --- # http://open-dev.dingtalk.com/#/corpAuthInfo dingtalk_oapi: 'https://oapi.dingtalk.com' corpid: 'dingc2ac01025c48fb7635c2f4657eb6378f' corpsecret: 'RTw5Vbsh6uKSOu2G25wOmQjQK6SA2NOHNyfHtlEgzQ-CWmvjoGH3c4a-MWTzaJ6Q' department_parentid: 52711158 |
这一切都放在setUpClass
方法中,在具体测试 case 运行之前就已经准备好这些工作了。
另外,接口返回值解析我这里使用的是 json 查询语言:JMESPath,类似 XML 中的 XPath,通过直观的描述语言来表达 json 值的解析,下图是官网的例子,非常强大,此描述语言能帮助我们规范化的解析接口返回的 json,提取我们想要断言的值。
而且此描述语言支持主流编程语言,这里使用的是其对应的 Python库:jmespath.py,接口非常简单:
1 2 3 | >>> import jmespath >>> path = jmespath.search('foo.bar', {'foo': {'bar': 'baz'}}) 'baz' |
现在我们来写一个测试 case:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | #!/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,新增了辅助函数,并自动注入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | #!/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 间都是相互独立的(每次都重新实例化测试类),case A 中使用 self.var = xxx 赋值的类成员变量(实例变量)在 case B 中无法访问:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import unittest class DemoTest(unittest.TestCase): def __init__(self, methodName='runTest'): super(DemoTest, self).__init__(methodName=methodName) print '__init__' @classmethod def setUpClass(cls): # setUpClass只执行一次 print 'setUpClass' cls.var_c = 'c' def test_a(self): self.var_a = 'a' self.assertTrue(True) def test_b(self): self.var_b = 'b' self.assertEqual(self.var_a, 'a') def test_c(self): self.assertEqual(self.var_c, 'c') |
输出:
1 2 3 4 5 6 7 8 9 10 | __init__ __init__ __init__ setUpClass test_a (test.DemoTest) ... ok test_b (test.DemoTest) ... ERROR …… AttributeError: 'DemoTest' object has no attribute 'var_a' test_c (test.DemoTest) ... ok |
那么如何共享数据呢(比如之前那个 case,获取到的 access_token 怎么给后续的 case 使用)?
这里可以使用一个小技巧:类变量(类变量存储于全局区在整个实例化的对象中是公用的,类变量定义在类中且在函数体之外),可以使用类变量保存一些跨越类实例的全局变量。下面这个例子可以感受下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | #!/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 |
输出:
1 2 3 4 5 6 7 8 9 10 | None aaa None ---------- bbb bbb bbb bbb 4390873528 4390873528 4390873528 4390873528 ---------- ccc bbb |
因此获取到的 access_token 可以存储在 self.__class__.access_token 类变量中,使后续的 case 能够使用。
2. 如何保证 case 的执行顺序,比如我要先调用获取 AccessToken 的接口之后,其它的 case 通过拿到的 AccessToken 才能够继续执行。依托 nose 生态的强大,我们可以通过 nose-dep 插件来实现这个需求。
1 2 3 4 5 6 | @depends(after=test_b) def test_a: pass def test_b: pass |
这样我们的测试 case 优化成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | 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) |
运行结果:
1 2 3 4 5 6 7 8 | $ nosetests -c demo/nose.cfg demo 获取access_token ... ok 获取部门列表 ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.275s OK |
基于此理论(思路),实现了一个简洁、实用的轻量级 http 接口测试框架,实际应用效果还不错,希望能帮助到有需要的同学。
参考资料:
pyresttest, https://github.com/svanoort/pyresttest