Тестирование
Благодаря Starlette, тестирование приложений FastAPI простое и приятное.
Основано на HTTPX, который, в свою очередь, разработан на основе Requests, поэтому он очень знакомый и интуитивно понятный.
С его помощью вы можете использовать pytest непосредственно с FastAPI.
Использование TestClient
Информация
Для использования TestClient, сначала установите httpx.
Убедитесь, что вы создали виртуальную среду, активировали её, а затем установили её, например:
$ pip install httpx
Импортируйте TestClient.
Создайте TestClient, передав в него ваше приложение FastAPI.
Создавайте функции с именем, начинающимся с test_ (это стандартные pytest соглашения).
Используйте объект TestClient так же, как и с httpx.
Записывайте простые assert утверждения со стандартными выражениями Python, которые вам нужно проверить (снова, стандартные pytest).
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
client = TestClient(app)
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
Подсказка
Обратите внимание, что функции тестирования являются обычными def, а не async def.
И вызовы к клиенту также являются обычными вызовами, не использующими await.
Это позволяет использовать pytest напрямую без осложнений.
Технические подробности
Вы также можете использовать from starlette.testclient import TestClient.
FastAPI предоставляет те же starlette.testclient что и fastapi.testclient, просто как удобство для вас, разработчика. Но оно происходит непосредственно из Starlette.
Подсказка
Если вы хотите вызвать функции async в своих тестах, помимо отправки запросов вашему приложению FastAPI (например, асинхронные функции базы данных), ознакомьтесь с Асинхронными тестами в руководстве по продвинутым темам.
Разделение тестов
В реальном приложении ваши тесты, вероятно, будут находиться в разных файлах.
И ваше приложение FastAPI также может состоять из нескольких файлов/модулей и т. д.
Файл приложения FastAPI
Предположим, у вас есть структура файлов, как описано в Больших приложениях:
.
├── app
│ ├── __init__.py
│ └── main.py
В файле main.py у вас есть ваше приложение FastAPI:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
Файл тестов
Затем у вас может быть файл test_main.py с вашими тестами. Он может находиться в том же пакете Python (в той же директории с файлом __init__.py):
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
Поскольку этот файл находится в том же пакете, вы можете использовать относительные импорты для импорта объекта app из модуля main (main.py):
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
…и код тестов будет таким же, как и раньше.
Тестирование: расширенный пример
Теперь давайте расширим этот пример и добавим больше деталей, чтобы увидеть, как протестировать разные части.
Расширенный файл приложения FastAPI
Давайте продолжим с той же структурой файлов, что и прежде:
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
Предположим, что сейчас в файле main.py с вашим приложением FastAPI есть некоторые другие операции с путями.
Он имеет GET операцию, которая может возвращать ошибку.
У него есть POST операция, которая может возвращать несколько ошибок.
Обе операции с путями требуют заголовка X-Token.
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: str | None = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item
return item
from typing import Annotated, Union
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: Union[str, None] = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item
return item
from typing import Union
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from typing_extensions import Annotated
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: Union[str, None] = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item
return item
Подсказка
Предпочтительно использовать версию Annotated при возможности.
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: str | None = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item
return item
Подсказка
Предпочтительно использовать версию Annotated при возможности.
from typing import Union
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
fake_secret_token = "coneofsilence"
fake_db = {
"foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
"bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}
app = FastAPI()
class Item(BaseModel):
id: str
title: str
description: Union[str, None] = None
@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item_id not in fake_db:
raise HTTPException(status_code=404, detail="Item not found")
return fake_db[item_id]
@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
if x_token != fake_secret_token:
raise HTTPException(status_code=400, detail="Invalid X-Token header")
if item.id in fake_db:
raise HTTPException(status_code=409, detail="Item already exists")
fake_db[item.id] = item
return item
Расширенный файл тестов
Затем вы можете обновить test_main.py с расширенными тестами:
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_item():
response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
assert response.status_code == 200
assert response.json() == {
"id": "foo",
"title": "Foo",
"description": "There goes my hero",
}
def test_read_item_bad_token():
response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
assert response.status_code == 400
assert response.json() == {"detail": "Invalid X-Token header"}
def test_read_nonexistent_item():
response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
assert response.status_code == 404
assert response.json() == {"detail": "Item not found"}
def test_create_item():
response = client.post(
"/items/",
headers={"X-Token": "coneofsilence"},
json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
)
assert response.status_code == 200
assert response.json() == {
"id": "foobar",
"title": "Foo Bar",
"description": "The Foo Barters",
}
def test_create_item_bad_token():
response = client.post(
"/items/",
headers={"X-Token": "hailhydra"},
json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid X-Token header"}
def test_create_existing_item():
response = client.post(
"/items/",
headers={"X-Token": "coneofsilence"},
json={
"id": "foo",
"title": "The Foo ID Stealers",
"description": "There goes my stealer",
},
)
assert response.status_code == 409
assert response.json() == {"detail": "Item already exists"}
Всякий раз, когда вам нужно, чтобы клиент передавал информацию в запросе, и вы не знаете как, вы можете поискать (в Google), как это сделать в httpx, или даже как это сделать с помощью requests, так как дизайн HTTPX основан на дизайне Requests.
Затем вы просто делаете то же самое в своих тестах.
- Для передачи параметра пути или запроса добавьте его в сам URL.
- Для передачи тела JSON передайте объект Python (например,
dict) в параметрjson. - Если вам нужно отправить данные формы вместо JSON, используйте параметр
dataвместо него. - Для передачи заголовков используйте
dictв параметреheaders. - Для куки,
dictв параметреcookies.
Для получения дополнительной информации о передаче данных в бэкенд (используя httpx или TestClient) см. документацию HTTPX.
Информация
Обратите внимание, что TestClient получает данные, которые можно преобразовать в JSON, а не модели Pydantic.
Если у вас есть модель Pydantic в вашем тесте, и вы хотите отправить её данные в приложение во время тестирования, вы можете использовать jsonable_encoder, описанный в JSON-совместимом кодировщике.
Запуск
После этого вам нужно установить pytest.
Убедитесь, что вы создали виртуальную среду, активировали её, а затем установили её, например:
$ pip install pytest
---> 100%
Он автоматически обнаружит файлы и тесты, выполнит их и сообщит вам результаты.
Запустите тесты с помощью:
$ pytest
================ test session starts ================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 6 items
---> 100%
test_main.py <span style="color: green; white-space: pre;">...... [100%]</span>
<span style="color: green;">================= 1 passed in 0.03s =================</span>
© 2018 Sebastián Ramírez
Licensed under the MIT License.
https://fastapi.tiangolo.com/tutorial/testing/