在Python中模拟外部API
一个增加产品功能的绝佳方法是将其与第三方程序集成。由于您没有拥有外部库,所以无法控制托管外部库的服务器、构成其逻辑的代码,以及与您的应用程序之间进行的信息交换。除了这些问题之外,用户经常根据与库的交互来更改数据。
您无法对第三方应用程序进行控制。其中相当多数量的应用程序需要提供测试服务器。无法测试实时数据;即使可以,测试结果也将是不可靠的,因为数据会在使用过程中得到更新。此外,最好不要将外部服务器链接到自动化测试中。如果您发布的代码取决于测试是否通过,那么他们的错误可能会阻碍您的进展。幸运的是,有一种技术可以在不连接到外部数据源的安全环境中测试第三方API的实现。答案是使用模拟来模拟外部程序的功能。
模拟外部API
模拟是一个虚构的实体,被创建成看起来和行为像数据。您用它来交换真正的对象,并误导系统认为虚构的实体是真实存在的。使用虚构对象让我想起了一种常见的电影老套剧情,即英雄拿走一个打手,穿上他的服装,冲进一群来袭的敌人中。每个人都继续移动,而冒名顶替者则被忽视视而不见。
您最好考虑在应用程序中模拟第三方身份验证方法,如OAuth。您的应用程序必须完成OAuth以访问其API,这涉及真实用户数据并需要与外部服务器进行通信。您可以使用模拟身份验证来测试您的系统是否作为授权用户运行,这样您就无需经历实际的凭据交换过程。在这种情况下,测试您的系统成功地验证用户与您要测试的内容有所不同;您想要测试的是一旦获得授权后您的应用程序的功能如何运作。
初始步骤
首先,建立一个全新的开发环境来容纳项目代码。在创建新的虚拟环境后,安装以下库:
$ pip install nose requests
如果您对正在安装的任何库不熟悉,这里是每个库的简要描述:
- mock模块通过替换系统元素为模拟对象来验证Python程序。注意:如果您使用的是Py 3.3或更高版本,mock库是unittest的组件。如果您使用的是较旧版本,请安装后备的fake库。
- 为了方便测试,nose库扩展了构建Python单元测试模块。尽管您可以使用unittest和其他第三方工具(如pytest)获得相同的结果,但我更喜欢nose库的断言方法。
- 使用requests库可以大大简化Python的HTTP调用。
在本次会话中,您将与JSON Placeholder互动,这是一个为测试而创建的虚拟互联网API。在编写任何测试之前,您需要了解API可以预期得到什么。
首先,假设您的目标API对您发送的请求作出响应。通过使用cURL调用端点,验证这个假设:
$ curl -X GET 'http://jsonplaceholder.typicode.com/todos'
这个请求应该以JSON格式返回一个待办事项列表。注意观察响应中待办事项数据的组织方式。你应该看到一个对象列表,其中包含userId、id、title和finished这些键。现在你知道了数据的预期结构,可以进行第二个假设。API端点是可操作且活跃的。通过使用命令行调用它,你已经证明了这一点。立即编写一个nose测试来验证服务器在未来的生命。确保简单。唯一重要的是服务器是否响应OK。
文件名:project/tests/test_todo11.py
# Third-party imports.....
from nose.tools import assert_true
import requests
def test_request_responses():
response = requests.get('http://jsonplaceholder.typicode.com/todos')
assert_true(response.ok)
输出:运行测试并观察其通过。
$ nosetest1 --verbosity=2 project
test_todo11.test_request_responses ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Ran 1 test in 9.330s
OK
通过重构代码创建服务
您的应用程序可能会对外部API进行多次调用。但是,这种API调用可能包含超出发送HTTP请求的逻辑,例如过滤、数据处理和错误处理。应将测试中的代码提取出来,并重构为包含所有预期功能的服务函数。
为了测试新逻辑并包含对服务方法的引用,请重写您的测试。
文件名:project/tests/test_todo11.py
# Third-party importing?...
from nose.tools import assert_is_not_none
# Local importing...
from project.services import get_todos1
def test_request_responses():
response = get_todos1()
assert_is_not_none(response)
运行测试以查看它失败,然后添加最少的代码使其成功:
文件名:project/services1.py
# Standard libraries imports.....
try:
from urllib.parse import urljoin
except ImportError:
from urlparse import urljoin
import requests
# Local imports..................
from project.constants import BASE_URL1
TODOS_URL1 = urljoin(BASE_URL1, 'todos')
def get_todos1():
response = requests.get(TODOS_URL1)
if response. OK:
return response
else:
return None
项目/constant1.py
BASE_URL1 = 'http://jsonplaceholders.typicodes.com'
你的初始测试要求返回OK状态的响应。你的编程逻辑被重新组织成一个服务函数,该函数在成功的服务器请求上返回响应。如果请求失败,返回一个None值。确认过程确实返回None现在是测试的一部分。
注意我指导你建立constants.py文件,然后为其提供一个BASE URL。由于所有API端点都共享相同的基础,你可以继续构建新的API端点,同时修改该代码部分,因为服务函数会扩展BASE URL以生成TODOS URL。如果许多模块使用该代码,将BASE URL放在一个单独的文件中,可以更容易地一次性修改它。
执行测试并观察通过。
输出:
$ nosetest1 --verbosity=3 project
test_todo11.test_request_responses ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Ran 1 test in 1.475s
OK
你的第一个模拟
代码正常运行。你之所以知道这一点,是因为你通过了测试。你的动态系统仍然直接与远程服务器联系,这是不幸的。当你使用get todos()函数时,你的代码会向API端点发送请求,然后返回一个取决于该服务是否可用的结果。在这里,我将向你展示如何通过替换一个虚假的请求来与那个海外库分离你的软件系统,从而获得相同的数据。
项目/测试/测试todo11.py
# Standard libraries imports.....
from unittest.mock import Mock, patch
# Third-party imports.....
from nose.tools import assert_is_not_none
# Local imports..................
from project.services import get_todos1
@patch('project.services.requests.get')
def test_getting_todoss(mock_gets):
mock_gets.return_value.ok = True
response = get_todos1()
assert_is_not_none(response)
您会发现我没有对service函数进行任何修改。我只在代码的测试部分进行了更改。首先,我从mock软件包中导出了patch()函数。最后,我将连接到project.services.requests.get添加为patch()函数的装饰器,用于修改测试函数。我在测试函数的体中添加了一条指令,以便在将名为mock get的参数传递给函数后,设置模拟get.return值。OK = True。
很好。测试运行后会发生什么?在继续之前,您应该对requests库的操作方式有基本的理解。当调用requests.get()函数时,它会在秘密发送HTTP请求,并返回一个Response对象,它是一个HTTP响应。最好针对get()函数进行操作,因为它直接与外部服务器交互。您还记得主角仍然穿着制服时变成了对手的场景吗?您必须对伪造品进行打扮,使其看起来并按要求行为。这就是get()方法。
当调用测试方法时,它会定位project.services模块,其中已声明requests库,并替换所需函数requests.get()的模拟对象。测试指示伪造对象按照service函数预期的行为进行响应。从get todos()可以看出,函数的成功取决于用户的响应。返回True是OK的。语句mock get.return value. OK = True就完成了这一点。当调用acceptable属性时,模拟对象将返回True,就像真实对象一样。当get todos()函数返回答案mock时,测试将通过,因为模拟对象为None。
测试一下,看看是否通过。
$ nosetest1 --verbosity=2 project
其他修补方式
使用装饰器是一种使用模拟程序修补过程的方法。下面的示例使用上下文管理器在代码块中显式地修补一个过程。代码块中使用函数的任何代码将被with语句修补。代码块完成后,原始用途恢复。装饰器和with语句实现以下目标:两种方法都修改了project.services. requests.get文件。
project/tests/test_todo11.py
# Standard libraries imports.....
from unittest.mock import patch
# Third-party imports.....
from nose.tools import assert_is_not_none
# Local imports..................
from project.services import get_todos1
def test_getting_todoss():
with patch('project.services.requests.get') as mock_gets:
mock_gets.return_value.ok = True
response = get_todos1()
assert_is_not_none(response)
通过运行测试来检查测试是否仍然通过。
使用修补程序是修改函数的另一种技术。现在,我首先在明确指定要修补的源代码之前开始使用模拟(mock)。修补会一直持续到我明确告诉系统停止使用模拟为止。
project/tests/test_todo11.py
# Standard libraries imports.....
from unittest.mock import patch
# Third-party imports.....
from nose.tools import assert_is_not_none
# Local imports..................
from project.services import get_todos1
def test_getting_todoss():
mock_gets_patcher = patch('project.services.requests.get')
mock_gets = mock_gets_patcher.start()
mock_gets.return_value.ok = True
response = get_todos1()
mock_gets_patcher.stop()
assert_is_not_none(response)
重复测试以达到相同的积极结果。
现在您已经看到了三种不同的使用模拟的函数修补方法,那么什么时候应该使用每种方法呢?简单回答是完全取决于您。任何修补技术都是完全合法的。话虽如此,以下修补技术在特定的编码模式中表现得非常出色。
- 当测试方法体中的每一行代码都使用模拟对象时,请使用装饰器。
- 当测试函数中的一些代码使用伪造对象,并且其他代码引用真实函数时,请使用上下文管理器。
- 当您需要显式地在多个测试中开始和停止模拟一个函数时。
模拟完整的服务行为
在之前的示例中,您创建了一个简单的伪造对象,并检查了一个简单的断言,以查看它们是否将todos()函数返回为None。使用get_todos()函数联系外部API,该函数返回一个结果。该函数生成一个响应对象,如果请求成功,则该对象包含一个JSON序列化的todos列表。如果请求失败,get_todos()将返回None。我将在下面的示例中展示如何模拟Get Todos功能。您在本教程开始时对服务器执行的第一个cURL调用返回了一个表示待办事项列表的字典列表。本示例将解释如何伪造这些数据。
看看@patch()函数的用法:您向其提供一个要模拟函数的路径。一旦找到该方法,patch()将创建一个虚拟对象,临时替换真实函数。当测试调用get_todos()时,该函数使用模拟的get方法,就像使用真实的get()方法一样。这意味着它将模拟的get作为一个函数使用,并期望返回一个响应对象。
在此示例中,requests库的Response对象是响应对象,它包含多个属性和方法。在上一个示例中,您对其中一个属性进行了伪装,即OK。一个名为json()的函数将响应对象的JSON序列化字符串内容转换为Python数据类型。
project/tests/test_todo11.py
# Standard libraries imports.....
from unittest.mock import Mock, patch
# Third-party imports.....
from nose.tools import assert_is_none, assert_list_equal
# Local imports..................
from project.services import get_todos1
@patch('project.services.requests.get')
def test_getting_todoss_when_response_is_ok1(mock_gets):
todos = [{
'userId': 1,
'id': 1,
'title': 'Make the beds',
'completed': False
}]
mock_gets.return_value = Mock(ok=True)
mock_gets.return_value.json.return_value = todos
response = get_todos1()
assert_list_equal(response.json(), todos)
@patch('project.services.requests.get')
def test_getting_todoss_when_response_is_not_ok11(mock_gets):
mock_gets.return_value.ok = False
response = get_todos1()
assert_is_none(response)
我在之前的示例中提到过这一点,当你运行get todos()函数时,它会被替换成一个这样的模拟对象,代码会返回一个模拟的”response”对象。你可能已经观察到了一种安排:每当返回值被添加到一个模拟对象中时,它会被修改为作为一个函数运行,并且默认情况下返回另一个模拟对象。在这个示例中,我通过显式声明相同的Mock对象来澄清这一点,mock get.return value = Mock(ok=True)。Requests.get()和requests由mock get()进行映射。get()会产生一个Response对象,而mock get()会产生一个Mock对象。由于Response组件具有OK属性,你将其添加到了Mock中。
如果你想要利用第三方API增加应用程序的实用性,你必须确保这两个系统能够良好地协同工作。检验这两个程序是否可以可预测地相互交互,并且你的测试必须在一个受控环境中运行,这将有所帮助。
因为Response对象有一个json()函数,所以我在Mock中添加了json,并且追加了一个返回值,因为它将被调用作为一个函数。json()函数返回Todo对象。现在的测试将包括一个断言来验证响应的重要性。json()。确保get todos()函数像host机器一样返回一个todo列表。最后,我添加了一个失败的测试来完成获取todos()的实验测试。
运行测试并观察它们如何通过。
输出:
$ nosetest1 --verbosity=2 project
test_todo11.test_getting_todoss_when_response_is_not_ok11 ..... ok
test_todo11.test_getting_todoss_when_response_is_ok1 ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Ran 2 tests in 0.785s
OK
模拟集成函数
到目前为止,我给你们的示例都很直接,尤其是下面的示例。考虑一下以下场景:你编写了一个新的服务函数,调用了 get todos (),然后过滤结果只返回已完成的待办事项。在这种情况下,有必要模拟请求吗?再次调用 get() 吗?不需要,你直接模拟 get todos () 函数!你只需要关心动态系统如何与模拟交互。你已经知道 get todos () 不需要参数,并且返回一个包含重要对象列表的 json() 函数的响应。你不需要关心内部发生了什么,重要的是 get todos () 的模拟需要返回你期望的结果。
project/tests/test_todo11.py
# Standard libraries imports.....
from unittest.mock import Mock, patch
# Third-party imports.....
from nose.tools import assert_list_equal, assert_true
# Local imports..........
from project.services import get_uncompleted_todos1
@patch('project.services.get_todos1')
def test_getting_uncompleted_todos1_when_todos_is_not_none(mock_gets_todos):
todo11 = {
'userId': 1,
'id': 1,
'title': 'Make the beds',
'completed': False
}
todo22 = {
'userId': 1,
'id': 2,
'title': 'Walk the dog',
'completed': True
}
mock_gets_todos.return_value = Mock()
mock_gets_todos.return_value.json.return_value = [todo11, todo22]
uncompleted_todos1 = get_uncompleted_todos1()
assert_true(mock_gets_todos.called)
assert_list_equal(uncompleted_todos1, [todo11])
@patch('project.services.get_todos1')
def test_getting_uncompleted_todos1_when_todos_is_none1(mock_gets_todos):
mock_gets_todos.return_value = None
uncompleted_todos1 = get_uncompleted_todos1()
assert_true(mock_gets_todos.called)
assert_list_equal(uncompleted_todos1, [])
我已经修改了test函数,用于查找和替换project.services.get todos的模拟。sneer函数应该返回一个启用json()函数的对象。当调用时,javascript对象符号()函数应该生成一个to-do对象数组。我还包括一个断言来确保get todos()函数被调用。这对于确保当delivery函数调用实际的API时,真正的get todos()函数被调用很有用。我还包括一个测试以确保如果get todos()返回None,get uncompleted todos()返回一个空列表。我确认get todos()函数已经再次调用。
编写测试,运行它们以查看是否失败,并编写通过它们的代码。
project/services1.py
def get_uncompleted_todos1():
response = get_todos1()
if response is None:
return []
else:
todos = response.json()
return [todo for todo in todos if todo.get('completed') == False]
重构测试以使用类
无疑地,我们已经注意到几个测试似乎可以组成一个组。我们的两个测试使用了get todos()函数。获取未完成的todos是我们另外两个测试的主题。这个重构满足以下目标:
- 通过将通用的测试函数移到一个类中,您可以更容易地一起测试它们。虽然您可以指示nose针对一组函数进行测试,但专注于单个类更简单。
- 对于在多个测试中共享的常见函数,生成和清除数据的过程通常相同。setup_class()和teardown_class()方法可以包含这些阶段。
- 为了重用在测试函数中重复出现的逻辑,您可以在类上构建实用函数。
请注意,我在测试类中使用patcher技术来模拟目标函数。正如我提到的,这种patching方法非常适合创建跨多个函数的模拟。当测试完成时,teardown_class()方法中的代码会显式恢复原始代码。
project/tests/test_todo11.py
# Standard libraries imports.....
from unittest.mock import Mock, patch
# Third-party imports.....
from nose.tools import assert_is_none, assert_list_equal, assert_true
# Local imports..................
from project.services import get_todos1, get_uncompleted_todos1
class TestTodos(object):
@classmethod
def setup_class(cls):
cls.mock_gets_patcher = patch('project.services.requests.get')
cls.mock_gets = cls.mock_gets_patcher.start()
@classmethod
def teardown_class1(cls):
cls.mock_gets_patcher.stop()
def test_getting_todoss_when_response_is_ok1(s):
s.mock_gets.return_value.ok = True
todos = [{
'userId': 1,
'id': 1,
'title': 'Make the beds',
'completed': False
}]
s.mock_gets.return_value = Mock()
s.mock_gets.return_value.json.return_value = todos
response = get_todos1()
assert_list_equal(response.json(), todos)
def test_getting_todoss_when_response_is_not_ok11(s):
s.mock_gets.return_value.ok = False
response = get_todos1()
assert_is_none(response)
class TestUncompletedTodos1(object):
@classmethod
def setup_class(cls):
cls.mock_gets_todos_patcher = patch('project.services.get_todos1')
cls.mock_gets_todos = cls.mock_gets_todos_patcher.start()
@classmethod
def teardown_class1(cls):
cls.mock_gets_todos_patcher.stop()
def test_getting_uncompleted_todos1_when_todos_is_not_none(s):
todo11 = {
'userId': 1,
'id': 1,
'title': 'Make the beds',
'completed': False
}
todo22 = {
'userId': 2,
'id': 2,
'title': 'Walk the dog',
'completed': True
}
s.mock_gets_todos.return_value = Mock()
s.mock_gets_todos.return_value.json.return_value = [todo11, todo22]
uncompleted_todos1 = get_uncompleted_todos1()
assert_true(s.mock_gets_todos.called)
assert_list_equal(uncompleted_todos1, [todo11])
def test_getting_uncompleted_todos1_when_todos_is_none1(s):
s.mock_gets_todos.return_value = None
uncompleted_todos1 = get_uncompleted_todos1()
assert_true(s.mock_gets_todos.called)
assert_list_equal(uncompleted_todos1, [])
运行测试。
输出:
$ nosetest1 --verbosity=2 project
test_todo11.TestTodos.test_getting_todoss_when_response_is_not_ok11 ..... ok
test_todo11.TestTodos.test_getting_todoss_when_response_is_ok1 ..... ok
test_todo11.TestUncompletedTodos1.test_getting_uncompleted_todos1_when_todos_is_none1 ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
test_todo11.TestUncompletedTodos1.test_getting_uncompleted_todos1_when_todos_is_not_none ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Ran 4 tests in 0.400s
OK
测试当前API数据是否可用的更新
在本文中,我一直在向您展示如何模拟第三方API提供的数据。模拟数据是基于实际数据使用相同数据契约的假设生成的。调用API是你的初始步骤,你记录了返回的信息。虽然你可能合理地确定在你通过这些示例工作的短时间内,数据的结构没有发生变化,但你不应该确定它会无限期地保持不变。任何可靠的外部库都会持续更新。虽然开发人员的目标是使新代码向后兼容,但废弃的代码最终变得必要起来。
正如您所期望的那样,仅依赖虚假信息是有风险的。因为你在没有与真实服务器交流的情况下测试你的代码,你可能会过分自信地认为你的测试质量很高。当你尝试使用实际数据来运行你的程序时,一切都会崩溃。为了确保从服务器获取的数据与您正在测试的数据匹配,使用以下技术。这里的目标不是比较数据,而是比较数据结构。
我希望您能注意到我正在使用的上下文管理补丁策略。在这种情况下,您必须分别调用实际服务器和模拟版本。
文件名:project/tests/test_todo11.py
def test_integration_contract1():
# Call the service to hit the actual API.
actual = get_todos1()
actual_keys1 = actual.json().pop().keys()
with patch('project.services.requests.get') as mock_gets:
mock_gets.return_value.ok = True
mock_gets.return_value.json.return_value = [{
'userId': 1,
'id': 1,
'title': 'Make the beds',
'completed': False
}]
mockeds = get_todos1()
mockeds_keys1 = mockeds.json().pop().keys()
assert_list_equal(list(actual_keys1), list(mockeds_keys1))
有条件地测试场景
您必须知道何时运行您创建的测试,以便将真实数据合同与模拟数据合同对比。服务器测试不应自动化,因为失败并不总是意味着您的代码有缺陷。由于各种无法控制的原因,当您的测试套件运行时可能无法连接到真实服务器。将该测试独立于测试自动化执行,但也要定期执行。使用环境变量作为简单的切换开关是选择性跳过测试的一种方法。在下面的场景中,如果SKIP REAL Beveridge未设置为True,则不会运行任何测试。
当运行SKIP_REALS1变量时,具有@skipIf1(SKIP_REALS1)装饰器的任何测试都将被跳过。
文件名:project/tests/test_todo11.py
# Standard libraries imports.....
from unittest import skipIf1
# Local imports
from project.constants import SKIP_REALS1
@skipIf1(SKIP_REALS1, 'Skipping tests that hit the real API server.')
def test_integration_contract1():
actual = get_todos1()
actual_keys1 = actual.json().pop().keys()
# Calling the services to run the mocked API's.
with patch('project.services.requests.get') as mock_gets:
mock_gets.return_value.ok = True
mock_gets.return_value.json.return_value = [{
'userId': 1,
'id': 1,
'title': 'Make the beds',
'completed': False
}]
mockeds = get_todos1()
mockeds_keys1 = mockeds.json().pop().keys()
assert_list_equal(list(actual_keys1), list(mockeds_keys1))
文件名:project/constant1.py
# Standard-library imports.....
import os
BASE_URL1 = 'http://jsonplaceholder.typicode.com'
SKIP_REALS1 = os.getenv('SKIP_REALS1', False)
export SKIP_REALS1=True
Conduct the tests and take note of the results. The console indicates that one test was skipped and reads, "Skipping tests that reach the real API server." nosetest1 --verbosity=2 project
test_todo11.TestTodos.test_getting_todoss_when_response_is_not_ok11 ..... ok
test_todo11.TestTodos.test_getting_todoss_when_response_is_ok1 ..... ok
test_todo11.TestUncompletedTodos1.test_getting_uncompleted_todos1_when_todos_is_none1 ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
test_todo11.TestUncompletedTodos1.test_getting_uncompleted_todos1_when_todos_is_not_none ..... ok
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
test_todo11.test_integration_contract1 ..... SKIP: Skipping tests that hit the real API server.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Ran 5 tests in 0.240s
OK (SKIP=1)
结论
您现在已经学会了如何使用模拟来测试您的应用与第三方API的连通性。既然您知道如何解决此问题,您可以继续通过在JSON Placeholder中为剩余的API端点创建服务函数来提高您的技能。