翻译自:Python Mocking 101: Fake It Before You Make It
欢迎看到这则使用Python语言模拟数据的基础指导。这篇博文诞生于我需要测试使用了大量网络服务的代码以及我使用GoMock的经验。GoMock向我展示了当操作正确时,模拟数据的功能是多么的强大。接下来我先从模拟的哲学讨论开始,因为好的模拟需要不同的思维而不是环境。开发是关于制造东西,而mocking是关于伪造东西。这似乎很明显,但模拟测试的“伪造”方面深入人心,理解这一点完全改变了人们对测试的看法。 之后,我们将研究 Python 提供的模拟工具,然后我们将完成一个完整的示例。
mocking很难去理解。当我测试我写的代码时,我想看到代码是否能够支持它在端到端之间应该支持的功能。我通常开始考虑一个功能性和完整性的测试,我输入真实数据然后得到真实的输出。我访问我的代码使用的每个真实系统,以确保这些系统之间的交互正常工作,使用真实对象和真实API调用。虽然这些类型的测试对于验证复杂系统是否能够良好交互是必不可少的,但他们并不是我们想要从单元测试中得到的。
单元测试是关于测试代码的最外层。集成测试是必要的,但我们运行的自动化单元测试不应该达到系统交互的深度。这意味着我们正在测试的函数中的任何API调用都可以而且应该被模拟出来。我们应该用模拟调用或对象替换任何重要的API调用或对象创建。这使得我们能够避免不必要的资源使用,简化测试的实例化,并减少他们的运行时间。考虑测试访问外部HTTP API的函数。我们可以模拟HTTP库并用模拟调用替换所有HTTP调用,而不是确保测试服务器可用于发送正确的响应。这降低了测试的复杂性和依赖性,并使我们能够精确控制HTTP库返回的内容,否则可能难以实现。
一、我们说mocking是什么意思?
mocking一词被广泛使用,本文档使用以下定义:
“用模糊调用或对象替换一个或多个函数调用或对象”。
模拟函数调用立即返回一个预定义的值,而不做任何工作。模拟对象的属性和方法同样完全在测试中定义的fan’h,不创建真实对象或做任何工作。测试的作者可以定义每个函数调用的返回值这一事实在测试时给了他或她巨大的权力,但这也意味着他/她需要做一些基础工作来正确设置一切。
在Python中,模拟是通过unittest.mock模块完成的。该模块包含许多有用的类和函数,其中最重要的是补丁函数(patch function,作为装饰器和上下文管理器)和MagicMock类。Python中的模拟主要是通过使用这两个强大的组件来完成的。
二、mocking不是什么意思?
开发人员使用大量"模拟"对象或模块,他们是网络服务和API的全功能本地替代品。例如,moto库是一个模拟boto库,它捕获所有botoAPI调用并在本地处理它们。虽然这些模拟允许开发人员在本地测试外部API,但它们仍然需要创建真实的对象。这不是本文档中涵盖的那种mocking。本文档专门关于使用MagicMock对象来全面管理被测函数的控制流,从而可以轻松测试故障和异常处理。
三、我们如何在 Python 中模拟?
Python 中的模拟是通过使用patch来劫持 API 函数或对象创建调用来完成的。 当patch拦截调用时,它默认返回一个MagicMock对象。 通过在MagicMock对象上设置属性,您可以模拟API调用以返回您想要的任何值或引发异常。
整体流程如下:
像使用真正的外部API一样编写测试。
在被测函数中,确定需要模拟出哪些API调用; 这应该是一个很小的数字。
在测试函数中,修补API调用。
设置MagicMock对象响应。
运行您的测试。
如果你的测试通过,你就完成了。 如果没有,您可能在被测函数中有错误,或者您可能错误地设置了MagicMock响应。 接下来,我们将更详细地介绍用于创建和配置模拟的工具。
四、patch
import unittest
from unittest.mock import patch
patch可以作为测试函数的装饰器,使用一个字符串命名将要被patch的函数,作为参数。为了让patch定位要修补的函数,必须使用其完全限定名称来指定它,这可能不是您所期望的。如果使用from module import ClassA语句导入类,则ClassA成为导入它的模块的命令空间的一部分。
举个例子吧,如果在模块my_module.py中导入一个类,如下所示:
[in my_module.py]
from module import ClassA
由于from……import……语句的语义,它必须被修补为@patch(my_module.ClassA),而不是@patch(module.ClassA),该语句将类和函数导入当前命名空间。
通常,patch用于修补外部 API 调用或任何其他时间或资源密集型函数调用或对象创建。 您应该只为每个测试修补几个可调用项。 如果您发现自己多次尝试打补丁,请考虑重构您的测试或您正在测试的功能。
使用补丁装饰器将自动向您正在装饰的函数(即您的测试函数)发送一个位置参数。 当修补多个函数时,最接近被装饰函数的装饰器首先被调用,因此它将创建第一个位置参数。
@patch('module.ClassB')
@patch('module.functionA')
def test_some_func(self, mock_A, mock_B):
...
默认情况下,这些参数是MagicMock的实例,它是unittest.mock的默认模拟对象。 您可以通过在返回的MagicMock实例上设置属性来定义修补函数的行为。
五、MagicMock
MagicMock 对象提供了一个简单的模拟接口,允许您设置您修补的函数或对象创建调用的返回值或其他行为。 这允许您完全定义调用的行为并避免创建可能很繁重的真实对象。 例如,如果我们正在修补对requests.get的调用,这是一个HTTP库调用,我们可以定义对该调用的响应,该响应将在被测函数中进行 API 调用时返回,而不是确保测试服务器可用于返回所需的响应。
MagicMock 实例的两个最重要的属性是 return_value 和 side_effect,这两个属性都允许我们定义PATCH调用的返回行为。
5.1 return_value
传递给测试函数的MagicMock实例上的return_value属性允许您选择patch后的可调用返回的内容。 在大多数情况下,您需要返回可调用通常返回的模拟版本。 这可以是 JSON、一个可迭代对象、一个值、一个真实响应对象的实例、一个伪装成响应对象的 MagicMock,或者其他任何东西。 打patch对象的时候,patched的调用就是创建对象的调用,所以MagicMock的return_value应该是一个mock对象,也可以是另一个MagicMock。
如果您正在测试的代码是 Pythonic 并且使用鸭子类型而不是显式类型,那么使用MagicMock作为响应对象会很方便。 您可以在MagicMock构造函数中定义任意属性键值对,而不是费心创建类的真实实例,它们将自动应用于实例。
[in test_my_module]
@patch(‘external_module.api_call’)
def test_some_func(self, mock_api_call):
mock_api_call.return_value = MagicMock(status_code=200,response=json.dumps({‘key’:‘value’}))
my_module.some_func()
[in my_module]
import external_module
def some_func():
response = external_module.api_call()
#normally returns a Response object, but now returns a MagicMock
#response == mock_api_call.return_value == MagicMock(status_code=200, response=json.dumps({‘key’:‘value’}))
请注意,传递给test_some_func的参数,即mock_api_call,是一个MagicMock,我们将return_value设置成另一个MagicMock。在mocking数据时,一切都是MagicMock。
5.2 指定一个MagicMock
虽然MagicMock的灵活性便于快速模拟具有复杂需求的类,但它也可能是一个缺点。默认情况下,MagicMocks 表现得好像它们具有任何属性,甚至是您不希望它们具有的属性。在上面的例子中,我们返回一个 MagicMock 对象而不是一个 Response 对象。然而,假设我们在补丁调用中犯了一个错误并修补了一个应该返回请求对象而不是响应对象的函数。我们返回的 MagicMock 仍然会像它具有 Request 对象的所有属性一样,即使我们打算让它建模一个 Response 对象。这会导致混淆测试错误和不正确的测试行为。
解决这个问题的方法是在创建它时指定 MagicMock,使用 spec 关键字参数:MagicMock(spec=Response)。这将创建一个 MagicMock,它只允许访问指定 MagicMock 的类中的属性和方法。尝试访问不在原始对象中的属性将引发 AttributeError,就像真实对象一样。一个简单的例子是:
m = MagicMock()m.foo()
#no error raised
# Response objects have a status_code attributem = MagicMock(spec=Response, status_code=200, response=json.dumps({‘key’:’value’}))m.foo()
#raises AttributeErrorm.status_code #no error raised
5.3 side_effect
有时您需要测试您的函数是否正确处理了异常,或者您正在修补的函数的多个调用是否得到正确处理。 您可以使用 side_effect 来做到这一点。 将 side_effect 设置为异常会在调用修补函数时立即引发该异常。
每次调用修补函数时,将 side_effect 设置为可迭代对象将返回可迭代对象的下一项。 将 side_effect 设置为任何其他值将返回该值。
[in test_my_module]
@patch('external_module.api_call')
def test_some_func(self, mock_api_call):
mock_api_call.side_effect = SomeException()
my_module.some_func()
[in my_module]
def some_func():
try:
external_module.api_call()
except SomeException:
print("SomeException caught!")
#this code is executed
except SomeOtherException:
print(“SomeOtherException caught!”)
# not executed[in test_my_module]
@patch('external_module.api_call')
def test_some_func(self, mock_api_call):
mock_api_call.side_effect = [0, 1]
my_module.some_func()[in my_module]
def some_func():
rv0 = external_module.api_call()
# rv0 == 0
rv1 = external_module.api_call()
# rv1 == 1
```
### 5.3 assert_call_with
assert_call_with 断言修补的函数是使用指定为 assert_call_with 的参数的参数调用的。
```python
[inside some_func]someAPI.API_call(foo, bar='baz')[inside test_some_func]some_func()mock_api_call.assert_called_with(foo, bar='baz')
六、一个完整的例子
在此示例中,我正在测试 Client.update 上的重试功能。 这意味着更新中的 API 调用将进行两次,这是使用 MagicMock.side_effect 的好时机。
示例的完整代码在这里:
import unittestfrom unittest.mock
import patchclass TestClient(unittest.TestCase):
def setUp(self):
self.vars_client = VarsClient()
@patch('pyvars.vars_client.VarsClient.get')
@patch('requests.post')def test_update_retry_works_eventually(self, mock_post, mock_get):
mock_get.side_effect = [VarsResponse(),VarsResponse()]
mock_post.side_effect = [requests.ConnectionError('Test error'),
MagicMock(status_code=200,
headers={
'content-type':"application/json"},
text=json.dumps({
'status':True})) ]
response = self.vars_client.update('test', '0')
self.assertEqual(response, response)
@patch('pyvars.vars_client.VarsClient.get')
@patch('requests.post')
def test_update_retry_works_eventually(self, mock_post, mock_get):
```
我正在测试被测函数中的两个调用(pyvars.vars_client.VarsClient.update),一个到 VarsClient.get,一个到 requests.post。 因为我要修补两个调用,所以我的测试函数有两个参数,我称之为 mock_post 和 mock_get。 这些都是 MagicMock 对象。 在默认状态下,它们不会做太多事情。 我们需要为它们分配一些响应行为。
```python
mock_get.side_effect = [ VarsResponse(), VarsResponse()]
mock_post.side_effect = [ requests.ConnectionError('Test error'),' MagicMock(status_code=200, headers={'content-type':"application/json"}, text=json.dumps({'status':True}))]
此测试以确保重试工具最终工作,因此我将多次调用更新,并多次调用 VarsClient.get 和 requests.post。
在这里我设置了我想要的 side_effects。 我希望对 VarsClient.get 的所有调用都能工作(返回一个空的 VarsResponse 对这个测试来说很好),第一次调用 requests.post 失败并出现异常,第二次调用 requests.post 工作。 这种对行为的细粒度控制只能通过模拟来实现。
response = self.vars_client.update('test', '0')self.assertEqual(response, response)
一旦我设置了 side_effects,剩下的测试就很简单了。 行为是:对 requests.post 的第一次调用失败,因此包装 VarsClient.update 的重试工具应该捕获错误,并且一切都应该在第二次工作。 这种行为可以通过检查 mock_get 和 mock_post 的调用历史来进一步验证。
七、结论
正确使用模拟对象违背了我们的直觉,即使测试尽可能真实和彻底,但这样做使我们能够编写快速运行且没有依赖关系的自包含测试。 它使我们能够测试异常处理和边缘情况,否则这些情况将无法测试。 最重要的是,它让我们可以自由地将测试工作集中在代码的功能上,而不是我们设置测试环境的能力上。 通过专注于测试重要的内容,我们可以提高测试覆盖率并提高代码的可靠性,这就是我们首先进行测试的原因。
文档链接
https://docs.python.org/3/library/unittest.mock.html
文章评论