复杂的软件由多个相互关联的部分组成——REST API、数据库、云服务等等。

然而,在编写单元测试时,你通常关注的是测试代码的某个特定部分,而非整个系统。

那么,当你的代码依赖于外部服务时,该如何进行测试呢?在单元测试中,你会调用REST API 或者连接数据库吗?

不,这可不是个好主意。

在单元测试中调用外部系统可能会很慢,不可靠,而且成本很高。

答案在于mock。

mock是一种技术,它能让你将正在测试的一段代码与其依赖项隔离开来,这样测试就能专注于孤立状态下的被测代码。

在本文中,我们将学习如何使用Pytest的mock功能来mock代码的各个部分以及外部依赖项。

你将学习如何mock变量和常量、整个函数和类、REST API的响应,甚至是AWS服务(S3)。

学习目标

在本教程结束时,你应该能够:

  • 理解mock的重要性。
  • 明白在单元测试或集成测试中何时使用mock。
  • mock函数、变量、类、REST API以及AWS服务。

什么是mock?

mock是一种技术,它能让你将正在测试的一段代码与其依赖项隔离开来,这样测试就能专注于孤立状态下的被测代码。

这是通过用mock对象替换依赖项来实现的,这些mock对象mock了真实对象的行为。

mock对象通常预先编程了对被测代码预期会进行的方法调用的特定响应。

这使你的测试能够验证被测代码在不同情况下的行为是否正确。

mock在单元测试中是一项很有价值的技术,因为它有助于隔离错误,并提高测试覆盖率。

它还允许你测试尚未完全实现的代码,或者依赖于不可用组件的代码。

一些流行的mock框架包括Java的Mockito、Python的unittest.mock和pytest-mock,以及.NET的Moq。

mock的好处

  1. 缩短反馈周期

想象一下,你正在测试一个用于获取猫咪趣闻的REST API。

每次运行测试时,它都会调用一个外部API,并且根据服务器处理请求的能力,你的调用可能需要几毫秒或几秒的时间。

这浪费了宝贵的开发和执行时间。

mock通过预先设置你期望API返回的内容来帮助你缩短测试反馈周期,这样你就能更快地进行测试。

我们在关于Python REST API单元测试的文章中对这一点有更详细的阐述。

  1. 减少对外部服务的依赖

在我们关于“如何设计你的Python测试策略”的文章中,我们讨论了将测试与传出命令消息的外部依赖项隔离开来的重要性。

也许你需要测试你的Webhook是否已经通知了另一个服务,或者你的服务是否可以查询外部数据库并返回结果。

等待数据库管理员授予访问权限以及网络团队打开防火墙,可能是一个极其缓慢的过程。

mock数据库功能可帮助你在极少甚至无需外部依赖的情况下验证你的工作。

这对于单元测试来说非常有用。

我们将在本文后面更多地讨论集成测试,以及是否有必要进行mock或使用实际连接。

项目代码结构

在这个项目中,我们将定义一个简单的模块,其中包含一系列mock示例代码。

然后,我们将在有mock和无mock的情况下分别测试这些代码,以获得类似的测试结果。

pytest-mock插件提供了一个mocker Fixture,以及围绕标准Python mock包的包装器。

项目结构如下:

Pytest Mock REST API、数据库、云服务等实战指南_API

在Pytest中进行mock

让我们来看一个在pytest-mock文档中定义的mock的基本示例。

import os

class UnixFS:
    @staticmethod
    def rm(filename):
        os.remove(filename)
def test_unix_fs(mocker):
    mocker.patch('os.remove')
    UnixFS.rm('file')
    os.remove.assert_called_once_with('file')

这段代码片段定义了一个UnixFS类,其中包含一个静态方法,用于从磁盘中删除文件。

我们可以在不实际从磁盘删除文件的情况下测试这个功能,使用mocker.patch方法来mockos.remove的功能。

然后,我们使用assert_called_once_with方法来验证os.remove方法是否被调用。

标准的mock库还允许你将mocker.patch函数用作上下文管理器和包装器。

不过,pytest-mock插件不建议这样使用。

重要提示——在使用的地方mock一个项,而不是在它定义的地方

例如,在单元测试中或类初始化的地方mock该项,而不仅仅是在它定义的地方。

mocker Fixture

pytest-mock插件提供了一个mocker Fixture,可用于创建mock对象和补丁函数。

mocker Fixture是MockFixture类的一个实例,该类是unittest.mock模块的一个子类。

