`unittest.mock.patch`:对类、方法、属性进行模拟

好的,各位听众,欢迎来到今天的“Mock一把梭,Bug全溜走”技术讲座!我是你们的老朋友,bug终结者,今天咱们来聊聊unittest.mock.patch这个神奇的家伙。

开场白:为什么我们需要Mock?

想象一下,你写了一个很棒的函数,它负责从数据库里读取数据,然后进行一些复杂的计算。但是,现在数据库出了点小问题,或者你根本不想每次测试都真的去访问数据库,这时候怎么办呢?难道要对着数据库祈祷吗?当然不是!

这时候,Mock就闪亮登场了。简单来说,Mock就是用一个假的、可控的对象来代替真实的对象。这样,你就可以在测试中完全控制这些依赖项的行为,从而更专注于测试你的代码逻辑本身。

unittest.mock.patch:你的Mock瑞士军刀

在Python的unittest.mock模块中,patch绝对是核心角色。它可以像一把瑞士军刀一样,让你轻松地模拟类、方法、属性,甚至整个模块。

1. patch的基本用法:函数装饰器

最常见的用法是作为函数装饰器。假设我们有一个函数get_data_from_api,它依赖于一个requests库来获取数据:

import requests

def get_data_from_api(url):
    response = requests.get(url)
    response.raise_for_status()  # 抛出异常如果状态码不是200
    return response.json()

现在,我们不想真的去访问API,而是想模拟requests.get的行为。我们可以这样做:

import unittest
from unittest.mock import patch

class TestGetDataFromApi(unittest.TestCase):
    @patch('requests.get')  # 告诉patch要模拟的是requests.get
    def test_get_data_from_api_success(self, mock_get): # mock_get 是模拟的 requests.get 对象
        # 配置mock对象的行为
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {'data': 'some data'}

        # 调用被测试的函数
        result = get_data_from_api('http://example.com/api')

        # 断言
        self.assertEqual(result, {'data': 'some data'})
        mock_get.assert_called_once_with('http://example.com/api')

这段代码做了什么?

  • @patch('requests.get'):这是一个装饰器,它告诉patch要模拟requests模块的get函数。
  • def test_get_data_from_api_success(self, mock_get):注意,测试函数接收了一个参数mock_get,这个参数就是被模拟的requests.get对象。
  • mock_get.return_value.status_code = 200mock_get.return_value.json.return_value = {'data': 'some data'}:这两行代码配置了模拟对象的行为。我们设置了status_code为200,并且让json()方法返回了我们想要的数据。
  • result = get_data_from_api('http://example.com/api'):调用被测试的函数。
  • self.assertEqual(result, {'data': 'some data'}):断言结果是否符合预期。
  • mock_get.assert_called_once_with('http://example.com/api'):断言mock_get是否被调用过,并且调用时的参数是否正确。

2. patch作为上下文管理器

除了作为装饰器,patch还可以作为上下文管理器使用。这在需要更精细地控制模拟对象的生命周期时非常有用。

import unittest
from unittest.mock import patch

class TestGetDataFromApiContextManager(unittest.TestCase):
    def test_get_data_from_api_failure(self):
        with patch('requests.get') as mock_get:
            mock_get.side_effect = requests.exceptions.RequestException('Simulated error')

            with self.assertRaises(requests.exceptions.RequestException):
                get_data_from_api('http://example.com/api')

这段代码做了什么?

  • with patch('requests.get') as mock_get::使用patch作为上下文管理器,mock_getwith语句块中有效。
  • mock_get.side_effect = requests.exceptions.RequestException('Simulated error'):设置side_effect属性,让mock_get在被调用时抛出一个异常。
  • with self.assertRaises(requests.exceptions.RequestException)::断言代码是否抛出了预期的异常。

3. patch的参数详解:newcreatespecautospec

