模拟 Python 中的外部 API

2024 年 8 月 29 日 | 阅读 17 分钟

将您的产品与第三方程序集成是增强其功能的一种绝佳方法。我们无法控制外部库所在的服务器、构成其逻辑的代码,也无法控制其与您的应用之间交换的信息,因为您并不拥有该外部库。除了这些问题,用户还经常因为与库的交互而更改数据。

您可能无法控制第三方应用程序。它们中很少有提供测试服务器的。实时数据无法测试;即使可以,测试结果也不可靠,因为数据会在使用过程中更新。此外,最好永远不要将外部服务器链接到您的自动化测试。如果代码的发布取决于测试是否通过,那么对方的错误可能会终止您的进程。幸运的是,有一种技术可以在安全的环境中测试第三方 API 的实现,而无需连接到外部数据源。答案是使用模拟(mocks)来模拟外部程序的功能。

模拟外部 API

模拟(mock)是一个为了看起来和行为都像真实数据而创建的虚构实体。您用它来替换真实对象,并误导系统,让系统认为这个假的是真的。使用模拟让我想起一个常见的电影桥段:英雄抓住一个暴徒,穿上他的服装,冲进一群迎面而来的敌人中。每个人都继续行动,而这个冒名顶替者没有被发现——一切照旧。

最好考虑在您的应用程序中模仿第三方身份验证方法,如 OAuth。您的应用程序必须完成 OAuth 才能访问其 API,这涉及真实的用户数据并需要与外部服务器通信。您可以使用模拟身份验证来测试您的系统,就好像您是一个已授权的用户,这使您不必经历真实的凭据交换过程。在这种情况下,测试您的系统成功验证用户的能力与您想要测试的内容不同;您想要测试的是您的应用程序功能在获得授权后如何运作。

初始步骤

首先,设置一个新的开发环境来存放项目代码。然后,在创建新的虚拟环境后,应安装以下库:

如果您对正在安装的任何库不熟悉,这里是每个库的简要说明:

  • mock 模块通过用模拟对象替换系统元素来验证 Python 程序。注意:如果您使用的是 Python 3.3 或更高版本,mock 库是 unittest 的一个组件。如果您使用的是旧版本,请安装向后移植的 mock 库。
  • 为了方便测试,nose 库扩展了内置的 Python unittest 模块。虽然您可以使用 unittest 和其他第三方工具(如 pytest)获得相同的结果,但我更喜欢 nose 的断言方法。
  • requests 包大大简化了 Python 的 HTTP 调用。

在本课程中,您将与 JSON Placeholder 互动,这是一个为测试而创建的虚拟在线 API。在编写任何测试之前,您需要知道对该 API 有何期待。

首先,假设您所针对的 API 会响应您发送给它们的请求。通过使用 cURL 调用端点来验证这个假设:

此请求应以 JSON 格式返回一个待办事项列表。请密切注意响应中待办事项数据的组织方式。您应该会看到一个对象列表,其中包含 userId、id、title 和 finished 等键。现在您知道了对数据的期望,您可以做出第二个假设。API 端点是可操作且活跃的。您通过在命令行调用它来证明了这一点。立即编写一个 nose 测试,以验证服务器将来是否处于活动状态。确保简单。唯一重要的是服务器是否以 OK 状态响应。

文件名:project/tests/test_todo11.py

输出:运行测试,看它通过。

$ 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

运行测试看它失败,然后添加最少的代码使其成功。

文件名:project/services1.py

project/constant1.py

您最初的测试要求响应状态为 OK。您的编程逻辑被重组为一个服务函数,在服务器请求成功时返回响应。如果请求不成功,则返回一个 None 值。现在的测试包含了断言,即该过程确实不返回 None。

请注意我是如何引导您创建一个 constants.py 文件,然后为其提供一个 BASE URL 的。因为所有的 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 端点发送请求,然后返回一个取决于该服务可用性的结果。在这里,我将向您展示如何通过用一个产生相同数据的虚假请求替换真实请求,从而将您的软件系统与外部库分离开来。

project/tests/test_todo11.py

您会看到我没有对服务函数做任何修改。我只更改了代码的测试部分。首先,我从 mock 包中导出了 patch() 函数。最后,我添加了一个指向 project.services.requests.get 的连接作为 patch() 函数的装饰器,我用它来修改测试函数。我在测试函数的主体中包含了一条指令,用于在传入一个名为 mock_get 的参数后设置 mock_get.return_value.ok = True。