它提供了以下方法:

  • mocker.patch - 补丁一个函数或方法
  • mocker.patch.object - 补丁一个对象的方法
  • mocker.patch.multiple - 补丁多个函数或方法
  • mocker.patch.dict - 补丁一个字典
  • mocker.patch.stopall - 停止所有补丁
  • mocker.patch.stop - 停止一个特定的补丁

虽然所有这些方法都很有用,但在本文中我们主要会使用mocker.patch方法,对于类实例则使用mocker.patch.object方法。

mock常量或变量

让我们看看mock的最简单形式——mock常量或变量。

mock_examples/area.py

PI = 3.14159


def area_of_circle(radius: float) -> float:
    """
    计算圆面积的函数
    :param radius: 圆的半径
    :return: 圆的面积
    """
    return PI * radius * radius

上面的代码片段定义了一个常量PI和一个函数area_of_circle,该函数根据给定的半径计算圆的面积。

让我们编写一个测试。

tests/test_area.py

from mock_examples.area import area_of_circle


def test_area_of_circle():
    """
    测试圆面积的函数
    """
    assert area_of_circle(5) == 78.53975

这个测试是有效的。但是,如果我们想用不同的PI值来测试这个函数呢?

我们可以mockPI常量,以便用mock_examples/area.py文件中不同的PI值来测试这个函数。

tests/test_area.py

def test_area_of_circle_with_mock(mocker):
    """
    用mock的PI值测试圆面积的函数
    """
    mocker.patch("mock_examples.area.PI", 3.0)
    assert area_of_circle(5) == 75.0

在这个测试中,我们使用mocker.patch方法将PI的值替换为3.0。

虽然这是一个简单的例子,但它展示了如何使用mock来测试依赖于常量或变量的代码。

mock函数:创建或删除文件

让我们考虑一个文件处理程序,它有两个函数——create_file和remove_file,分别用于创建和删除文件。

mock_examples/file_handler.py

import os

def create_file(filename: str) -> None:
    """
    创建文件的函数
    :param filename: 要创建的文件名
    :return: 无
    """
    with open(f"{filename}", "w") as f:
        f.write("hello")

def remove_file(filename: str) -> None:
    """
    删除文件的函数
    :param filename: 要删除的文件名
    :return: 无
    """
    os.remove(filename)

这段代码有两个函数。我们可以如下进行测试。

tests/test_file_handler.py

import os
from mock_examples.file_handler import create_file, remove_file


deftest_create_file():
    """
    测试创建文件的函数
    """
    create_file(filename="delete_me.txt")
    assert os.path.isfile("delete_me.txt")

deftest_remove_file():
    """
    测试删除文件的函数
    """
    create_file(filename="delete_me.txt")
    remove_file(filename="delete_me.txt")
    assertnot os.path.isfile("delete_me.txt")

虽然这样可行,但有点风险。

在进行单元测试时,你最不想做的事情就是弄乱本地文件系统。

你可以通过mock来消除这种风险。另外,在测试时,你可以使用内置的tmp_path Fixture来管理临时文件。

让我们看看如何mock我们的文件处理程序方法。

tests/test_file_handler.py

def test_create_file_with_mock(mocker):
    """
    用mock测试创建文件的函数
    """
    filename = "delete_me.txt"

    # mock'open'函数调用,返回一个文件对象。
    mock_file = mocker.mock_open()
    mocker.patch("builtins.open", mock_file)

    # 调用创建文件的函数。
    create_file(filename)

    # 断言'open'函数是否使用了预期的参数调用。
    mock_file.assert_called_once_with(filename, "w")

    # 断言文件是否用预期的文本写入。
    mock_file().write.assert_called_once_with("hello")

上面的测试mock了open函数调用,用一个mock文件对象替换了它。然后我们断言open函数是否使用了预期的参数调用,以及文件是否用预期的文本写入。

同样,我们可以mockos.remove函数来测试remove_file函数。

tests/test_file_handler.py

def test_remove_file_with_mock(mocker):
    """
    使用mock测试文件删除功能,避免实际的文件系统操作。
    """
    filename = "delete_me.txt"

    # mockos.remove,以便在不删除任何内容的情况下测试文件删除
    mock_remove = mocker.patch("os.remove")

    # mockos.path.isfile以控制其返回值
    mocker.patch("os.path.isfile", return_value=False)

    # 为create_file函数mockopen
    mocker.patch("builtins.open", mocker.mock_open())

    # mock文件创建和删除
    create_file(filename)
    remove_file(filename)

    # 断言os.remove是否被正确调用
    mock_remove.assert_called_once_with(filename)

    # 断言os.path.isfile返回False,mock文件不存在
    assertnot os.path.isfile(filename)

