Redian新闻
>
用 Python 测试 API 的 3 种方式 | Linux 中国

用 Python 测试 API 的 3 种方式 | Linux 中国

科技
 
导读:单元测试可能令人生畏,但是这些 Python 模块会使你的生活变得更容易。                 
本文字数:14228,阅读时长大约:17分钟

在这个教程中,你将学到如何对执行 HTTP 请求代码的进行单元测试。也就是说,你将看到用 Python 对 API 进行单元测试的艺术。

单元测试是指对单个行为的测试。在测试中,一个众所周知的经验法则就是隔离那些需要外部依赖的代码。

比如,当测试一段执行 HTTP 请求的代码时,建议在测试过程中,把真正的调用替换成一个假的的调用。这种情况下,每次运行测试的时候,就可以对它进行单元测试,而不需要执行一个真正的 HTTP 请求。

问题就是,怎样才能隔离这些代码?

这就是我希望在这篇博文中回答的问题!我不仅会向你展示如果去做,而且也会权衡不同方法之间的优点和缺点。

要求:

◈ Python 3.8🔗 miguendes.me
◈ pytest-mock
◈ requests
◈ flask
◈ responses
◈ VCR.py🔗 VCR.py

使用一个天气状况 REST API 的演示程序

为了更好的解决这个问题,假设你正在创建一个天气状况的应用。这个应用使用第三方天气状况 REST API 来检索一个城市的天气信息。其中一个需求是生成一个简单的 HTML 页面,像下面这个图片:

伦敦的天气,OpenWeatherMap。图片是作者自己制作的。

为了获得天气的信息,必须得去某个地方找。幸运的是,通过 OpenWeatherMap🔗 miguendes.me 的 REST API 服务,可以获得一切需要的信息。

好的,很棒,但是我该怎么用呢?

通过发送一个 GET 请求到:https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric,就可以获得你所需要的所有东西。在这个教程中,我会把城市名字设置成一个参数,并确定使用公制单位。

检索数据

使用 requests 模块来检索天气数据。你可以创建一个接收城市名字作为参数的函数,然后返回一个 JSON。JSON 包含温度、天气状况的描述、日出和日落时间等数据。

下面的例子演示了这样一个函数:

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

这个 URL 是由两个全局变量构成:

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

