0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

FastAPIで実装した様々なエンドポイントのテストを書く(フォームデータの送信、クッキーの確認、ファイルのアップロード等)

Posted at

前提

本記事は、以下の環境を想定しています。

  • 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を利用するときれいに実装することができます。
この話題は機会があったら別の記事に書こうと思います。

参考文献

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?