使用 Python 测试 API 的 3 种方法

单元测试可能令人生畏,但这些 Python 模块将使您的生活轻松得多。
78 位读者喜欢这篇文章。
Puzzle pieces coming together to form a computer screen

Opensource.com

在本教程中,您将学习如何对执行 HTTP 请求的代码进行单元测试。换句话说,您将了解 Python 中 API 单元测试的艺术。

单元测试旨在测试单个行为单元。在测试中,一个众所周知的经验法则是隔离到达外部依赖项的代码。

例如,在测试执行 HTTP 请求的代码时,建议在测试期间用虚假调用替换真实调用。这样,您可以进行单元测试,而无需在每次运行测试时执行真实的 HTTP 请求。

问题是,如何隔离代码?

希望这正是我将在本文中回答的问题!我不仅会向您展示如何做到这一点,还会权衡三种不同方法的优缺点。

要求

  • Python 3.8
  • pytest-mock
  • requests
  • flask
  • responses
  • VCR.py

使用天气 REST API 的演示应用

为了将这个问题放在上下文中,假设您正在构建一个天气应用。此应用使用第三方天气 REST API 来检索特定城市的天气信息。其中一项要求是生成一个简单的 HTML 页面,如下面的图片所示

web page displaying London weather

伦敦天气,OpenWeatherMap。图片为作者本人所有。

要获取有关天气的信息,您必须在某个地方找到它。幸运的是,OpenWeatherMap 通过其 REST API 服务提供了您所需的一切。

好的,这很酷,但我该如何使用它?

您可以通过向以下地址发送 GET 请求来获取所需的一切:https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric。对于本教程,我将参数化城市名称并确定公制单位。

检索数据

要检索天气数据,请使用 requests。您可以创建一个函数,该函数接收城市名称作为参数并返回 JSON。JSON 将包含温度、天气描述、日落、日出时间等。

下面的示例说明了这样一个函数

def find_weather_for(city: str) -> dict:
    """Queries the weather API and returns the weather data for a particular city."""
    url = API.format(city_name=city, api_key=API_KEY)
    resp = requests.get(url)
    return resp.json()

URL 由两个全局变量组成

BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
API = BASE_URL + "?q={city_name}&appid={api_key}&units=metric"

API 以这种格式返回 JSON