API 以这个格式返回了一个 JSON:

  1. {
  2.   "coord": {
  3.     "lon": -0.13,
  4.     "lat": 51.51
  5.   },
  6.   "weather": [
  7.     {
  8.       "id": 800,
  9.       "main": "Clear",
  10.       "description": "clear sky",
  11.       "icon": "01d"
  12.     }
  13.   ],
  14.   "base": "stations",
  15.   "main": {
  16.     "temp": 16.53,
  17.     "feels_like": 15.52,
  18.     "temp_min": 15,
  19.     "temp_max": 17.78,
  20.     "pressure": 1023,
  21.     "humidity": 72
  22.   },
  23.   "visibility": 10000,
  24.   "wind": {
  25.     "speed": 2.1,
  26.     "deg": 40
  27.   },
  28.   "clouds": {
  29.     "all": 0
  30.   },
  31.   "dt": 1600420164,
  32.   "sys": {
  33.     "type": 1,
  34.     "id": 1414,
  35.     "country": "GB",
  36.     "sunrise": 1600407646,
  37.     "sunset": 1600452509
  38.   },
  39.   "timezone": 3600,
  40.   "id": 2643743,
  41.   "name": "London",
  42.   "cod": 200

当调用 resp.json() 的时候,数据是以 Python 字典的形式返回的。为了封装所有细节,可以用 dataclass 来表示它们。这个类有一个工厂方法,可以获得这个字典并且返回一个 WeatherInfo 实例。

这种办法很好,因为可以保持这种表示方法的稳定。比如,如果 API 改变了 JSON 的结构,就可以在同一个地方(from_dict 方法中)修改逻辑。其他代码不会受影响。你也可以从不同的源获得信息,然后把它们都整合到 from_dict 方法中。

  1. @dataclass
  2. class WeatherInfo:
  3.     temp: float
  4.     sunset: str
  5.     sunrise: str
  6.     temp_min: float
  7.     temp_max: float
  8.     desc: str
  9.     @classmethod
  10.     def from_dict(cls, data: dict) -> "WeatherInfo":
  11.         return cls(
  12.             temp=data["main"]["temp"],
  13.             temp_min=data["main"]["temp_min"],
  14.             temp_max=data["main"]["temp_max"],
  15.             desc=data["weather"][0]["main"],
  16.             sunset=format_date(data["sys"]["sunset"]),
  17.             sunrise=format_date(data["sys"]["sunrise"]),
  18.         )

现在来创建一个叫做 retrieve_weather 的函数。使用这个函数调用 API,然后返回一个 WeatherInfo,这样就可创建你自己的 HTML 页面。

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

很好,我们的 app 现在有一些基础了。在继续之前,对这些函数进行单元测试。

1、使用 mock 测试 API

根据维基百科🔗 en.wikipedia.org模拟对象(mock object)是通过模仿真实对象来模拟它行为的一个对象。在 Python 中,你可以使用 unittest.mock 库来模拟(mock)任何对象,这个库是标准库中的一部分。为了测试 retrieve_weather 函数,可以模拟 requests.get,然后返回静态数据。

pytest-mock

在这个教程中,会使用 pytest 作为测试框架。通过插件,pytest 库是非常具有扩展性的。为了完成我们的模拟目标,要用 pytest-mock。这个插件抽象化了大量 unittest.mock 中的设置,也会让你的代码更简洁。如果你感兴趣的话,我在 另一篇博文中🔗 miguendes.me 会有更多的讨论。

好的,言归正传,现在看代码。

下面是一个 retrieve_weather 函数的完整测试用例。这个测试使用了两个 fixture:一个是由 pytest-mock 插件提供的 mocker fixture, 还有一个是我们自己的。就是从之前请求中保存的静态数据。

  1. @pytest.fixture()
  2. def fake_weather_info():
  3.     """Fixture that returns a static weather data."""
  4.     with open("tests/resources/weather.json") as f:
  5.         return json.load(f)
  1. def test_retrieve_weather_using_mocks(mocker, fake_weather_info):
  2.     """Given a city name, test that a HTML report about the weather is generated
  3.     correctly."""
  4.     # Creates a fake requests response object
  5.     fake_resp = mocker.Mock()
  6.     # Mock the json method to return the static weather data
  7.     fake_resp.json = mocker.Mock(return_value=fake_weather_info)
  8.     # Mock the status code
  9.     fake_resp.status_code = HTTPStatus.OK
  10.     mocker.patch("weather_app.requests.get", return_value=fake_resp)
  11.     weather_info = retrieve_weather(city="London")
  12.     assert weather_info == WeatherInfo.from_dict(fake_weather_info)

如果运行这个测试,会获得下面的输出:

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

很好,测试通过了!但是...生活并非一帆风顺。这个测试有优点,也有缺点。现在来看一下。

优点

好的,有一个之前讨论过的优点就是,通过模拟 API 的返回值,测试变得简单了。将通信和 API 隔离,这样测试就可以预测了。这样总会返回你需要的东西。

缺点

对于缺点,问题就是,如果不再想用 requests 了,并且决定回到标准库的 urllib,怎么办。每次改变 find_weather_for 的代码,都得去适配测试。好的测试是,当你修改代码实现的时候,测试时不需要改变的。所以,通过模拟,你最终把测试和实现耦合在了一起。

而且,另一个不好的方面是你需要在调用函数之前进行大量设置——至少是三行代码。

  1. ...
  2.     # Creates a fake requests response object
  3.     fake_resp = mocker.Mock()
  4.     # Mock the json method to return the static weather data
  5.     fake_resp.json = mocker.Mock(return_value=fake_weather_info)
  6.     # Mock the status code
  7.     fake_resp.status_code = HTTPStatus.OK
  8. ...

我可以做的更好吗?

是的,请继续看。我现在看看怎么改进一点。

使用 responses

用 mocker 功能模拟 requests 有点问题,就是有很多设置。避免这个问题的一个好办法就是使用一个库,可以拦截 requests 调用并且给它们 打补丁(patch)。有不止一个库可以做这件事,但是对我来说最简单的是 responses。我们来看一下怎么用,并且替换 mock

  1. @responses.activate
  2. def test_retrieve_weather_using_responses(fake_weather_info):
  3.     """Given a city name, test that a HTML report about the weather is generated
  4.     correctly."""
  5.     api_uri = API.format(city_name="London", api_key=API_KEY)
  6.     responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPStatus.OK)
  7.     weather_info = retrieve_weather(city="London")
  8.     assert weather_info == WeatherInfo.from_dict(fake_weather_info)

这个函数再次使用了我们的 fake_weather_info fixture。