mock函数——休眠

通常你的代码可能包含休眠功能,例如,在等待另一个任务执行或获取数据时。

mock_examples/sleep_function.py

import time

def sleep_for_a_bit(duration: int):
    """
    休眠一段时间的函数
    :param duration: 休眠的时长
    """
    time.sleep(duration)

上面的函数使代码休眠指定的时长。

我们可以轻松编写一个调用此函数的测试,并等待X秒让它执行。

或者……我们可以mock休眠功能,这样我们的测试就能立即运行。

tests/test_sleep.py

import time
from mock_examples.sleep_function import sleep_for_a_bit


def test_sleep_for_a_bit_with_mock(mocker):
    """
    用mock测试休眠一段时间的函数
    """
    mocker.patch("mock_examples.sleep_function.time.sleep")
    sleep_for_a_bit(duration=5)
    time.sleep.assert_called_once_with(
        5
    )  # 检查time.sleep是否使用了正确的参数调用

mock外部REST API调用

学习mock外部API可能是最有趣且最有用的情况之一。

想象一下,你正在构建一个依赖于另一个REST API来获取数据的REST API。在测试期间调用它并不理想。

这不仅可能会产生高额费用,而且还会引入依赖关系、不可预测性和延迟。

让我们举一个简单的天气API示例。

mock_examples/api.py

from typing import Dict
import requests

def get_weather(city: str) -> Dict:
    """
    获取天气的函数
    :return: API的响应
    """
    response = requests.get(f"https://goweather.herokuapp.com/weather/{city}")
    return response.json()

上面的代码片段定义了一个函数get_weather,它调用一个外部API来获取某个城市的天气。

让我们快速编写一个测试。

tests/test_api.py

from mock_examples.api import get_weather


def test_get_weather():
    """
    测试获取天气的函数
    """
    response = get_weather(city="London")
    assert type(response) is dict

你可以断言响应的类型、状态码等等。

然而,这个测试严重依赖于外部API,如果API出现故障、发生变化或对你进行速率限制,测试可能会失败。更不用说如果你频繁调用它所产生的费用了。

让我们mockAPI调用。

tests/test_api.py

def test_get_weather_mocked(mocker):
    mock_data = {
        "temperature": "+7 °C",
        "wind": "13 km/h",
        "description": "Partly cloudy",
        "forecast": [
            {"day": "1", "temperature": "+10 °C", "wind": "13 km/h"},
            {"day": "2", "temperature": "+6 °C", "wind": "26 km/h"},
            {"day": "3", "temperature": "+15 °C", "wind": "21 km/h"},
        ],
    }

    # 创建一个带有.json()方法的mock响应对象,该方法返回mock数据
    mock_response = mocker.MagicMock()
    mock_response.json.return_value = mock_data

    # 补丁'requests.get',使其返回mock响应
    mocker.patch("requests.get", return_value=mock_response)

    # 调用函数
    result = get_weather(city="London")

    # 断言返回的数据是否符合预期
    assert result == mock_data
    asserttype(result) isdict
    assert result["temperature"] == "+7 °C"

在这个测试中,我们创建了一个mock响应对象,它返回mock数据。这是一种无需实际调用外部API即可测试代码的绝佳方法。

现在让我们看看如何mock类中的一个方法。

mock类

假设我们有一个简单的Person类。

mock_examples/person.py

from typing importDict


classPerson:
    def__init__(self, name: str, age: int = None, address: str = None) -> None:
        self._name = name
        self._age = age
        self._address = address

    @property
    defname(self) -> str:
        return self._name

    @property
    defage(self) -> int:
        return self._age

    @property
    defaddress(self) -> str:
        return self._address

    defget_person_json(self) -> Dict[str, str]:
        return {"name": self._name, "age": self._age, "address": self._address}

上面的代码片段定义了一个Person类,它有三个属性——name、age和address,以及一个方法get_person_json,该方法返回一个表示人物的字典。

让我们编写一个测试。

tests/test_person.py

import pytest
from mock_examples.person import Person


@pytest.fixture
defperson():
    return Person(name="Eric", age=25, address="123 Farmville Rd")