patch有很多可选参数,可以让你更灵活地控制模拟行为。

  • new: 用于指定替换的对象。你可以传递一个值,一个对象,或者一个类。

    import unittest
    from unittest.mock import patch
    
    class MyClass:
        def my_method(self):
            return "Original"
    
    def new_function():
        return "Mocked"
    
    class TestPatchNew(unittest.TestCase):
        def test_patch_with_new_function(self):
            with patch('__main__.MyClass.my_method', new=new_function) as mock_method:
                instance = MyClass()
                result = instance.my_method()
                self.assertEqual(result, "Mocked")
    
        def test_patch_with_new_value(self):
            with patch('__main__.MyClass.my_method', new="Mocked Value") as mock_method:
                instance = MyClass()
                result = instance.my_method()
                self.assertEqual(result, "Mocked Value")

    在这个例子中,我们使用 new 参数来将 MyClass.my_method 替换为一个新的函数 new_function 和一个字符串 "Mocked Value"。

  • create: 如果你尝试模拟一个不存在的属性,patch默认会抛出一个AttributeError。但是,如果设置create=Truepatch会自动创建这个属性。

    import unittest
    from unittest.mock import patch
    
    class MyClass:
        pass
    
    class TestPatchCreate(unittest.TestCase):
        def test_patch_create(self):
            with patch('__main__.MyClass.non_existent_attribute', create=True) as mock_attribute:
                mock_attribute.return_value = "Created Attribute"
                instance = MyClass()
                result = instance.non_existent_attribute()
                self.assertEqual(result, "Created Attribute")

    在这个例子中,MyClass 没有 non_existent_attribute 属性。通过设置 create=Truepatch 会自动创建这个属性。

  • spec: spec参数可以让你指定一个对象作为模拟对象的“模板”。模拟对象会拥有和模板对象相同的属性和方法。这可以帮助你避免拼写错误,并且让你的测试更贴近真实环境。

    import unittest
    from unittest.mock import patch
    
    class MyClass:
        def my_method(self, arg1, arg2):
            return arg1 + arg2
    
    class TestPatchSpec(unittest.TestCase):
        def test_patch_spec(self):
            with patch('__main__.MyClass.my_method', spec=MyClass.my_method) as mock_method:
                mock_method.return_value = "Mocked Result"
                instance = MyClass()
                result = instance.my_method("a", "b")
                self.assertEqual(result, "Mocked Result")
                mock_method.assert_called_with("a", "b")

    在这个例子中,我们使用 spec=MyClass.my_method 来指定 mock_method 应该具有与 MyClass.my_method 相同的接口。

  • autospec: autospecspec的更智能版本。它会自动根据被模拟对象的签名来创建模拟对象。这意味着,如果被模拟对象的签名发生变化,你的测试会立即失败,从而帮助你及时发现问题。

    import unittest
    from unittest.mock import patch
    
    class MyClass:
        def my_method(self, arg1, arg2):
            return arg1 + arg2
    
    class TestPatchAutospec(unittest.TestCase):
        def test_patch_autospec(self):
            with patch('__main__.MyClass.my_method', autospec=True) as mock_method:
                mock_method.return_value = "Mocked Result"
                instance = MyClass()
                result = instance.my_method("a", "b")
                self.assertEqual(result, "Mocked Result")
                mock_method.assert_called_with("a", "b")
    
        def test_patch_autospec_wrong_arguments(self):
            with patch('__main__.MyClass.my_method', autospec=True) as mock_method:
                instance = MyClass()
                with self.assertRaises(TypeError):
                    instance.my_method("a")  # 缺少一个参数,会抛出TypeError

    在这个例子中,我们使用 autospec=True 来让 mock_method 自动具有与 MyClass.my_method 相同的签名。如果我们在调用 instance.my_method 时传递了错误的参数数量,测试将会失败。

4. side_effect:让你的Mock更灵活