然后运行测试:

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

非常好!测试也通过了。但是...并不是那么棒。

优点

使用诸如 responses 这样的库,好的方面就是不需要再给 requests 打补丁(patch)。通过将这层抽象交给库,可以减少一些设置。然而,如果你没注意到的话,还是有一些问题。

缺点

和 unittest.mock 很像,测试和实现再一次耦合了。如果替换 requests,测试就不能用了。

2、使用适配器测试 API

如果用模拟让测试耦合了,我能做什么?

设想下面的场景:假如说你不能再用 requests 了,而且必须要用 urllib 替换,因为这是 Python 自带的。不仅仅是这样,你了解了不要把测试代码和实现耦合,并且你想今后都避免这种情况。你想替换 urllib,也不想重写测试了。

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

真的吗?怎么做?

可以使用适配器(adapter)来抽象它。适配器是一种用来封装其他类的接口,并作为新接口暴露出来的一种设计模式。用这种方式,就可以修改适配器而不需要修改代码了。比如,在 find_weather_for 函数中,封装关于 requests 的所有细节,然后把这部分暴露给只接受 URL 的函数。

所以,这个:

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

变成这样:

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

然后适配器变成这样:

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

现在到了重构 retrieve_weather 函数的时候:

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

所以,如果你决定改为使用 urllib 的实现,只要换一下适配器:

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

好的,那测试怎么做?

为了测试 retrieve_weather, 只要创建一个在测试过程中使用的假的适配器:

  1. @responses.activate
  2. def test_retrieve_weather_using_adapter(
  3.     fake_weather_info,
  4. ):
  5.     def fake_adapter(url: str):
  6.         return fake_weather_info
  7.     weather_info = retrieve_weather(city="London", adapter=fake_adapter)
  8.     assert weather_info == WeatherInfo.from_dict(fake_weather_info)

如果运行测试,会获得:

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

优点

这个方法的优点是可以成功将测试和实现解耦。使用依赖注入🔗 stackoverflow.com(dependency injection)在测试期间注入一个假的适配器。你也可以在任何时候更换适配器,包括在运行时。这些事情都不会改变任何行为。

缺点

缺点就是,因为你在测试中用了假的适配器,如果在实现中往适配器中引入了一个 bug,测试的时候就不会发现。比如说,往 requests 传入了一个有问题的参数,像这样:

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

在生产环境中,适配器会有问题,而且单元测试没办法发现。但是事实是,之前的方法也会有同样的问题。这就是为什么不仅要单元测试,并且总是要集成测试。也就是说,要考虑另一个选项。

3、使用 VCR.py 测试 API

现在终于到了讨论我们最后一个选项了。诚实地说,我也是最近才发现这个。我用模拟(mock)也很长时间了,而且总是有一些问题。VCR.py 是一个库,它可以简化很多 HTTP 请求的测试。

它的工作原理是将第一次运行测试的 HTTP 交互记录为一个 YAML 文件,叫做 cassette。请求和响应都会被序列化。当第二次运行测试的时候,VCT.py 将拦截对请求的调用,并且返回一个响应。

现在看一下下面如何使用 VCR.py 测试 retrieve_weather

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

天呐,就这样?没有设置?@vcr.use_cassette() 是什么?

是的,就这样!没有设置,只要一个 pytest 标注告诉 VCR 去拦截调用,然后保存 cassette 文件。

cassette 文件是什么样?

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

  1. interactions:
  2. - request:
  3.     body: null
  4.     headers:
  5.       Accept:
  6.       - '*/*'
  7.       Accept-Encoding:
  8.       - gzip, deflate
  9.       Connection:
  10.       - keep-alive
  11.       User-Agent:
  12.       - python-requests/2.24.0
  13.     method: GET
  14.     uri: https://api.openweathermap.org/data/2.5/weather?q=London&appid=<YOUR API KEY HERE>&units=metric
  15.   response:
  16.     body:
  17.       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}'
  18.     headers:
  19.       Access-Control-Allow-Credentials:
  20.       - 'true'
  21.       Access-Control-Allow-Methods:
  22.       - GET, POST
  23.       Access-Control-Allow-Origin:
  24.       - '*'
  25.       Connection:
  26.       - keep-alive
  27.       Content-Length:
  28.       - '454'
  29.       Content-Type:
  30.       - application/json; charset=utf-8
  31.       Date:
  32.       - Fri, 18 Sep 2020 10:53:25 GMT
  33.       Server:
  34.       - openresty
  35.       X-Cache-Key:
  36.       - /data/2.5/weather?q=london&amp;units=metric
  37.     status:
  38.       code: 200
  39.       message: OK
  40. version: 1