很好。那么,在测试运行后会发生什么呢?在我继续之前,您需要对 requests 库的运作方式有一个基本的了解。requests.get() 函数在被调用时会秘密地发送一个 HTTP 请求,并返回一个 Response 对象,这是一个 HTTP 响应。最好是针对 get() 函数,因为它直接与外部服务器交互。您还记得英雄换上对手的制服然后混入其中的场景吗?您必须将这个模拟对象装扮得看起来和行为都像 requests.get() 方法。

当测试方法被调用时,它会找到声明了 requests 库的 project.services 模块,并用一个模拟对象替换掉目标函数 requests.get()。测试指示该模拟对象按照服务函数预期的方式进行响应。从 get_todos() 中您可以看到,该函数的成功取决于用户的响应。ok 返回 True。语句 mock_get.return_value.ok = True 实现了这一点。当调用 ok 属性时,模拟对象将返回 True,就像真实对象一样。由于当 get_todos() 函数返回响应时,模拟对象不是 None,测试将会通过,而这个响应就是 mock 对象。

测试一下,看看它是否通过。

其他 patch 的方法

用模拟对象来 patch 一个过程的一种方法是使用装饰器。下一个示例使用上下文管理器在一个代码块内显式地 patch 一个过程。任何使用该函数的代码块内的代码都会被 with 语句 patch。代码块完成后,原始功能会恢复。装饰器和 with 语句实现了以下目标:两种方法都修改了 project.services.requests.get 文件。

project/tests/test_todo11.py

运行测试,检查它们是否仍然通过。

使用 patcher 是修改函数的另一种技术。现在,我首先明确开始使用模拟,然后确定要 patch 的源。patching 会一直持续,直到我明确告诉我的系统停止使用模拟。

project/tests/test_todo11.py

重复测试以获得同样积极的结果。

现在您已经看到了三种用模拟对象 patch 函数的不同方法,那么应该在什么时候应用每种方法呢?简单的回答是,这完全取决于您。任何 patch 技术都是完全合法的。话虽如此,以下的 patch 技术在特定的编码模式下表现得非常好。

  1. 当您测试方法主体中的每一行代码都使用模拟时,请使用装饰器。
  2. 当您的测试函数中有些代码使用模拟,而其他代码引用真实函数时,请使用上下文管理器。
  3. 当您需要在多个测试中显式地开始和停止模拟一个函数时。

模拟完整的服务行为

在前面的例子中,您创建了一个简单的模拟,并检查了一个简单的断言,看 get_todos() 函数是否返回 None。get_todos() 函数用于联系外部 API 并返回结果。如果请求成功,该函数会生成一个响应对象,其中包括一个 JSON 序列化的待办事项列表。如果请求失败,get_todos() 返回 None。我在下面的例子中展示了如何模拟 get_todos() 的功能。您在本教程开始时对服务器进行的第一个 cURL 调用返回了一个代表您待办事项列表的 JavaScript 字典对象列表。这个例子将解释如何模拟这些数据。

看看 @patch() 是如何工作的:您给它一个指向被模拟函数的路径。一旦找到该方法,patch() 就会创建一个虚拟对象,临时替代真实函数。当测试调用 get_todos() 时,该函数会像使用真实 get() 方法一样使用 mock_get。这意味着它将 mock_get 作为一个函数使用,并期望返回一个响应对象。

在这种情况下,响应对象是 requests 库的 Response 对象,它包含多个特性和方法。您在前面的例子中伪造了其中一个特性,即 ok。一个名为 json() 的函数将 Response 对象的 JSON 序列化字符串内容转换为 Python 数据类型。

project/tests/test_todo11.py

我在前面的例子中提到过这一点,因为当您运行已经被模拟 patch 过的 get_todos() 函数时,代码返回了一个模拟对象“响应”。您可能已经观察到一个规律:每当 return_value 被添加到模拟对象时,它就会被修改为像函数一样运行,并且默认返回另一个模拟对象。在这个例子中,我通过显式声明 Mock 对象来阐明了这一点,即 mock_get.return_value = Mock(ok=True)。mock_get() 镜像了 requests.get()。requests.get() 产生一个 Response,而 mock_get() 产生一个 Mock。因为 Response 组件有一个 ok 属性,所以您给 Mock 也添加了一个。