side_effect是一个非常强大的属性,它可以让你定义模拟对象在被调用时产生的副作用。

  • 抛出异常:

    import unittest
    from unittest.mock import patch
    
    class TestSideEffectException(unittest.TestCase):
        def test_side_effect_exception(self):
            with patch('requests.get') as mock_get:
                mock_get.side_effect = Exception("Simulated Error")
                with self.assertRaises(Exception):
                    requests.get("http://example.com")

    在这个例子中,我们设置 mock_getside_effect 为一个异常。当 requests.get 被调用时,会抛出这个异常。

  • 返回不同的值:

    import unittest
    from unittest.mock import patch
    
    class TestSideEffectIterable(unittest.TestCase):
        def test_side_effect_iterable(self):
            with patch('requests.get') as mock_get:
                mock_get.side_effect = [1, 2, 3]
                self.assertEqual(requests.get("http://example.com"), 1)
                self.assertEqual(requests.get("http://example.com"), 2)
                self.assertEqual(requests.get("http://example.com"), 3)

    在这个例子中,我们设置 mock_getside_effect 为一个列表。每次 requests.get 被调用时,会依次返回列表中的值。

  • 执行自定义函数:

    import unittest
    from unittest.mock import patch
    
    def my_side_effect(arg):
        return arg * 2
    
    class TestSideEffectFunction(unittest.TestCase):
        def test_side_effect_function(self):
            with patch('requests.get') as mock_get:
                mock_get.side_effect = my_side_effect
                self.assertEqual(requests.get(5), 10)

    在这个例子中,我们设置 mock_getside_effect 为一个自定义函数 my_side_effect。每次 requests.get 被调用时,会执行这个函数,并将函数的返回值作为 requests.get 的返回值。

5. assert_called系列方法:验证你的Mock是否被正确调用

unittest.mock提供了一系列assert_called方法,可以让你验证模拟对象是否被调用过,以及调用时的参数是否正确。

  • assert_called(): 验证模拟对象是否被调用过。
  • assert_called_once(): 验证模拟对象是否只被调用过一次。
  • *`assert_called_with(args, kwargs)`: 验证模拟对象是否被调用过,并且调用时的参数是否正确。
  • *`assert_called_once_with(args, kwargs)`: 验证模拟对象是否只被调用过一次,并且调用时的参数是否正确。
  • *`assert_any_call(args, kwargs)`: 验证模拟对象是否被调用过,并且至少有一次调用时的参数符合要求。
  • assert_has_calls(calls, any_order=False): 验证模拟对象是否按照指定的顺序被调用过。
import unittest
from unittest.mock import patch, call

class MyClass:
    def my_method(self, arg1, arg2):
        pass