确实很多!

真的!好的方面就是你不需要留意它。VCR.py 会为你安排好一切。

优点

现在看一下优点,我可以至少列出五个:

◈ 没有设置代码。
◈ 测试仍然是分离的,所以很快。
◈ 测试是确定的。
◈ 如果你改了请求,比如说用了错误的 header,测试会失败。
◈ 没有与代码实现耦合,所以你可以换适配器,而且测试会通过。唯一有关系的东西就是请求必须是一样的。

缺点

再与模拟相比较,除了避免了错误,还是有一些问题。

如果 API 提供者出于某种原因修改了数据格式,测试仍然会通过。幸运的是,这种情况并不经常发生,而且在这种重大改变之前,API 提供者通常会给他们的 API 提供不同版本。

另一个需要考虑的事情是就地(in place)端到端(end-to-end)测试。每次服务器运行的时候,这些测试都会调用。顾名思义,这是一个范围更广、更慢的测试。它们会比单元测试覆盖更多。事实上,并不是每个项目都需要使用它们。所以,就我看来,VCR.py 对于大多数人的需求来说都绰绰有余。

总结

就这么多了。我希望今天你了解了一些有用的东西。测试 API 客户端应用可能会有点吓人。然而,当武装了合适的工具和知识,你就可以驯服这个野兽。

在 我的 Github🔗 github.com 上可以找到这个完整的应用。

这篇文章最早发表在 作者的个人博客🔗 miguendes.me,授权转载


via: https://opensource.com/article/21/9/unit-test-python

作者:Miguel Brito 选题:lujun9972 译者:Yufei-Yan 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出


LCTT 译者 :MCGA
🌟🌟🌟
翻译: 14.0 篇
|
贡献: 787 天
2020-06-21
2022-08-17
https://linux.cn/lctt/Yufei-Yan
欢迎遵照 CC-BY-SA 协议规定转载,
如需转载,请在文章下留言 “转载:公众号名称”,
我们将为您添加白名单,授权“转载文章时可以修改”。

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
我的青春永驻的父亲,关于他的七省蒋介石秘密黄金的故事Python中常见魔法方法介绍Python 中可观测性的 7 个关键部分 | Linux 中国字节大佬编写的这本《Python背记手册》,带我横扫互联网大厂秋招!Gunicorn 与 Python GILPython批量将Photoshop文件保存为图片父亲节看旧文忆父亲将你的 Python 脚本转换为命令行程序 | Linux 中国在 Linux 上学习 C 语言的五种方式 | Linux 中国高考那道龙门在 Linux 上截屏的 3 种方法 | Linux 中国在 Linux 上使用 Rhythbox 听音乐 | Linux 中国硬核观察 #739 Python 虽然是最受欢迎的编程语言,但是找工作还是要会点 SQL神秘的 GeckoLinux 创建者推出了一个新的 Debian 合成发行版 | Linux 中国在 Linux 上玩电子游戏的三种方式 | Linux 中国Tuxedo 已对所有用户开放基于 Ubuntu 的 TUXEDO OS | Linux 中国长木公园的一点中国风MNE/Python-fNIRS近红外数据处理中文手册4 步打包一个新的 Python 模块 | Linux 中国《天才基本法》完结!张子枫学Python的样子,像极了出国后的我自己...Python之谜:四舍五入round(4.5)等于4?《天才基本法》揭秘Python真实用法,留学生直呼“上当了”修复 Ubuntu Linux 中 “Command ‘python’ not found” 的错误 | Linux 中国民國時期的英文課本:用这些开源工具在 Linux 上编辑 PDF 文件 | Linux 中国33 个 "不得不看" 的 Python 关键字总结!看漫画就能学会?最适合留学生快速上手的Python教程来了!Python环境搭建手把手图文教程Julia 和 Python,哪一个更快? | Linux 中国Python证书的含金量高吗?使用 Python 的 requests 和 Beautiful Soup 来分析网页 | Linux 中国10 个 Python 脚本来自动化你的日常任务关于 Linux 和 Git 的创造者 Linus Torvalds 的 20 件趣事 | Linux 中国胡渊鸣:import 一个“太极”库,让 Python 代码提速100倍!10个Python脚本来自动化你的日常任务
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。