deftest_person_properties(person):
    """
    测试Person类的各个属性。
    """
    assert person.name == "Eric"
    assert person.age == 25
    assert person.address == "123 Farmville Rd"

这个测试很简单且有效。但是,如果我们想mock类实例的get_person_json方法呢?

tests/test_person.py

def test_person_class_with_mock(mocker):
    """
    使用mock测试Person类的'get_person_json'方法
    """
    person = Person(name="Eric", age=25, address="123 Farmville Rd")
    mock_response = {"name": "FAKE_NAME", "age": "FAKE_AGE", "address": "FAKE_ADDRESS"}

    # 补丁该方法
    mocker.patch.object(person, "get_person_json", return_value=mock_response)

    assert person.get_person_json() == mock_response

上面的代码使用mocker.patch.object方法来补丁Person类实例的get_person_json方法。

mockAWS服务——例如S3

作为最后一个例子,让我们看看如何mockAWS服务,例如S3。

我们使用moto库来mockAWS服务。

mock_examples/aw_s3_.py

from typing import Any
import boto3


def get_my_object(bucket: str, key: str) -> Any:
    """
    从S3获取对象的函数
    :param bucket: 存储桶名称
    :param key: 键名称
    :return: S3的响应
    """
    s3_client = boto3.client("s3")
    response = s3_client.get_object(Bucket=bucket, Key=key)
    return response

这段简单的代码通过将存储桶名称和键作为参数,从S3存储桶中获取一个对象。

当我们在测试中运行这段代码时,它会尝试与实际的AWS账户和服务进行通信以获取对象。

通过使用Motomock存储桶和键,可以避免这种情况。

首先,在conftest.py文件中设置Moto和所需的 Fixture。

tests/conftest.py

import os
import pytest
from moto import mock_aws
import boto3


@pytest.fixture(scope="function")
defaws_credentials():
    """为motomock的AWS凭证。"""
    os.environ["AWS_ACCESS_KEY_ID"] = "testing"
    os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
    os.environ["AWS_SECURITY_TOKEN"] = "testing"
    os.environ["AWS_SESSION_TOKEN"] = "testing"
    os.environ["AWS_DEFAULT_REGION"] = "us-east-1"


@pytest.fixture(scope="function")
defaws_s3(aws_credentials):
    with mock_aws():
        yield boto3.client("s3", region_name="us-east-1")

这将为测试设置AWS凭证和S3客户端。

tests/test_aws_s3.py

from moto import mock_aws
from mock_examples.aws_s3 import get_my_object


@mock_aws
deftest_get_my_object_mocked(aws_s3):
    """
    测试获取对象的函数
    :param s3: pytest-mock Fixture
    :return: 无
    """
    # 创建一个mock的S3存储桶。
    aws_s3.create_bucket(Bucket="mock-bucket")

    # 在mock的S3存储桶中创建一个mock对象。
    aws_s3.put_object(Bucket="mock-bucket", Key="mock-key", Body="mock-body")

    # 从mock的S3存储桶中获取mock对象。
    response = get_my_object(bucket="mock-bucket", key="mock-key")
    assert response["Body"].read() == b"mock-body"

我们刚刚在没有实际调用S3(也没有产生费用)的情况下测试了获取对象的功能。

相当不错,不是吗?

运行测试

最后,让我们运行测试以确保一切正常。

$ pytest

pytest-mock

(忽略这些警告,它们与Boto3一个尚未解决的弃用问题有关)

何时应该进行mock?

所以,

也许你在想——什么时候应该进行mock,什么时候应该使用真实资源呢?

没有一个确切的答案。但这里有一个简单的经验法则。

当你想要孤立地测试单个模块并避免外部依赖时,进行mock是个好主意。

当你想要将功能作为一个系统进行测试时,你需要测试真实的连接,例如你的API能否连接到数据库或外部API。

然而,在单元测试中,例如测试对象转换或逻辑时,建议mock不属于该模块的外部资源。

建议:

单元测试——在必要时进行mock 集成测试——不要mock,使用真实连接

结论

我希望你喜欢这篇文章,并且它消除了你对mock的一些疑虑。

总之,你了解了mock、它的好处以及如何在各种合适的场景中使用它。

你通过示例探索了如何mock常量、变量、函数、类、REST API,甚至像S3这样的AWS服务。

最后,你学会了如何决定是应该mock一个服务,还是直接使用它。如果你想孤立地测试单个模块,最好对其进行mock。

如果你正在测试一个系统及其集成,你需要让系统尽可能地代表真实的系统。