class TestAssertCalled(unittest.TestCase):
    def test_assert_called(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            self.assertTrue(mock_method.called)
            mock_method.assert_called()

    def test_assert_called_once(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            mock_method.assert_called_once()

    def test_assert_called_with(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            mock_method.assert_called_with("a", "b")

    def test_assert_called_once_with(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            mock_method.assert_called_once_with("a", "b")

    def test_assert_any_call(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            instance.my_method("c", "d")
            mock_method.assert_any_call("a", "b")

    def test_assert_has_calls(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            instance.my_method("c", "d")
            calls = [call("a", "b"), call("c", "d")]
            mock_method.assert_has_calls(calls)

    def test_assert_has_calls_any_order(self):
        with patch('__main__.MyClass.my_method') as mock_method:
            instance = MyClass()
            instance.my_method("a", "b")
            instance.my_method("c", "d")
            calls = [call("c", "d"), call("a", "b")]
            mock_method.assert_has_calls(calls, any_order=True)

6. patch.object:专门用于模拟对象属性

如果你只想模拟一个对象的某个属性,可以使用patch.object

import unittest
from unittest.mock import patch

class MyClass:
    def __init__(self):
        self.my_attribute = "Original Value"

    def get_attribute(self):
        return self.my_attribute

class TestPatchObject(unittest.TestCase):
    def test_patch_object(self):
        instance = MyClass()
        with patch.object(instance, 'my_attribute', "Mocked Value") as mock_attribute:
            self.assertEqual(instance.get_attribute(), "Mocked Value")

在这个例子中,我们使用 patch.object 来模拟 MyClass 实例的 my_attribute 属性。

7. patch.dict:模拟字典

patch.dict可以让你临时替换一个字典的内容。

import unittest
from unittest.mock import patch

my_dict = {'a': 1, 'b': 2}

class TestPatchDict(unittest.TestCase):
    def test_patch_dict(self):
        with patch.dict(my_dict, {'a': 3, 'c': 4}):
            self.assertEqual(my_dict['a'], 3)
            self.assertEqual(my_dict['b'], 2)
            self.assertEqual(my_dict['c'], 4)

        self.assertEqual(my_dict['a'], 1)
        self.assertEqual(my_dict['b'], 2)
        self.assertNotIn('c', my_dict)

在这个例子中,我们使用 patch.dict 来临时替换 my_dict 的内容。在 with 语句块中,my_dicta 键的值被替换为 3,并且添加了新的键 c。当 with 语句块结束时,my_dict 恢复到原始状态。

8. Mocking Properties

在Python中,我们经常使用properties来封装对属性的访问。patch 也能很好地处理这种情况。

import unittest
from unittest.mock import patch

class MyClass:
    def __init__(self, value):
        self._my_property = value

    @property
    def my_property(self):
        return self._my_property

    @my_property.setter
    def my_property(self, value):
        self._my_property = value

class TestPatchProperty(unittest.TestCase):
    def test_patch_property(self):
        instance = MyClass("Original Value")
        with patch('__main__.MyClass.my_property', new_callable=unittest.mock.PropertyMock) as mock_property:
            mock_property.return_value = "Mocked Value"
            self.assertEqual(instance.my_property, "Mocked Value")

            instance.my_property = "New Value"
            mock_property.assert_called_once_with("New Value")

关键点在于使用new_callable=unittest.mock.PropertyMock。这告诉patch我们要模拟的是一个property,而不是一个普通的方法或属性。

最佳实践和注意事项

  • 尽量使用autospec=True: 它可以帮助你避免因接口变更而导致的测试失败。
  • 只模拟你需要的: 不要过度模拟,只模拟那些你无法控制或者不想在测试中使用的依赖项。
  • 保持测试的简洁性: 模拟应该让你的测试更简单,而不是更复杂。
  • 注意作用域: 确保你的模拟对象在正确的作用域内生效。
  • 清理模拟: 使用上下文管理器或者stop()方法来确保你的模拟对象在测试结束后被清理干净。

总结

unittest.mock.patch是一个非常强大的工具,可以让你轻松地模拟各种依赖项,从而编写更可靠、更易于维护的单元测试。熟练掌握patch的各种用法,可以让你在测试的世界里如鱼得水,bug无处遁形!

表格总结patch常用参数

参数 描述 示例
new 用于指定替换的对象。可以传递一个值,一个对象,或者一个类。 patch('module.object', new=mock_object)
create 如果要模拟的属性不存在,是否自动创建。默认为False patch('module.non_existent_attribute', create=True)
spec 指定一个对象作为模拟对象的“模板”。模拟对象会拥有和模板对象相同的属性和方法。 patch('module.object', spec=RealClass)
autospec 自动根据被模拟对象的签名来创建模拟对象。 patch('module.object', autospec=True)
side_effect 设置模拟对象被调用时产生的副作用。可以是异常、返回值列表、或者自定义函数。 patch('module.object', side_effect=Exception('Simulated error'))

常见问题解答

  • Q: 为什么我的模拟没有生效?

    • A: 检查你的patch目标是否正确。确保你模拟的是代码实际使用的对象。
    • A: 检查你的作用域是否正确。确保模拟对象在代码执行时生效。
  • Q: 如何模拟一个全局变量?

    • A: 使用patch('__main__.global_variable')来模拟当前模块的全局变量。
  • Q: 如何模拟一个类的静态方法或类方法?

    • A: 使用patch('module.Class.static_method')patch('module.Class.classmethod')
  • Q: 什么时候应该使用patch,什么时候应该使用其他Mock方法?

    • A: patch适用于需要替换对象的情况。如果只需要创建Mock对象并配置其行为,可以使用Mock类本身。

结束语

希望今天的讲座能帮助大家更好地理解和使用unittest.mock.patch。记住,Mock不是万能的,但它绝对是你测试工具箱里不可或缺的一员。掌握了它,你就能写出更健壮、更可靠的代码,让bug无处遁形!

感谢大家的收听,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注