本文概述
如何在Python中运行单元测试而不测试你的耐心
我们编写的软件通常会与我们称为”脏”服务的软件直接交互。用外行的话来说:对我们的应用程序至关重要的服务, 但是它们的交互具有预期的但不希望的副作用, 即在自动测试运行中是不希望的。
例如:也许我们正在编写一个社交应用, 并想测试新的”发布到Facebook功能”, 但又不想每次运行测试套件时都实际发布到Facebook。
Python单元测试库包含一个名为unittest.mock的子程序包(或者, 如果你将其声明为依赖项, 则只是简单地模拟), 它提供了非常强大且有用的方法来模拟和消除这些不良副作用。
注意:从Python 3.3开始, mock新包含在标准库中;之前的发行版将不得不使用可通过PyPI下载的Mock库。
系统调用与Python模拟
为了给你提供另一个示例, 以及本文后面将使用的示例, 请考虑系统调用。不难看出, 这些是首选的模拟对象:无论是编写脚本以弹出CD驱动器, 从/ tmp删除过时的缓存文件的Web服务器, 还是绑定到TCP端口的套接字服务器, 这些在单元测试的上下文中, 将所有功能的不良影响称为。
作为开发人员, 你更关心的是你的库成功调用了用于弹出CD的系统函数, 而不是每次运行测试时都会打开CD托盘。
作为开发人员, 你更关心的是你的库成功调用了用于弹出CD(带有正确的参数等)的系统函数, 而不是每次运行测试时实际打开CD托盘。 (或更糟糕的是, 多次, 因为多个测试在一次单元测试运行中会引用弹出代码!)
同样, 保持单元测试高效和高性能意味着在自动测试运行中保持尽可能多的”慢代码”, 即文件系统和网络访问。
对于第一个示例, 我们将使用模拟将标准的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重构
让我们使用模拟重构测试用例:
#!/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")
通过这些重构, 我们从根本上改变了测试的运行方式。现在, 我们有了一个内部人员, 一个可以用来验证另一个对象功能的对象。
潜在的Python模拟陷阱
首先需要强调的一件事是, 我们正在使用mock.patch方法装饰器来模拟位于mymodule.os上的对象, 并将该模拟注入到我们的测试用例方法中。仅仅模拟操作系统本身, 而不是在mymodule.os上引用它, 是否更有意义?
好吧, 在导入和管理模块方面, Python有点像偷偷摸摸的蛇。在运行时, mymodule模块具有自己的操作系统, 该操作系统被导入模块中自己的本地范围。因此, 如果我们模拟操作系统, 则不会在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方法重构为服务类开始。实际上, 将这样一个简单的函数封装到一个对象中实际上并没有合理的需要, 但这至少将帮助我们演示模拟中的关键概念。让我们重构一下:
#!/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方法, 该方法在我们先前的测试案例中称为” just works™”。
有两种解决方法:
- 模拟出RemovalService.rm方法本身。
- 在UploadService的构造函数中提供模拟的实例。
由于这两种方法在单元测试中通常都很重要, 因此我们将对其进行复习。
选项1:模拟实例方法
模拟库具有一个用于模拟对象实例方法和属性的特殊方法装饰器, 即@ 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.MagicMock。如果可以选择使用ock.mock实例, mock.MagicMock实例或自动规范, 则总是喜欢使用自动规范, 因为它有助于使测试保持健全, 以备将来更改。这是因为mock.Mock和嘲笑.MagicMock接受所有方法调用和属性分配, 而与基础API无关。考虑以下用例:
class Target(object):
def apply(value):
return value
def method(target, value):
return target.apply(value)
我们可以用一个模拟ock.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中使用模拟程序编写更智能的测试。
总结
Python的模拟库(如果使用起来有些混乱)是改变单元测试的游戏规则。我们已经演示了在单元测试中开始使用模拟的常见用例, 希望本文能够帮助Python开发人员克服最初的障碍并编写出色的, 经过测试的代码。
评论前必须登录!
注册