前提
本記事は、以下の環境を想定しています。
- Python==3.8
- FastAPI==0.91.0
はじめに
FastAPIは、PythonのWebアプリケーションフレームワークとして、最近非常に人気があります。
そんなFastAPIですが、個人的に気に入っている点の一つとして、「テストを実装しやすい」という点があります。
本記事では実際に例を挙げて、FastAPIの基本的なテストの書き方から、ちょっと複雑なパターンまで実装をまとめてみました。
FastAPIでのテストの基本
FastAPIでAPIエンドポイントを実装するとこのようになります。
from fastapi import FastAPI
app = FastAPI()
@app.get("/greetings/{name}")
def _greetings(name:str):
return {"greetings":f"Hello, {name}."}
名前をパスパラメータとして受け取って、それをもとに生成したJSONを返すエンドポイントになります。
今回は、このエンドポイントに対し、正常にレスポンスを返せるか、レスポンスの内容は正しいかのテストを書いてみます。
テストを書く場合は、FastAPIからTestClientをインポートし、appを読み込ませ、それに対しリクエストを発行する流れになります。
このリクエストの発行の仕方は、HTTPXの使い方と大体一緒です。(というのも、FastAPIのテストはHTTPXをベースとしているからです。)
そのため、使い方に困った場合は、HTTPXのドキュメントを調査するのがよいと思われます。(FastAPIの公式ドキュメントにもそう書かれています。)
今回はunittestを利用してテストを実装してみます。
from unittest import TestCase
# TestClientはfastapi.testclientからimportできます
from fastapi.testclient import TestClient
# 別ファイルにテストを実装する場合は、APPをimportしてくればOKです
from main import APP
client = TestClient(app)
class TestGreetings(TestCase):
def test_greetings(self):
response = client.get("/greetings/tama")
self.assertEqual(response.status_code, 200)
self.assertDictEqual(response.json(), {"greetings":"Hello, tama."})
TestClient.get()
の返り値として、/greetings/tama
に対し疑似的に発行されたリクエストへのレスポンスのデータが返ってきます。
あとはこれらのデータの中身が期待通りの値かを確認すればよい訳です。シンプルですね。
実際のテストの際には、レスポンスコードやレスポンスボディ、その他確認したいパラメータ(ヘッダーやクッキーなど)に対して値を検証すると良いでしょう。
様々なパターン
ここからは様々なテストの実装パターンを見ていきましょう。
JSONをリクエストボディに設定する
データをPOSTする場合などはリクエストボディにJSON形式でデータをセットしたい場合があります。
この場合は、TestClientのリクエストを発行するメソッドに、キーワード引数json
として、
設定したいデータをdict形式で指定すればOKです。
エンドポイント実装
from pydantic import BaseModel
from fastapi import FastAPI
app = FastAPI()
class Name(BaseModel):
name:str
@app.post("/name")
def _name(data:Name):
return {"name":data.name}
テスト
from unittest import TestCase
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
class TestName(TestCase):
def test_name(self):
# 引数jsonでリクエストボディのJSONを指定
response = client.post("/name", json={"name": "mike"})
self.assertEqual(response.status_code, 200)
self.assertDictEqual(response.json(), {"name":"mike"})
※この時、json引数に指定するdictはJSONにシリアライズできる必要があります。
そのため、例えばdatetime型を指定した場合はエラーが発生します。
なお、datetime型の変数をJSONに加える場合は、str型に変換する必要があります。
client.post("/", json={"date":str(datetime.now())})
リクエストヘッダを加える/レスポンスヘッダを確認する
リクエストヘッダを加える場合は、TestClientのリクエストを発行するメソッドに、
キーワード引数headers
として、設定したいヘッダを辞書で指定します。
また、リクエストヘッダの設定値をテストする場合は、レスポンスのheaders
を確認します。
エンドポイント実装
from fastapi import FastAPI, Request, Response, HTTPException
app = FastAPI()
@app.get("/greetings")
def _greetings(request:Request, response:Response):
if request.headers.get("passcode") != "correct_passcode":
raise HTTPException(403)
response.headers["greetings"] = "Hello!"
return
テスト
from unittest import TestCase
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
class TestGreetings(TestCase):
def test_greetings(self):
with self.subTest("Correct Passcode."):
# 引数headersでリクエストヘッダを設定
response = client.get("/greetings", headers={"passcode": "correct_passcode"})
self.assertEqual(response.status_code, 200)
# レスポンスヘッダが正しく設定されているか確認する
self.assertEqual(response.headers["greetings"], "Hello!")
with self.subTest("Incorrect passcode."):
response = client.get("/greetings", headers={"passcode": "incorrect_passcode"})
# リクエストヘッダが正しくない場合に、ステータスコードとして403が返ってくるか確認する
self.assertEqual(response.status_code, 403)
クッキーの取り扱い
リクエストにクッキーを設定する場合は、TestClientのリクエストを発行するメソッドに、
キーワード引数cookies
として、設定したいクッキーをdict形式で指定します。
エンドポイント実装
from fastapi import FastAPI, Cookie, HTTPException, Response
app = FastAPI()
@app.post("/greetings")
def _greetings(response:Response, session_token=Cookie(default=None)):
if session_token != "secret_token":
raise HTTPException(401)
response.set_cookie(key="login", value="true")
return {"greetings": "HELLO!"}
テスト
from unittest import TestCase
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
class TestGreetings(TestCase):
def test_greetings(self):
with self.subTest("With session token."):
# 引数cookiesとして、設定したいクッキーをdict形式で指定
response = client.post("/greetings", cookies={"session_token": "secret_token"})
self.assertEqual(response.status_code, 200)
# レスポンスにクッキーが登録されているか確認する
self.assertEqual(response.cookies["login"], "true")
with self.subTest("Without session token."):
response = client.post("/greetings")
self.assertEqual(response.status_code, 401)
フォームデータのアップロード
フォームデータをアップロードしたい場合は、TextClientのリクエストを発行するメソッドに、
キーワード引数data
として、設定したいデータをdict形式で指定します。
エンドポイント実装
from fastapi import FastAPI, Form
app = FastAPI()
@app.post("/greetings")
def _greetings(name:str=Form()):
return {"greetings": f"Hello, {name}!"}
テスト
from unittest import TestCase
from fastapi.testclient import TestClient
client = TestClient(app)
class TestGreetings(TestCase):
def test_greetings(self):
# 引数dataにフォームデータを設定
response = client.post("/greetings", data={"name":"Tama"})
self.assertEqual(response.status_code, 200)
self.assertDictEqual(response.json(), {"greetings": "Hello, Tama!"})
ファイルをアップロード/ダウンロード
ファイルをアップロードしたい場合は、TestClientのリクエストを発行するメソッドに、
キーワード引数files
として、設定したいファイルを指定します(設定の仕方がちょっと複雑です)。
エンドポイント実装
from fastapi import FastAPI, UploadFile, File, FileResponse
app = FastAPI()
@app.post("/file")
def _upload(upload_file: UploadFile=File(...)):
return FileResponse("test.txt")
テスト
import io
from unittest import TestCase
from fastapi.testclient import TestClient
client = TestClient(app)
class TestUpload(TestCase):
def test_upload(self):
with open("test.txt") as f:
# 引数filesでアップロードしたいファイルを設定
# files={"エンドポイントで設定した引数名":("ファイル名", file-like, "MIMEタイプ")}
response = client.post("/file", files={"upload_file": ("setting_file.txt", f, "text/plain")})
self.assertEqual(response.status_code, 200)
# 帰ってきたファイルの中身を確認したい場合は、一旦ストリームに変換してから
# ファイルを読み出す要領で中身を確認すればよいと思われます
file = io.BytesIO(response._content)
self.assertEqual(file.readline(), b"hoge")
まとめ
今回はFastAPIのテストの様々なパターンを見てきました。
テストを実装することで、実装時間は伸びるものの、それ以上の恩恵を受けることができるようになります。
皆さんも今回の内容をもとに、ぜひテストを活用してみてくださいね。
なお、DBなど外部モジュールと連携したテストを実装する場合は、今回の例のように一筋縄ではいかず、テストが複雑になりがちです。
この場合は、DependencyInjectionを利用するときれいに実装することができます。
この話題は機会があったら別の記事に書こうと思います。