新聞中心
在這個(gè)教程中,你將學(xué)到如何對(duì)執(zhí)行 HTTP 請(qǐng)求代碼的進(jìn)行單元測(cè)試。也就是說(shuō),你將看到用 Python 對(duì) API 進(jìn)行單元測(cè)試的藝術(shù)。

公司主營(yíng)業(yè)務(wù):網(wǎng)站設(shè)計(jì)、網(wǎng)站建設(shè)、移動(dòng)網(wǎng)站開(kāi)發(fā)等業(yè)務(wù)。幫助企業(yè)客戶真正實(shí)現(xiàn)互聯(lián)網(wǎng)宣傳,提高企業(yè)的競(jìng)爭(zhēng)能力。成都創(chuàng)新互聯(lián)是一支青春激揚(yáng)、勤奮敬業(yè)、活力青春激揚(yáng)、勤奮敬業(yè)、活力澎湃、和諧高效的團(tuán)隊(duì)。公司秉承以“開(kāi)放、自由、嚴(yán)謹(jǐn)、自律”為核心的企業(yè)文化,感謝他們對(duì)我們的高要求,感謝他們從不同領(lǐng)域給我們帶來(lái)的挑戰(zhàn),讓我們激情的團(tuán)隊(duì)有機(jī)會(huì)用頭腦與智慧不斷的給客戶帶來(lái)驚喜。成都創(chuàng)新互聯(lián)推出羅湖免費(fèi)做網(wǎng)站回饋大家。
單元測(cè)試是指對(duì)單個(gè)行為的測(cè)試。在測(cè)試中,一個(gè)眾所周知的經(jīng)驗(yàn)法則就是隔離那些需要外部依賴的代碼。
比如,當(dāng)測(cè)試一段執(zhí)行 HTTP 請(qǐng)求的代碼時(shí),建議在測(cè)試過(guò)程中,把真正的調(diào)用替換成一個(gè)假的的調(diào)用。這種情況下,每次運(yùn)行測(cè)試的時(shí)候,就可以對(duì)它進(jìn)行單元測(cè)試,而不需要執(zhí)行一個(gè)真正的 HTTP 請(qǐng)求。
問(wèn)題就是,怎樣才能隔離這些代碼?
這就是我希望在這篇博文中回答的問(wèn)題!我不僅會(huì)向你展示如果去做,而且也會(huì)權(quán)衡不同方法之間的優(yōu)點(diǎn)和缺點(diǎn)。
要求:
- ??Python 3.8??
- pytest-mock
- requests
- flask
- responses
- ??VCR.py??
使用一個(gè)天氣狀況 REST API 的演示程序
為了更好的解決這個(gè)問(wèn)題,假設(shè)你正在創(chuàng)建一個(gè)天氣狀況的應(yīng)用。這個(gè)應(yīng)用使用第三方天氣狀況 REST API 來(lái)檢索一個(gè)城市的天氣信息。其中一個(gè)需求是生成一個(gè)簡(jiǎn)單的 HTML 頁(yè)面,像下面這個(gè)圖片:
倫敦的天氣,OpenWeatherMap。圖片是作者自己制作的。
為了獲得天氣的信息,必須得去某個(gè)地方找。幸運(yùn)的是,通過(guò) ??OpenWeatherMap?? 的 REST API 服務(wù),可以獲得一切需要的信息。
好的,很棒,但是我該怎么用呢?
通過(guò)發(fā)送一個(gè) ??GET??? 請(qǐng)求到:??https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric??,就可以獲得你所需要的所有東西。在這個(gè)教程中,我會(huì)把城市名字設(shè)置成一個(gè)參數(shù),并確定使用公制單位。
檢索數(shù)據(jù)
使用 ??requests?? 模塊來(lái)檢索天氣數(shù)據(jù)。你可以創(chuàng)建一個(gè)接收城市名字作為參數(shù)的函數(shù),然后返回一個(gè) JSON。JSON 包含溫度、天氣狀況的描述、日出和日落時(shí)間等數(shù)據(jù)。
下面的例子演示了這樣一個(gè)函數(shù):
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()
這個(gè) URL 是由兩個(gè)全局變量構(gòu)成:
BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
API = BASE_URL + "?q={city_name}&appid={api_key}&units=metric"
API 以這個(gè)格式返回了一個(gè) 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當(dāng)調(diào)用 ??resp.json()??? 的時(shí)候,數(shù)據(jù)是以 Python 字典的形式返回的。為了封裝所有細(xì)節(jié),可以用 ??dataclass??? 來(lái)表示它們。這個(gè)類有一個(gè)工廠方法,可以獲得這個(gè)字典并且返回一個(gè) ??WeatherInfo?? 實(shí)例。
這種辦法很好,因?yàn)榭梢员3诌@種表示方法的穩(wěn)定。比如,如果 API 改變了 JSON 的結(jié)構(gòu),就可以在同一個(gè)地方(??from_dict??? 方法中)修改邏輯。其他代碼不會(huì)受影響。你也可以從不同的源獲得信息,然后把它們都整合到 ??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"]),
)
現(xiàn)在來(lái)創(chuàng)建一個(gè)叫做 ??retrieve_weather??? 的函數(shù)。使用這個(gè)函數(shù)調(diào)用 API,然后返回一個(gè) ??WeatherInfo??,這樣就可創(chuàng)建你自己的 HTML 頁(yè)面。
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)
很好,我們的 app 現(xiàn)在有一些基礎(chǔ)了。在繼續(xù)之前,對(duì)這些函數(shù)進(jìn)行單元測(cè)試。
1、使用 mock 測(cè)試 API
??根據(jù)維基百科???,模擬對(duì)象mock object是通過(guò)模仿真實(shí)對(duì)象來(lái)模擬它行為的一個(gè)對(duì)象。在 Python 中,你可以使用 ??unittest.mock??? 庫(kù)來(lái)模擬mock任何對(duì)象,這個(gè)庫(kù)是標(biāo)準(zhǔn)庫(kù)中的一部分。為了測(cè)試 ??retrieve_weather??? 函數(shù),可以模擬 ??requests.get??,然后返回靜態(tài)數(shù)據(jù)。
pytest-mock
在這個(gè)教程中,會(huì)使用 ??pytest??? 作為測(cè)試框架。通過(guò)插件,??pytest??? 庫(kù)是非常具有擴(kuò)展性的。為了完成我們的模擬目標(biāo),要用 ??pytest-mock???。這個(gè)插件抽象化了大量 ??unittest.mock??? 中的設(shè)置,也會(huì)讓你的代碼更簡(jiǎn)潔。如果你感興趣的話,我在 ??另一篇博文中?? 會(huì)有更多的討論。
好的,言歸正傳,現(xiàn)在看代碼。
下面是一個(gè) ??retrieve_weather??? 函數(shù)的完整測(cè)試用例。這個(gè)測(cè)試使用了兩個(gè) ??fixture???:一個(gè)是由 ??pytest-mock??? 插件提供的 ??mocker?? fixture, 還有一個(gè)是我們自己的。就是從之前請(qǐng)求中保存的靜態(tài)數(shù)據(jù)。
@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)
如果運(yùn)行這個(gè)測(cè)試,會(huì)獲得下面的輸出:
============================= 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
很好,測(cè)試通過(guò)了!但是...生活并非一帆風(fēng)順。這個(gè)測(cè)試有優(yōu)點(diǎn),也有缺點(diǎn)?,F(xiàn)在來(lái)看一下。
優(yōu)點(diǎn)
好的,有一個(gè)之前討論過(guò)的優(yōu)點(diǎn)就是,通過(guò)模擬 API 的返回值,測(cè)試變得簡(jiǎn)單了。將通信和 API 隔離,這樣測(cè)試就可以預(yù)測(cè)了。這樣總會(huì)返回你需要的東西。
缺點(diǎn)
對(duì)于缺點(diǎn),問(wèn)題就是,如果不再想用 ??requests??? 了,并且決定回到標(biāo)準(zhǔn)庫(kù)的 ??urllib???,怎么辦。每次改變 ??find_weather_for?? 的代碼,都得去適配測(cè)試。好的測(cè)試是,當(dāng)你修改代碼實(shí)現(xiàn)的時(shí)候,測(cè)試時(shí)不需要改變的。所以,通過(guò)模擬,你最終把測(cè)試和實(shí)現(xiàn)耦合在了一起。
而且,另一個(gè)不好的方面是你需要在調(diào)用函數(shù)之前進(jìn)行大量設(shè)置——至少是三行代碼。
...
# 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
...
我可以做的更好嗎?
是的,請(qǐng)繼續(xù)看。我現(xiàn)在看看怎么改進(jìn)一點(diǎn)。
使用 responses
用 ??mocker??? 功能模擬 ??requests??? 有點(diǎn)問(wèn)題,就是有很多設(shè)置。避免這個(gè)問(wèn)題的一個(gè)好辦法就是使用一個(gè)庫(kù),可以攔截 ??requests??? 調(diào)用并且給它們 打補(bǔ)丁patch。有不止一個(gè)庫(kù)可以做這件事,但是對(duì)我來(lái)說(shuō)最簡(jiǎn)單的是 ??responses???。我們來(lái)看一下怎么用,并且替換 ??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)
這個(gè)函數(shù)再次使用了我們的 ??fake_weather_info?? fixture。
然后運(yùn)行測(cè)試:
============================= test session starts ==============================
...
tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED [100%]
============================== 1 passed in 0.19s ===============================
非常好!測(cè)試也通過(guò)了。但是...并不是那么棒。
優(yōu)點(diǎn)
使用諸如 ??responses??? 這樣的庫(kù),好的方面就是不需要再給 ??requests?? 打補(bǔ)丁patch。通過(guò)將這層抽象交給庫(kù),可以減少一些設(shè)置。然而,如果你沒(méi)注意到的話,還是有一些問(wèn)題。
缺點(diǎn)
和 ??unittest.mock??? 很像,測(cè)試和實(shí)現(xiàn)再一次耦合了。如果替換 ??requests??,測(cè)試就不能用了。
2、使用適配器測(cè)試 API
如果用模擬讓測(cè)試耦合了,我能做什么?
設(shè)想下面的場(chǎng)景:假如說(shuō)你不能再用 ??requests??? 了,而且必須要用 ??urllib??? 替換,因?yàn)檫@是 Python 自帶的。不僅僅是這樣,你了解了不要把測(cè)試代碼和實(shí)現(xiàn)耦合,并且你想今后都避免這種情況。你想替換 ??urllib??,也不想重寫(xiě)測(cè)試了。
事實(shí)證明,你可以抽象出執(zhí)行 ??GET?? 請(qǐng)求的代碼。
真的嗎?怎么做?
可以使用適配器adapter來(lái)抽象它。適配器是一種用來(lái)封裝其他類的接口,并作為新接口暴露出來(lái)的一種設(shè)計(jì)模式。用這種方式,就可以修改適配器而不需要修改代碼了。比如,在 ??find_weather_for??? 函數(shù)中,封裝關(guān)于 ??requests?? 的所有細(xì)節(jié),然后把這部分暴露給只接受 URL 的函數(shù)。
所以,這個(gè):
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()
現(xiàn)在到了重構(gòu) ??retrieve_weather?? 函數(shù)的時(shí)候:
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?? 的實(shí)現(xiàn),只要換一下適配器:
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)
好的,那測(cè)試怎么做?
為了測(cè)試 ??retrieve_weather??, 只要?jiǎng)?chuàng)建一個(gè)在測(cè)試過(guò)程中使用的假的適配器:
@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)
如果運(yùn)行測(cè)試,會(huì)獲得:
============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED [100%]
============================== 1 passed in 0.22s ===============================
優(yōu)點(diǎn)
這個(gè)方法的優(yōu)點(diǎn)是可以成功將測(cè)試和實(shí)現(xiàn)解耦。使用??依賴注入??dependency injection在測(cè)試期間注入一個(gè)假的適配器。你也可以在任何時(shí)候更換適配器,包括在運(yùn)行時(shí)。這些事情都不會(huì)改變?nèi)魏涡袨椤?/p>
缺點(diǎn)
缺點(diǎn)就是,因?yàn)槟阍跍y(cè)試中用了假的適配器,如果在實(shí)現(xiàn)中往適配器中引入了一個(gè) bug,測(cè)試的時(shí)候就不會(huì)發(fā)現(xiàn)。比如說(shuō),往 ??requests?? 傳入了一個(gè)有問(wèn)題的參數(shù),像這樣:
def requests_adapter(url: str) -> dict:
resp = requests.get(url, headers=)
return resp.json()
在生產(chǎn)環(huán)境中,適配器會(huì)有問(wèn)題,而且單元測(cè)試沒(méi)辦法發(fā)現(xiàn)。但是事實(shí)是,之前的方法也會(huì)有同樣的問(wèn)題。這就是為什么不僅要單元測(cè)試,并且總是要集成測(cè)試。也就是說(shuō),要考慮另一個(gè)選項(xiàng)。
3、使用 VCR.py 測(cè)試 API
現(xiàn)在終于到了討論我們最后一個(gè)選項(xiàng)了。誠(chéng)實(shí)地說(shuō),我也是最近才發(fā)現(xiàn)這個(gè)。我用模擬mock也很長(zhǎng)時(shí)間了,而且總是有一些問(wèn)題。??VCR.py?? 是一個(gè)庫(kù),它可以簡(jiǎn)化很多 HTTP 請(qǐng)求的測(cè)試。
它的工作原理是將第一次運(yùn)行測(cè)試的 HTTP 交互記錄為一個(gè) YAML 文件,叫做 ??cassette???。請(qǐng)求和響應(yīng)都會(huì)被序列化。當(dāng)?shù)诙芜\(yùn)行測(cè)試的時(shí)候,??VCT.py?? 將攔截對(duì)請(qǐng)求的調(diào)用,并且返回一個(gè)響應(yīng)。
現(xiàn)在看一下下面如何使用 ??VCR.py??? 測(cè)試 ??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)
天吶,就這樣?沒(méi)有設(shè)置???@vcr.use_cassette()?? 是什么?
是的,就這樣!沒(méi)有設(shè)置,只要一個(gè) ??pytest?? 標(biāo)注告訴 VCR 去攔截調(diào)用,然后保存 cassette 文件。
cassette 文件是什么樣?
好問(wèn)題。這個(gè)文件里有很多東西。這是因?yàn)?VCR 保存了交互中的所有細(xì)節(jié)。
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=&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
確實(shí)很多!
真的!好的方面就是你不需要留意它。??VCR.py?? 會(huì)為你安排好一切。
優(yōu)點(diǎn)
現(xiàn)在看一下優(yōu)點(diǎn),我可以至少列出五個(gè):
- 沒(méi)有設(shè)置代碼。
- 測(cè)試仍然是分離的,所以很快。
- 測(cè)試是確定的。
- 如果你改了請(qǐng)求,比如說(shuō)用了錯(cuò)誤的 header,測(cè)試會(huì)失敗。
- 沒(méi)有與代碼實(shí)現(xiàn)耦合,所以你可以換適配器,而且測(cè)試會(huì)通過(guò)。唯一有關(guān)系的東西就是請(qǐng)求必須是一樣的。
缺點(diǎn)
再與模擬相比較,除了避免了錯(cuò)誤,還是有一些問(wèn)題。
如果 API 提供者出于某種原因修改了數(shù)據(jù)格式,測(cè)試仍然會(huì)通過(guò)。幸運(yùn)的是,這種情況并不經(jīng)常發(fā)生,而且在這種重大改變之前,API 提供者通常會(huì)給他們的 API 提供不同版本。
另一個(gè)需要考慮的事情是就地in place端到端end-to-end測(cè)試。每次服務(wù)器運(yùn)行的時(shí)候,這些測(cè)試都會(huì)調(diào)用。顧名思義,這是一個(gè)范圍更廣、更慢的測(cè)試。它們會(huì)比單元測(cè)試覆蓋更多。事實(shí)上,并不是每個(gè)項(xiàng)目都需要使用它們。所以,就我看來(lái),??VCR.py?? 對(duì)于大多數(shù)人的需求來(lái)說(shuō)都綽綽有余。
總結(jié)
就這么多了。我希望今天你了解了一些有用的東西。測(cè)試 API 客戶端應(yīng)用可能會(huì)有點(diǎn)嚇人。然而,當(dāng)武裝了合適的工具和知識(shí),你就可以馴服這個(gè)野獸。
在 ??我的 Github?? 上可以找到這個(gè)完整的應(yīng)用。
標(biāo)題名稱:用Python測(cè)試API的三種方式
網(wǎng)頁(yè)URL:http://fisionsoft.com.cn/article/dpjgpsp.html


咨詢
建站咨詢
