Python 中的 Mocking 简介
已发表: 2022-03-11如何在不测试耐心的情况下在 Python 中运行单元测试
通常情况下,我们编写的软件直接与我们标记为“肮脏”的服务交互。 通俗地说:对我们的应用程序至关重要的服务,但其交互具有预期但不希望出现的副作用,即在自主测试运行的上下文中是不希望出现的。
例如:也许我们正在编写一个社交应用程序并想测试我们的新“发布到 Facebook 功能”,但不想在每次运行我们的测试套件时实际发布到 Facebook。
Python unittest
库包含一个名为unittest.mock
的子包——或者如果您将其声明为依赖项,则只需mock
——它提供了极其强大和有用的方法来模拟和排除这些不希望的副作用。
注意:从 Python 3.3 开始,标准库中新包含了mock
; 以前的发行版必须使用可通过 PyPI 下载的 Mock 库。
系统调用与 Python 模拟
再举一个例子,我们将在本文的其余部分使用这个例子,考虑系统调用。 不难看出,这些是模拟的主要候选对象:无论您是在编写用于弹出 CD 驱动器的脚本、从/tmp
中删除过时缓存文件的 Web 服务器,还是绑定到 TCP 端口的套接字服务器,这些在单元测试的上下文中调用所有功能不需要的副作用。
作为开发人员,您更关心您的库是否成功调用了用于弹出 CD 的系统函数(使用正确的参数等),而不是每次运行测试时实际体验到您的 CD 托盘打开。 (或者更糟糕的是,多次,因为多个测试在单个单元测试运行期间引用弹出代码!)
同样,保持单元测试高效和高性能意味着在自动化测试运行(即文件系统和网络访问)之外保留尽可能多的“慢代码”。
对于我们的第一个示例,我们将使用mock
将标准 Python 测试用例从原始形式重构为一个。 我们将演示如何使用模拟编写测试用例将使我们的测试更智能、更快,并能够更多地揭示软件的工作原理。
一个简单的删除函数
我们都需要不时地从文件系统中删除文件,所以让我们用 Python 编写一个函数,这将使我们的脚本更容易执行此操作。
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
显然,此时我们的rm
方法并没有提供比底层os.remove
方法更多的功能,但是我们的代码库将会改进,允许我们在这里添加更多功能。
让我们编写一个传统的测试用例,即没有模拟:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
我们的测试用例非常简单,但每次运行时,都会创建一个临时文件,然后将其删除。 此外,我们无法测试我们的rm
方法是否正确地将参数传递给os.remove
调用。 我们可以根据上面的测试假设它确实如此,但还有很多不足之处。
使用 Python Mocks 进行重构
让我们使用mock
重构我们的测试用例:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
通过这些重构,我们从根本上改变了测试的运行方式。 现在,我们有了一个Insider ,一个我们可以用来验证另一个功能的对象。
潜在的 Python 模拟陷阱
应该突出的第一件事是我们正在使用mock.patch
方法装饰器来模拟位于mymodule.os
的对象,并将该模拟注入到我们的测试用例方法中。 仅仅模拟os
本身而不是在mymodule.os
中对它的引用不是更有意义吗?
好吧,在导入和管理模块方面,Python 有点狡猾。 在运行时, mymodule
模块有自己的os
,该操作系统被导入到模块中自己的本地范围中。 因此,如果我们模拟os
,我们将不会在mymodule
模块中看到模拟的效果。
不断重复的口头禅是:
在使用它的地方模拟一个项目,而不是它来自哪里。
如果您需要为myproject.app.MyElaborateClass
模拟tempfile
模块,您可能需要将模拟应用于myproject.app.tempfile
,因为每个模块都保留自己的导入。
排除了这个陷阱,让我们继续嘲笑吧。
向“rm”添加验证
前面定义的rm
方法过于简单化了。 我们想让它在盲目地尝试删除它之前验证路径是否存在并且是一个文件。 让我们重构rm
让它更聪明一点:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
伟大的。 现在,让我们调整我们的测试用例以保持覆盖率。
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
我们的测试范式已经完全改变。 我们现在可以验证和验证方法的内部功能,而不会产生任何副作用。
使用模拟补丁将文件删除作为服务
到目前为止,我们只为函数提供模拟,而不是为对象上的方法或发送参数需要模拟的情况。 让我们首先介绍对象方法。
我们将从将rm
方法重构为服务类开始。 将这样一个简单的函数封装到一个对象中本身确实没有合理的需要,但它至少会帮助我们在mock
中演示关键概念。 让我们重构:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
您会注意到我们的测试用例没有太大变化:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
太好了,所以我们现在知道RemovalService
按计划工作。 让我们创建另一个将其声明为依赖项的服务:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
由于我们已经对RemovalService
进行了测试,因此我们不会在UploadService
的测试中验证rm
方法的内部功能。 相反,我们将简单地测试(当然没有副作用) UploadService
调用RemovalService.rm
方法,从我们之前的测试用例中我们知道该方法“正常工作”。

