51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 1442|回复: 1
打印 上一主题 下一主题

[原创] 三年杂谈 | 测试之Mock

[复制链接]
  • TA的每日心情
    擦汗
    前天 08:59
  • 签到天数: 1021 天

    连续签到: 2 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2017-10-18 15:13:49 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    引言
      笔者最近开始补全测试方面的一些基础知识,由于也算是个人职业技能建设的一部分,权且也算作"三年小结"系列的一篇罢。
      TIPS:测试领域的水非常深,本人只是班门弄斧,欢迎测试领域的大佬们拍砖~
      Mock
      why mock
      在软件测试的领域里,单元测试被视为一个项目代码覆盖率的重要衡量指标。我们在执行单元测试时,往往需要测试和关注的是自身组件代码的稳定性,而非外界依赖\服务的交互(这块的测试放到了集成测试的部分),也因此产生了伪造调用依赖/服务的需求。
      通过mock,我们可以假装调用了xxx服务,或者xxx sdk的方法,甚至于可以借此测试一些复杂的场景,比如重试部分的代码是否符合预期等等。
      mock in Python
      Python的mock库为我们提供了完善的功能,可以很好地实现伪造请求/调用的需求。
      MagicMock
      我们一起来看看下面这个例子:
    1. # a simple module with a simple class
    2.   class AClass(object):  
    3.       def method(self, an_argument):
    4.           print "I typed {}".format(an_argument)
    复制代码
    1. >>> from mock import MagicMock
    2.   >>> import amodule
    3.   >>> thing = amodule.AClass()
    4.   # method方法替换成MagicMock实例
    5.   # 这里传入参数指定了return_value,因此调用MagicMock伪造的method方法,将会返回3!
    6.   >>> thing.method = MagicMock(return_value=3)
    7.   >>> print thing.method('an_arg')
    8.   3
    9.   # assert_called_with 即验证,我们最近一次调用method方法,是传入了'others'来调用的
    10.   # 但是,显然不是:( 因此这行会报错
    11.   >>> print thing.method.assert_called_with('others')
    12.   ...
    13.   AssertionError: Expected call: mock('others')  
    14.   Actual call: mock('an_arg')
    复制代码
    上面这个例子可以实现什么效果呢?MagicMock是mock模块的一个魔法类,它可以实现对现实方法的模拟,在本例子里,MagicMock即替代了thing的method方法,它会假装接受传入的参数,然后返回值设置为3,因此调用thing.method时实现的效果便是得到的返回值为3。
      我们还注意到,这里有调用一个assert_called_with方法,那么这个方法的作用是什么呢?正如注释里面所说的,它会去验证最近一次对mock代替的方法的调用,传入的参数是预期的那样,否则会报错AssertionError。
      当然,MagicMock还有其他的一些功能,比如side_effect实现调用该方法执行一些额外操作等。
      Patch
      除了MagicMock外,mock模块里另外一个经常用到的是patch。
      patch经常被用到的场景是结合with关键字使用,通常它可以用来在with作用域内mock一个或一组方法的调用。
      下面是一个mock requests.get返回值的例子:
    1. import mock  
    2.   import requests  
    3.   import json  
    4.   import unittest
    5.   def get_request():  
    6.       req = requests.get("http://www.baidu.com")
    7.       # do sth...
    8.       return req.status_code == 200
    9.   class TestClient(unittest.TestCase):  
    10.       @mock.patch('requests.get')
    11.       def test_get_request(self, mock_request):
    12.           mock_request.return_value = mock.MagicMock(
    13.               status_code=200, response=json.dumps({'key': 'value'})
    14.           )
    15.           self.assertEqual(get_request(), True)
    16.   if __name__ == '__main__':  
    17.       unittest.main()
    复制代码
    1. # result
    2.   $ python request_get_mock.py
    3.   .
    4.   ----------------------------------------------------------------------
    5.   Ran 1 test in 0.001s
    6.   OK
    复制代码
    我们通过patch关键字成功mock了requests.get方法的调用!可以看到,使用patch的过程非常的Pythonic!
      一个完整的单元测试
      说了这么多,让我们一起看一个完整的例子来系统了解下现实世界里mock的用法吧!下面代码是一个完整的用来测试博客Blog类的post方法的测试用例,覆盖了成功、失败、重试等完整逻辑功能,可以从我的Github找到可执行的源代码。
    1. # coding=utf-8
    2.   import unittest  
    3.   import retrying  
    4.   import mock  
    5.   import requests  
    6.   import json
    7.   class RequestError(Exception):  
    8.       def __init__(self, server):
    9.           self.message = "Request failed to {}.".format(server)
    10.   class Blog(object):  
    11.       '''
    12.       Blog类包含一些博客文章相关的操作封装
    13.       类在初始化后,post方法提供对指定文章链接内容的获取并返回JSON的格式
    14.       '''
    15.       def __init__(self, name):
    16.           self.name = name
    17.       @retrying.retry(stop_max_attempt_number=3, wait_fixed=100)
    18.       def post(self, article_path):
    19.           '''
    20.           根据获取文章的内容
    21.           '''
    22.           response = requests.get("http://{name}/{path}".format(
    23.               name=self.name,
    24.               path=article_path))
    25.           if response.status_code >= 200 and response.status_code < 300:
    26.               return response.text
    27.           else:
    28.               raise RequestError(self.name)
    29.       def __repr__(self):
    30.           return '<Blog: {}>'.format(self.name)
    31.   class TestBlog(unittest.TestCase):  
    32.       '''
    33.       我们需要测试哪些内容?根据K大的http://docs.python-guide.org/en/latest/writing/tests/
    34.       我们关注本身的功能的覆盖,那么主要需要测试:
    35.       1. 正常请求时是否能获得正确的JSON数据
    36.       2. 异常返回时是否抛出RequestError异常
    37.       3. 遇到错误是否会重试
    38.       '''
    39.       @mock.patch('requests.get')
    40.       def test_blog_post(self, mock_get):
    41.           mocked_content = json.dumps({
    42.               'status': True, 'content': 'This is the post content'
    43.           })
    44.           mocked_success = mock.MagicMock(
    45.                   status_code=200,
    46.                   headers={'content-type': "application/json"},
    47.                   text=mocked_content)
    48.           mock_get.return_value = mocked_success
    49.           # new blog instance and call `post`.
    50.           # confirm return value on succcess.
    51.           b = Blog(name="devopstarter.info")
    52.           content = b.post("three-years-later-test-mock")
    53.           self.assertEqual(content, mocked_content)
    54.           mock_get.reset_mock()  # reset mock, include called count etc.
    55.           mocked_error = mock.MagicMock(
    56.                   status_code=500,
    57.                   headers={'content-type': "application/json"},
    58.                   text="Internal server error")
    59.           # confirm raise exception on failure.
    60.           # due to mock object return_value is always error, retry is also error.
    61.           mock_get.return_value = mocked_error
    62.           with self.assertRaises(RequestError):
    63.               b.post("three-years-later-test-mock")
    64.           # confirm the retry works in failure case, call_count exceeded 3!
    65.           self.assertEqual(mock_get.call_count, 3)
    66.           # confirm the retry works while ONLY one time failed.
    67.           # `side_effect` helped us complete this goal!
    68.           mock_get.reset_mock()  # reset mock, include called count etc.
    69.           mock_get.side_effect = [
    70.               requests.ConnectionError('Test error'),
    71.               mocked_success
    72.           ]
    73.           content = b.post("three-years-later-test-mock")
    74.           self.assertEqual(content, mocked_content)
    75.   if __name__ == '__main__':  
    76.       unittest.main()
    复制代码
    关于测试用例如何覆盖,笔者强烈建议阅读k大的guide。
      mock模块的实现分析
      MonkeyPatch
      这么棒的模块究竟是怎么实现的呢?带着这样的好奇,我们不妨先考虑一下mock模块本身的需求,然后再通过阅读源码来确认实现原理。
      mock模块的需求,大致有下面这些:
      ●能够模拟模块里的类和方法,得到的mock实例应该与原有模块的方法"动作"一样;
      ●能够控制mock实例的返回值、调用情况、异常错误处理等;
      ●...
      在继续分析mock源码之前,我们先通过一个简单的例子讲解一下monkeypatch这一技巧。不妨假设我们有下面这样一个模块mymodule,里面实现了一个Hello方法。
    1. # mymodule.py
    2.   def Hello():  
    3.       print "Hello, Jacky!"
    复制代码
    假设我们现在有N个(N>>100)代码文件里均调用了这一方法,而我们现在有一个需求,需要让Hello的行为发生变化,在不改变上述源码内容的情况下,有什么办法实现吗?答案当然是有啦!
      在动态语言里,例如Python,可以通过覆盖模块的方法对象来实现对方法的hack,不妨参考下面的代码:
    1. # monkeypatch.py
    2.   import sys  
    3.   import mymodule
    4.   # patched method `Hello`
    5.   def Hello():  
    6.       print "Hello, Monkey!"
    7.   # monkey patch
    8.   class monkeypatch(object):  
    9.       def __init__(self):
    10.           mymod = self._get_global_module("mymodule")
    11.           # Methods to replace
    12.           self.methods = ('Hello', )
    13.           # Store the original methods
    14.           self.orig_methods = dict(
    15.               (m, mymod.__dict__[m]) for m in self.methods)
    16.           # Monkey patch
    17.           g = globals()
    18.           for m in self.methods:
    19.               # mymod's method has been replaced!
    20.               mymod.__dict__[m] = g[m]
    21.       def _get_global_module(self, mod):
    22.           return sys.modules[mod]
    23.       def __enter__(self):
    24.           return self
    25.       def __exit__(self, *args):
    26.           mymod = self._get_global_module("mymodule")
    27.           # restore the method
    28.           for m in self.methods:
    29.               mymod.__dict__[m] = self.orig_methods[m]
    30.   if __name__ == '__main__':  
    31.       monkeypatch()
    32.       # under this hood, mymodule's Hello has been monkeypatched.
    33.       mymodule.Hello()
    复制代码
    1. # actual call
    2.   $ python monkeypatch.py
    3.   Hello, Monkey!
    复制代码
    我们看到了,最终我们在没有改动源码的情况下,实现了对Hello方法的hack!Success!
      上述例子即是一个monkeypatch(猴子补丁)的示范(完整例子可以转到Github),而通过实现__enter__,__exit__方法,monkeypatch的影响范围限制在本身实例方法的作用域(也让它可以用with...的方式调用)。
      一般来说,monkeypatch适用于无法很好地修改当前调用某个类库的方法,而需要在上层调用时动手脚的场景。(在此,感谢毛毛同学让我第一次知道monkeypatch这个技巧~)
      _get_child_mock
      如果读者尝试过执行MagicMock方法,会发现这样的效果:
    1. In [6]: import mock
    2.   In [7]: m = mock.MagicMock()
    3.   In [8]: isinstance(m, mock.MagicMock)  
    4.   Out[8]: True
    5.   In [9]: isinstance(m.m, mock.MagicMock)  
    6.   Out[9]: True
    7.   In [10]: isinstance(m.m.m, mock.MagicMock)  
    8.   Out[10]: True
    复制代码
     啊咧,怎么会这样?!! 别着急,细想一下,如果我们需要方便的模拟类里面内部甚至嵌套的任何一处调用,黑魔法似的MagicMock似乎正好可以帮助我们实现这样的效果!
      那么,它是怎么实现的呢?让我们正式开始阅读mock源码的旅行吧!
      我们不难发现,MagicMock类是继承Mock和MagicMixin。
      继续八下去,我们可以看到它有重载__call__和__get__方法(__get__是Python里面元类的一个方法,class.xxx,均是调用此方法获取的,重载它即修改了class.xxx的调用结果),每次都会调用create_mock方法,后面又调用了_get_child_mock方法:
    1. ...
    2.       def _get_child_mock(self, **kw):
    3.           """Create the child mocks for attributes and return value.
    4.           By default child mocks will be the same type as the parent.
    5.           Subclasses of Mock may want to override this to customize the way
    6.           child mocks are made.
    7.           For non-callable mocks the callable variant will be used (rather than
    8.           any custom subclass)."""
    9.   ...
    复制代码
    根据代码docstring即可知晓,它便是MagicMock类实现上述黑魔法的依仗(Mock类的__getattr__同样调用了该方法~)。
      CallableMixin
      上述实现恐怕还不能够满足mock的需求,我们还需要实现模拟原模块方法的调用,那么调用怎么实现呢?
      同样从源码里,我们可以发现MagicMock是部分继承自Mock的,Mock有一个很有意思的父类,叫做CallableMixin,它即是模拟调用的核心实现。
      我们可以看到它有个mockcall的实现,其内部又是调用_Call类来实现链式调用,具体的话,读者们可以细看下该类的完整实现~
      一些黑科技
      mock模块里其他一些利用到的黑科技还包括以下这些:
    1. # https://github.com/testing-cabal/mock/blob/master/mock/mock.py#L1203
    2.   # _importer 方法实现的即动态根据"class.module"引入相应的原始模块
    3.   # https://github.com/testing-cabal/mock/blob/master/mock/mock.py#L1270
    4.   # patch是通过猴子补丁实现方法和类的mock,没错,就是前面介绍到的monkeypatch,这也是mock模块的主要实现思想!
    5.   # 其他一些代码也比较有趣,奈何功力有限,读的不是很清晰,就不误人子弟了,:)
    6.   ...
    复制代码
    总的来说,mock模块通过简单的2k+代码实现了强大完善的模块类和方法的模拟,棒棒哒!
      结语
      鄙人在这篇文章里介绍了mock的概念以及Python的mock模块提供的MagicMock、patch、return_value、side_effect等诸多方法,并简单分析了mock模块的源码实现。
      测试的领域同样浩瀚无边,如何验证自己的代码在各种各样的业务场景下的健壮性和可维护性,是一门很深的学问。希望这篇文章能够让更多的人了解mock的用法、意义以及它的实现原理。

    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-9-21 05:42 , Processed in 0.064831 second(s), 23 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表