{
  "coord": {
    "lon": -0.13,
    "lat": 51.51
  },
  "weather": [
    {
      "id": 800,
      "main": "Clear",
      "description": "clear sky",
      "icon": "01d"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 16.53,
    "feels_like": 15.52,
    "temp_min": 15,
    "temp_max": 17.78,
    "pressure": 1023,
    "humidity": 72
  },
  "visibility": 10000,
  "wind": {
    "speed": 2.1,
    "deg": 40
  },
  "clouds": {
    "all": 0
  },
  "dt": 1600420164,
  "sys": {
    "type": 1,
    "id": 1414,
    "country": "GB",
    "sunrise": 1600407646,
    "sunset": 1600452509
  },
  "timezone": 3600,
  "id": 2643743,
  "name": "London",
  "cod": 200

当您调用 resp.json() 时,数据将作为 Python 字典返回。为了封装所有细节,您可以将它们表示为 dataclass。此类具有一个工厂方法,该方法获取字典并返回 WeatherInfo 实例。

这样做很好,因为您可以保持表示的稳定性。例如,如果 API 更改了 JSON 的结构方式,您只需在一个地方更改逻辑,即 from_dict 方法。代码的其他部分将不受影响。您甚至可以从不同的来源获取信息,并在 from_dict 方法中将它们组合起来!

@dataclass
class WeatherInfo:
    temp: float
    sunset: str
    sunrise: str
    temp_min: float
    temp_max: float
    desc: str

    @classmethod
    def from_dict(cls, data: dict) -> "WeatherInfo":
        return cls(
            temp=data["main"]["temp"],
            temp_min=data["main"]["temp_min"],
            temp_max=data["main"]["temp_max"],
            desc=data["weather"][0]["main"],
            sunset=format_date(data["sys"]["sunset"]),
            sunrise=format_date(data["sys"]["sunrise"]),
        )

现在,您将创建一个名为 retrieve_weather 的函数。您将使用此函数调用 API 并返回 WeatherInfo,以便您可以构建 HTML 页面。

def retrieve_weather(city: str) -> WeatherInfo:
    """Finds the weather for a city and returns a WeatherInfo instance."""
    data = find_weather_for(city)
    return WeatherInfo.from_dict(data)

很好,您已经拥有了我们应用的基本构建块。在继续之前,请对这些函数进行单元测试。

1. 使用模拟测试 API

根据维基百科,模拟对象是通过模仿真实对象的行为来模拟真实对象的对象。在 Python 中,您可以使用标准库中包含的 unittest.mock 库来模拟任何对象。要测试 retrieve_weather 函数,您可以模拟 requests.get 并返回静态数据。

pytest-mock

对于本教程,您将使用 pytest 作为您的首选测试框架。pytest 库通过插件非常容易扩展。为了实现我们的模拟目标,请使用 pytest-mock。此插件从 unittest.mock 中抽象出大量设置,并使您的测试代码非常简洁。如果您好奇,我在另一篇博文中更详细地讨论了它。

好的,说够了,给我看代码。

这是一个完整的 retrieve_weather 函数的测试用例。此测试使用两个 fixture:一个是 pytest-mock 插件提供的 mocker fixture。另一个是我们的。这只是您从之前的请求中保存的静态数据。

@pytest.fixture()
def fake_weather_info():
    """Fixture that returns a static weather data."""
    with open("tests/resources/weather.json") as f:
        return json.load(f)
def test_retrieve_weather_using_mocks(mocker, fake_weather_info):
    """Given a city name, test that a HTML report about the weather is generated
    correctly."""
    # Creates a fake requests response object
    fake_resp = mocker.Mock()
    # Mock the json method to return the static weather data
    fake_resp.json = mocker.Mock(return_value=fake_weather_info)
    # Mock the status code
    fake_resp.status_code = HTTPStatus.OK

    mocker.patch("weather_app.requests.get", return_value=fake_resp)

    weather_info = retrieve_weather(city="London")
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

如果您运行测试,您将得到以下输出

============================= test session starts ==============================
...[omitted]...
tests/test_weather_app.py::test_retrieve_weather_using_mocks PASSED      [100%]
============================== 1 passed in 0.20s ===============================
Process finished with exit code 0

太棒了,您的测试通过了!但是... 生活并非一帆风顺。此测试有优点和缺点。我将看一下它们。

优点

好吧,已经讨论过的一个优点是,通过模拟 API 的返回,您可以使您的测试更容易。隔离与 API 的通信并使测试可预测。它将始终返回您想要的内容。

缺点

至于缺点,问题是,如果您不想再使用 requests,而是决定使用标准库的 urllib 会怎么样。每次您更改 find_weather_for 的实现时,您都必须调整测试。一个好的测试不会随着您的实现更改而更改。因此,通过模拟,您最终将测试与实现耦合在一起。

此外,另一个缺点是您在调用函数之前必须进行的设置量——至少三行代码。

...
    # Creates a fake requests response object
    fake_resp = mocker.Mock()
    # Mock the json method to return the static weather data
    fake_resp.json = mocker.Mock(return_value=fake_weather_info)
    # Mock the status code
    fake_resp.status_code = HTTPStatus.OK
...

我可以做得更好吗?

是的,请继续关注。我现在将看看如何稍微改进它。

使用 responses

使用 mocker 功能模拟 requests 的缺点是设置时间长。避免这种情况的一个好方法是使用一个拦截 requests 调用并对其进行修补的库。有不止一个这样的库,但对我来说最简单的是 responses。让我们看看如何使用它来替换 mock

@responses.activate
def test_retrieve_weather_using_responses(fake_weather_info):
    """Given a city name, test that a HTML report about the weather is generated
    correctly."""
    api_uri = API.format(city_name="London", api_key=API_KEY)
    responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPStatus.OK)

    weather_info = retrieve_weather(city="London")
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

同样,此函数使用了我们的 fake_weather_info fixture。

接下来,运行测试

============================= test session starts ==============================
...
tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED  [100%]
============================== 1 passed in 0.19s ===============================

太棒了!此测试也通过了。但是... 它仍然不是那么好。

优点

使用 responses 等库的好处是您无需自己修补 requests。您可以通过将抽象委托给库来节省一些设置。但是,如果您没有注意到,还是存在问题。

缺点

同样,问题是,与 unittest.mock 非常相似,您的测试与实现耦合在一起。如果您替换 requests,您的测试就会中断。

2. 使用适配器测试 API

如果使用模拟我会耦合我们的测试,我该怎么办?

想象以下场景:假设您不能再使用 requests,并且您必须用 urllib 替换它,因为它随 Python 一起提供。不仅如此,您还吸取了不将测试代码与实现耦合的教训,并且您希望将来避免这种情况。您想替换 urllib 而不必重写测试。

事实证明,您可以抽象出执行 GET 请求的代码。

真的吗?怎么做?

您可以通过使用适配器来抽象它。适配器是一种设计模式,用于封装或包装其他类的接口,并将其公开为新接口。这样,您可以更改适配器而无需更改我们的代码。例如,您可以将有关 requests 的详细信息封装在我们的 find_weather_for 中,并通过仅接受 URL 的函数公开它。

所以,这个

def find_weather_for(city: str) -> dict:
    """Queries the weather API and returns the weather data for a particular city."""
    url = API.format(city_name=city, api_key=API_KEY)
    resp = requests.get(url)
    return resp.json()

变成这个

def find_weather_for(city: str) -> dict:
    """Queries the weather API and returns the weather data for a particular city."""
    url = API.format(city_name=city, api_key=API_KEY)
    return adapter(url)

适配器变成这个

def requests_adapter(url: str) -> dict:
    resp = requests.get(url)
    return resp.json()

现在是时候重构我们的 retrieve_weather 函数了

def retrieve_weather(city: str) -> WeatherInfo:
    """Finds the weather for a city and returns a WeatherInfo instance."""
    data = find_weather_for(city, adapter=requests_adapter)
    return WeatherInfo.from_dict(data)

因此,如果您决定将此实现更改为使用 urllib 的实现,只需交换适配器即可

def urllib_adapter(url: str) -> dict:
    """An adapter that encapsulates urllib.urlopen"""
    with urllib.request.urlopen(url) as response:
        resp = response.read()
    return json.loads(resp)
def retrieve_weather(city: str) -> WeatherInfo:
    """Finds the weather for a city and returns a WeatherInfo instance."""
    data = find_weather_for(city, adapter=urllib_adapter)
    return WeatherInfo.from_dict(data)

好的,测试呢?

要测试 retrieve_weather,只需创建一个在测试期间使用的虚假适配器

@responses.activate
def test_retrieve_weather_using_adapter(
    fake_weather_info,
):
    def fake_adapter(url: str):
        return fake_weather_info

    weather_info = retrieve_weather(city="London", adapter=fake_adapter)
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

如果您运行测试,您会得到

============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED    [100%]
============================== 1 passed in 0.22s ===============================

优点

此方法的优点是您已成功将测试与实现分离。使用依赖注入在测试期间注入虚假适配器。此外,您可以随时交换适配器,包括在运行时。您在不更改行为的情况下完成了所有这些操作。

缺点

缺点是,由于您在测试中使用虚假适配器,如果您在您在实现中使用的适配器中引入错误,您的测试将无法捕获它。例如,假设我们向 requests 传递了错误的参数,就像这样

def requests_adapter(url: str) -> dict:
    resp = requests.get(url, headers=<some broken headers>)
    return resp.json()

此适配器在生产中将失败,并且单元测试将无法捕获它。但说实话,您在前一种方法中也遇到了同样的问题。这就是为什么您始终需要超越单元测试并进行集成测试的原因。话虽如此,请考虑另一种选择。

3. 使用 VCR.py 测试 API

现在终于可以讨论我们的最后一个选项了。坦率地说,我最近才发现它。我使用模拟很长时间了,并且总是遇到一些问题。VCR.py 是一个库,它简化了许多发出 HTTP 请求的测试。

它的工作原理是,在您第一次运行测试时,将 HTTP 交互记录为名为磁带盒的平面 YAML 文件。请求和响应都被序列化。当您第二次运行测试时,VCR.py 将拦截调用并返回对所发出请求的响应。

现在看看如何使用 VCR.py 测试 retrieve_weather,如下所示:

@vcr.use_cassette()
def test_retrieve_weather_using_vcr(fake_weather_info):
    weather_info = retrieve_weather(city="London")
    assert weather_info == WeatherInfo.from_dict(fake_weather_info)

哇,就这些?没有设置?@vcr.use_cassette() 是什么?

是的,就这些!没有设置,只有一个 pytest 注释来告诉 VCR 拦截调用并保存磁带盒文件。

磁带盒文件是什么样的?

好问题。里面有很多东西。这是因为 VCR 保存了交互的每个细节。

interactions:
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.24.0
    method: GET
    uri: https://api.openweathermap.org/data/2.5/weather?q=London&appid=<YOUR API KEY HERE>&units=metric
  response:
    body:
      string: '{"coord":{"lon":-0.13,"lat":51.51},"weather":[{"id":800,"main":"Clear","description":"clearsky","icon":"01d"}],"base":"stations","main":{"temp":16.53,"feels_like":15.52,"temp_min":15,"temp_max":17.78,"pressure":1023,"humidity":72},"visibility":10000,"wind":{"speed":2.1,"deg":40},"clouds":{"all":0},"dt":1600420164,"sys":{"type":1,"id":1414,"country":"GB","sunrise":1600407646,"sunset":1600452509},"timezone":3600,"id":2643743,"name":"London","cod":200}'
    headers:
      Access-Control-Allow-Credentials:
      - 'true'
      Access-Control-Allow-Methods:
      - GET, POST
      Access-Control-Allow-Origin:
      - '*'
      Connection:
      - keep-alive
      Content-Length:
      - '454'
      Content-Type:
      - application/json; charset=utf-8
      Date:
      - Fri, 18 Sep 2020 10:53:25 GMT
      Server:
      - openresty
      X-Cache-Key:
      - /data/2.5/weather?q=london&units=metric
    status:
      code: 200
      message: OK
version: 1

真多!

的确!好处是您不需要太在意它。VCR.py 会为您处理这一切。

优点

现在,对于优点,我可以列出至少五件事

  • 没有设置代码。
  • 测试保持隔离,因此速度很快。
  • 测试是确定性的。
  • 如果您更改请求,例如使用不正确的标头,测试将失败。
  • 它与实现无关,因此您可以交换适配器,并且测试将通过。唯一重要的是您的请求是否相同。

缺点

再次强调,尽管与模拟相比有巨大的好处,但仍然存在问题。

如果 API 提供商出于某种原因更改了数据格式,则测试仍然会通过。幸运的是,这种情况不是很频繁,API 提供商通常会在引入此类重大更改之前对他们的 API 进行版本控制。此外,单元测试并非旨在访问外部 API,因此此处无能为力。

另一个需要考虑的事情是进行端到端测试。这些测试将在每次运行时调用服务器。顾名思义,它是一个更广泛的测试,速度较慢。它们比单元测试涵盖的范围更广。事实上,并非每个项目都需要进行端到端测试。因此,在我看来,VCR.py 对于大多数人的需求来说已经足够了。

结论

就是这样。我希望您今天学到了一些有用的东西。测试 API 客户端应用程序可能有点令人生畏。然而,当您配备了正确的工具和知识时,您就可以驯服这头野兽。

您可以在 我的 GitHub 上找到完整的应用。


本文最初发表在作者的个人博客上,并已获得许可进行改编。

接下来阅读什么
标签
User profile image.
我是一名位于英国伦敦的人工智能软件工程师,使用人工智能来改进癌症检测。我拥有 6 年以上的专业经验,其中 4 年从事机器学习/人工智能工作。这些年来,我一直在开发和发布不同编程语言的软件。我撰写关于 Python 的文章,但任何与我的专业领域相关的内容都是教程的绝佳候选主题。

1 条评论

我喜欢 JS 和 Phyton

Creative Commons License本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.