如果您希望使用第三方 API 来增强应用程序的实用性,您必须确保两个系统能够很好地协同工作。您需要验证两个程序的交互是否可预测,并且您的测试必须在受控的环境中运行。

因为 Response 对象有一个 json() 函数,所以我向 Mock 添加了 json,并附加了一个返回值,因为它将像函数一样被调用。json() 函数返回待办事项对象。现在的测试将包括一个断言,用于验证 response.json() 的重要性。确保 get_todos() 函数像主机一样返回一个待办事项列表。最后,我加入一个失败测试来完成 get_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(),然后过滤结果以仅返回已完成的待办事项。有必要再次模拟 requests.get() 吗?不,在这种情况下,您直接模拟 get_todos() 函数!您只需要关心动态系统如何与模拟对象交互。您已经知道 get_todos() 不带参数,并返回带有 json() 函数的响应,该函数返回一个重要对象的列表。您不必关心底层发生了什么;重要的是 get_todos() 模拟需要返回您期望的东西。

project/tests/test_todo11.py

我已经修改了测试函数,以查找并将 project.services.get_todos 替换为模拟对象。这个模拟函数应该返回一个启用了 json() 函数的对象。当被调用时,json() 函数应该产生一个待办事项对象的数组。我还包括一个断言,以确保 get_todos() 函数被调用。这对于确保在交付函数调用实际 API 时调用了真正的 get_todos() 函数很有用。我还包括一个测试,以确保如果 get_todos() 返回 None,get_uncompleted_todos() 会返回一个空列表。我再次确认 get_todos() 函数已经被调用。

编写测试,运行它们看它们失败,然后编写代码使它们通过。

将测试重构为使用类

我们无疑已经注意到,有几个测试似乎构成了一个组。我们的两个测试利用了 get_todos() 函数。我们另外两个测试的主题是获取未完成的待办事项。这种重构满足以下目标:

  1. 通过将常见的测试函数移动到一个类中,您可以更容易地将它们一起测试。虽然您可以指示 nose 针对一组函数,但针对单个类更简单。
  2. 对于测试中常见的函数,为每个测试生成和清除数据的过程通常是相同的。setup_class() 和 teardown_class() 例程可以包含这些步骤。
  3. 您可以在类上构建实用函数,以重用在测试函数中重复的逻辑。

请注意,我使用 **patcher** 技术来模拟测试类中的目标函数。正如我提到的,这种 patch 方法非常适合创建跨越多个函数的模拟。当测试完成时,teardown_class1() 方法中的代码会明确恢复原始代码。

project/tests/test_todo11.py

运行测试。

输出

$ 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,并记下了返回的信息。虽然您可以合理地确定在您处理这些示例的短时间内数据结构没有改变,但您不应该确定它会永远如此。任何可靠的外部库都会持续更新。虽然开发人员的目标是使新代码向后兼容,但最终弃用旧代码是必要的。

正如您可以预料的,仅仅依赖虚假信息是有风险的。您可能会对测试的质量过于自信,因为您在测试代码时没有与真实服务器通信。当您尝试在实际数据上使用您的程序时,一切都会崩溃。为了确保来自服务器的数据与您正在测试的数据相匹配,请使用以下技术。这里的目标不是比较数据,而是比较数据结构。

我希望您能注意我正在使用的上下文管理器 patch 策略。在这种情况下,您必须分别调用真实服务器和模拟版本。

文件名:project/tests/test_todo11.py

有条件地测试场景

您必须知道何时运行您创建的用于对比真实数据契约与模拟数据契约的测试。服务器测试不应该是自动化的,因为失败并不总是表明您的代码有缺陷。由于各种您无法控制的情况,当您的测试套件运行时,您可能无法连接到真实服务器。请独立于您的测试自动化来执行此测试,但也要定期进行。一种有选择地跳过测试的方法是使用环境变量作为切换开关。在下面的场景中,如果 SKIP_REAL 环境变量没有设置为 True,则不会运行任何测试。

当 SKIP_REALS1 变量运行时,任何带有 @skipIf1(SKIP_REALS1) 装饰器的测试都将被跳过。

文件名:project/tests/test_todo11.py

文件名:project/constant1.py

结论

现在您已经学会了如何使用模拟来测试您的应用与第三方 API 的连接。既然您知道了如何解决这个问题,您可以通过在 JSON Placeholder 中为其余的 API 端点创建服务函数来继续磨练您的技能。