有两种方法可以解决这个问题:
- 模拟出
RemovalService.rm
方法本身。 - 在
UploadService
的构造函数中提供一个模拟实例。
由于这两种方法在单元测试中通常都很重要,因此我们将回顾这两种方法。
选项 1:模拟实例方法
mock
库有一个特殊的方法装饰器,用于模拟对象实例方法和属性, @mock.patch.object
装饰器:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
伟大的! 我们已经验证UploadService
成功调用了我们实例的rm
方法。 注意到里面有什么有趣的东西吗? 补丁机制实际上取代了我们测试方法中所有RemovalService
实例的rm
方法。 这意味着我们实际上可以检查实例本身。 如果您想了解更多信息,请尝试在您的模拟代码中插入断点,以更好地了解修补机制的工作原理。
模拟补丁陷阱:装饰器订单
在您的测试方法上使用多个装饰器时,顺序很重要,而且有点令人困惑。 基本上,当将装饰器映射到方法参数时,向后工作。 考虑这个例子:
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
注意我们的参数是如何与装饰器的相反顺序匹配的? 这部分是因为 Python 的工作方式。 使用多个方法装饰器,这是伪代码中的执行顺序:
patch_sys(patch_os(patch_os_path(test_something)))
由于sys
的补丁是最外层的补丁,它将最后执行,使其成为实际测试方法参数中的最后一个参数。 请注意这一点,并在运行测试时使用调试器,以确保以正确的顺序注入正确的参数。
选项 2:创建模拟实例
我们可以代替模拟特定的实例方法,而是提供一个模拟的实例给UploadService
及其构造函数。 我更喜欢上面的选项 1,因为它更精确,但在很多情况下,选项 2 可能是有效的或必要的。 让我们再次重构我们的测试:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
在这个示例中,我们甚至不需要修补任何功能,我们只需为RemovalService
类创建一个自动规范,然后将此实例注入到UploadService
中以验证功能。
mock.create_autospec
方法为提供的类创建一个功能等效的实例。 这实际上意味着,当与返回的实例进行交互时,如果以非法方式使用它将引发异常。 更具体地说,如果使用错误数量的参数调用方法,则会引发异常。 当重构发生时,这一点非常重要。 随着库的变化,测试中断,这是意料之中的。 如果不使用自动规范,即使底层实现被破坏,我们的测试仍然会通过。
陷阱: mock.Mock
和mock.MagicMock
类
mock
库还包括两个重要的类,大多数内部功能都基于这些类构建: mock.Mock
和mock.MagicMock
。 当给定选择mock.Mock
实例时, mock.MagicMock
实例或自动规范,始终使用自动规范,因为它有助于保持您的测试SANE以供将来更改。 这是因为mock.Mock
和mock.MagicMock
接受所有方法调用和属性分配,而不管底层 API 是什么。 考虑以下用例:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
我们可以使用这样的mock.Mock
实例来测试它:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
这个逻辑看起来很合理,但是让我们修改Target.apply
方法以获取更多参数:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
重新运行你的测试,你会发现它仍然通过了。 那是因为它不是针对您的实际 API 构建的。 这就是为什么您应该始终将create_autospec
方法和autospec
参数与@patch
和@patch.object
装饰器一起使用。
Python 模拟示例:模拟 Facebook API 调用
最后,让我们编写一个更适用的真实 Python 模拟示例,我们在介绍中提到过:向 Facebook 发布消息。 我们将编写一个不错的包装类和相应的测试用例。
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
这是我们的测试用例,它检查我们是否在没有实际发布消息的情况下发布消息:
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
正如我们目前所见,在 Python 中使用mock
编写更智能的测试非常简单。
结论
Python 的mock
库,如果使用起来有点混乱,它是单元测试的游戏规则改变者。 我们已经展示了在单元测试中开始使用mock
的常见用例,希望本文能帮助 Python 开发人员克服最初的障碍并编写出色的、经过测试的代码。