テスト¶
Starlette のおかげで、FastAPI アプリケーションのテストは簡単で楽しいものになっています。
これは HTTPX をベースにしており、HTTPX は Requests をベースに設計されているため、非常に馴染み深く直感的です。
これを使えば、pytest を FastAPI と直接使用できます。
TestClient
の使用¶
情報
TestClient
を使用するには、まず httpx
をインストールします。
仮想環境を作成し、アクティブ化してからインストールしてください。たとえば、
$ pip install httpx
TestClient
をインポートします。
FastAPI アプリケーションを渡して TestClient
を作成します。
test_
で始まる名前の関数を作成します (これは標準的な pytest
の規約です)。
TestClient
オブジェクトは httpx
と同じように使用します。
チェックする必要がある標準的な Python 式で単純な assert
ステートメントを記述します (これも標準的な 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 は、開発者の利便性のために、fastapi.testclient
と同じ starlette.testclient
を提供しています。しかし、それは Starlette から直接来ています。
ヒント
FastAPI アプリケーションにリクエストを送信する以外に、テストで async
関数 (例: 非同期データベース関数) を呼び出す必要がある場合は、詳細チュートリアルの非同期テストを参照してください。
テストの分離¶
実際のアプリケーションでは、おそらくテストは別のファイルにあります。
そして、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
このファイルは同じパッケージ内にあるため、相対インポートを使用して main
モジュール (main.py
) から app
オブジェクトをインポートできます。
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
FastAPI アプリケーションを含む main.py
ファイルが、他のいくつかの パス操作 を持つようになったとします。
エラーを返す可能性のある 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"}
クライアントがリクエストに情報を渡す必要があるが、その方法がわからない場合は、httpx
で行う方法、あるいは HTTPX のデザインが Requests のデザインに基づいているため、requests
で行う方法を検索 (Google) できます。
そして、テストでも同じことを行います。
例:
- パス または クエリ パラメータを渡すには、URL自体に追加します。
- JSON ボディを渡すには、Python オブジェクト (例:
dict
) をパラメータjson
に渡します。 - JSON の代わりに フォームデータ を送信する必要がある場合は、代わりに
data
パラメータを使用します。 - ヘッダー を渡すには、
headers
パラメータにdict
を使用します。 - クッキー の場合は、
cookies
パラメータにdict
を使用します。
バックエンドにデータを渡す方法 (httpx
または TestClient
を使用) の詳細については、HTTPX のドキュメントを確認してください。
情報
TestClient
は、Pydantic モデルではなく、JSON に変換できるデータを受け取ることに注意してください。
テストに Pydantic モデルがあり、テスト中にそのデータをアプリケーションに送信したい場合は、JSON 互換エンコーダーで説明されている jsonable_encoder
を使用できます。
実行¶
その後、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>