使用 Python nose 组织 HTTP 接口测试

发表于:2017-10-16 11:23

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:yaozhen    来源:个人博客

  现在前端 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),实际应用效果还不错,希望能帮助到有需要的同学。
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号