6.3 模拟(mocking)

mock对象即模拟对象,用来通过某种特殊和可控的方式模拟真实应用程序对象的行为。在创建精确地描述测试代码的状态的环境时,它们非常有用。

如果正在开发一个HTTP客户端,要想部署HTTP服务器并测试所有场景,令其返回所有可能值,几乎是不可能的(至少会非常复杂)。此外,测试所有失败场景也是极其困难的。

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

一种更简单的方式是创建一组根据这些特定场景进行建模的mock对象,并利用它们作为测试环境对代码进行测试。

Python标准库中用来创建mock对象的库名为mock(https://pypi.python.org/pypi/mock/ )。从Python 3.3开始,它被命名为unit.mock,合并到Python标准库。因此可以使用下面的代码片段:

try:
    from unittest import mock
except ImportError:
    import mock

要保持Python 3.3和之前版本之间的向后兼容。

它使用起来也非常简单,如示例6.6所示。

示例6.6 mock的基本用法

>>> import mock
>>> m = mock.Mock()
>>> m.some_method.return_value = 42
>>> m.some_method()
42
>>> def print_hello():
...
print("hello world!")
...
>>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
>>> def print_hello():
... print("hello world!")
... return 43
...
>>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
43
>>> m.some_method.call_count
3

即使只使用这一组功能,也应该可以模拟许多内部对象以用于不同的数据场景中。

模拟使用动作/断言模式,也就是说一旦测试运行,必须确保模拟的动作被正确地执行,如示例6.7所示。

示例6.7 确认方法调用

>>> import mock
>>> m = mock.Mock()
>>> m.some_method('foo', 'bar')
<Mock name='mock.some_method()' id='26144272'>
>>> m.some_method.assert_called_once_with('foo', 'bar')
>>> m.some_method.assert_called_once_with('foo', mock.ANY)
>>> m.some_method.assert_called_once_with('foo', 'baz')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python2.7/dist-packages/mock.py", line 846, in
      assert_called_once_with
    return self.assert_called_with(*args, **kwargs)
  File "/usr/lib/python2.7/dist-packages/mock.py", line 835, in
      assert_called_with
    raise AssertionError(msg)
AssertionError: Expected call: some_method('foo', 'baz')
Actual call: some_method('foo', 'bar')

显然,很容易传一个mock对象到代码的任何部分,并在其后检查代码是否按其期望的传入参数被调用。如果不知道该传入何种参数,可以使用mock.ANY作为参数值传入,它将会匹配传递给mock方法的任何参数。

有时可能需要来自外部模块的函数、方法或对象。mock库为此提供了一组补丁函数。

示例6.8 使用mock.patch

>>> import mock
>>> import os
>>> def fake_os_unlink(path):
...     raise IOError("Testing!")
...
>>> with mock.patch('os.unlink', fake_os_unlink):
...    os.unlink('foobar')
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 2, in fake_os_unlink
IOError: Testing!

通过mock.pach方法,可以修改外部代码的任何部分,使其按照需要的方式对软件进行各种条件下的测试,如示例6.9所示。

示例6.9 使用mock.patch测试一组行为

import requests
import unittest
import mock

class WhereIsPythonError(Exception):
    pass

def is_python_still_a_programming_language():
    try:
        r = requests.get("http://python.org")
    except IOError:
        pass
    else:
        if r.status_code == 200:
            return 'Python is a programming language' in r.content
    raise WhereIsPythonError("Something bad happened")

def get_fake_get(status_code, content):
    m = mock.Mock()
    m.status_code = status_code
    m.content = content
    def fake_get(url):
        return m
    return fake_get

def raise_get(url):
    raise IOError("Unable to fetch url %s" % url)
class TestPython(unittest.TestCase):
    @mock.patch('requests.get', get_fake_get(
        200, 'Python is a programming language for sure'))
    def test_python_is(self):
        self.assertTrue(is_python_still_a_programming_language())

    @mock.patch('requests.get', get_fake_get(
        200, 'Python is no more a programming language'))
    def test_python_is_not(self):
        self.assertFalse(is_python_still_a_programming_language())

    @mock.patch('requests.get', get_fake_get(
        404, 'Whatever'))
    def test_bad_status_code(self):
        self.assertRaises(WhereIsPythonError,
                          is_python_still_a_programming_language)

    @mock.patch('requests.get', raise_get)
    def test_ioerror(self):
        self.assertRaises(WhereIsPythonError,
                          is_python_still_a_programming_language)

示例6.9使用了mock.patch的装饰器版本,这并不改变它的行为,但当需要在整个测试函数的上下文内使用模拟时这会更方便。

使用模拟可以很方便地模拟任何问题,如Web服务器返回404错误,或者发生网络问题。我们可以确定代码返回的是正确的值,或在每种情况下抛出正确的异常,总之确保代码总是按照预期行事。