Testen¶
Dank Starlette ist das Testen von FastAPI-Anwendungen einfach und macht Spaß.
Es basiert auf HTTPX, welches wiederum auf der Grundlage von requests konzipiert wurde, es ist also sehr vertraut und intuitiv.
Damit können Sie pytest direkt mit FastAPI verwenden.
Verwendung von TestClient
¶
Importieren Sie TestClient
.
Erstellen Sie einen TestClient
, indem Sie ihm Ihre FastAPI-Anwendung übergeben.
Erstellen Sie Funktionen mit einem Namen, der mit test_
beginnt (das sind pytest
-Konventionen).
Verwenden Sie das TestClient
-Objekt auf die gleiche Weise wie httpx
.
Schreiben Sie einfache assert
-Anweisungen mit den Standard-Python-Ausdrücken, die Sie überprüfen müssen (wiederum, Standard-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"}
Tipp
Beachten Sie, dass die Testfunktionen normal def
und nicht async def
sind.
Und die Anrufe an den Client sind ebenfalls normale Anrufe, die nicht await
verwenden.
Dadurch können Sie pytest
ohne Komplikationen direkt nutzen.
Technische Details
Sie könnten auch from starlette.testclient import TestClient
verwenden.
FastAPI stellt denselben starlette.testclient
auch via fastapi.testclient
bereit, als Annehmlichkeit für Sie, den Entwickler. Es kommt aber tatsächlich direkt von Starlette.
Tipp
Wenn Sie in Ihren Tests neben dem Senden von Anfragen an Ihre FastAPI-Anwendung auch async
-Funktionen aufrufen möchten (z. B. asynchrone Datenbankfunktionen), werfen Sie einen Blick auf die Async-Tests im Handbuch für fortgeschrittene Benutzer.
Tests separieren¶
In einer echten Anwendung würden Sie Ihre Tests wahrscheinlich in einer anderen Datei haben.
Und Ihre FastAPI-Anwendung könnte auch aus mehreren Dateien/Modulen, usw. bestehen.
FastAPI Anwendungsdatei¶
Nehmen wir an, Sie haben eine Dateistruktur wie in Größere Anwendungen beschrieben:
.
├── app
│ ├── __init__.py
│ └── main.py
In der Datei main.py
haben Sie Ihre FastAPI-Anwendung:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
Testdatei¶
Dann könnten Sie eine Datei test_main.py
mit Ihren Tests haben. Sie könnte sich im selben Python-Package befinden (dasselbe Verzeichnis mit einer __init__.py
-Datei):
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
Da sich diese Datei im selben Package befindet, können Sie relative Importe verwenden, um das Objekt app
aus dem main
-Modul (main.py
) zu importieren:
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"}
... und haben den Code für die Tests wie zuvor.
Testen: erweitertes Beispiel¶
Nun erweitern wir dieses Beispiel und fügen weitere Details hinzu, um zu sehen, wie verschiedene Teile getestet werden.
Erweiterte FastAPI-Anwendungsdatei¶
Fahren wir mit der gleichen Dateistruktur wie zuvor fort:
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
Nehmen wir an, dass die Datei main.py
mit Ihrer FastAPI-Anwendung jetzt einige andere Pfadoperationen hat.
Sie verfügt über eine GET
-Operation, die einen Fehler zurückgeben könnte.
Sie verfügt über eine POST
-Operation, die mehrere Fehler zurückgeben könnte.
Beide Pfadoperationen erfordern einen X-Token
-Header.
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=400, 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=400, 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=400, detail="Item already exists")
fake_db[item.id] = item
return item
Tipp
Bevorzugen Sie die Annotated
-Version, falls möglich.
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
Tipp
Bevorzugen Sie die Annotated
-Version, falls möglich.
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
Erweiterte Testdatei¶
Anschließend könnten Sie test_main.py
mit den erweiterten Tests aktualisieren:
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"}
Wenn Sie möchten, dass der Client Informationen im Request übergibt und Sie nicht wissen, wie das geht, können Sie suchen (googeln), wie es mit httpx
gemacht wird, oder sogar, wie es mit requests
gemacht wird, da das Design von HTTPX auf dem Design von Requests basiert.
Dann machen Sie in Ihren Tests einfach das gleiche.
Z. B.:
- Um einen Pfad- oder Query-Parameter zu übergeben, fügen Sie ihn der URL selbst hinzu.
- Um einen JSON-Body zu übergeben, übergeben Sie ein Python-Objekt (z. B. ein
dict
) an den Parameterjson
. - Wenn Sie Formulardaten anstelle von JSON senden müssen, verwenden Sie stattdessen den
data
-Parameter. - Um Header zu übergeben, verwenden Sie ein
dict
imheaders
-Parameter. - Für Cookies ein
dict
imcookies
-Parameter.
Weitere Informationen zum Übergeben von Daten an das Backend (mithilfe von httpx
oder dem TestClient
) finden Sie in der HTTPX-Dokumentation.
Info
Beachten Sie, dass der TestClient
Daten empfängt, die nach JSON konvertiert werden können, keine Pydantic-Modelle.
Wenn Sie ein Pydantic-Modell in Ihrem Test haben und dessen Daten während des Testens an die Anwendung senden möchten, können Sie den jsonable_encoder
verwenden, der in JSON-kompatibler Encoder beschrieben wird.
Tests ausführen¶
Danach müssen Sie nur noch pytest
installieren:
$ pip install pytest
---> 100%
Es erkennt die Dateien und Tests automatisch, führt sie aus und berichtet Ihnen die Ergebnisse.
Führen Sie die Tests aus, mit:
$ 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>