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

# 前提

本記事は、以下の環境を想定しています。
- Python==3.8
- FastAPI==0.91.0

# はじめに

FastAPIは、PythonのWebアプリケーションフレームワークとして、最近非常に人気があります。

https://fastapi.tiangolo.com/ja/

そんなFastAPIですが、個人的に気に入っている点の一つとして、「テストを実装しやすい」という点があります。

本記事では実際に例を挙げて、FastAPIの基本的なテストの書き方から、ちょっと複雑なパターンまで実装をまとめてみました。

# FastAPIでのテストの基本

FastAPIでAPIエンドポイントを実装するとこのようになります。

```python
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を利用してテストを実装してみます。

```python
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です。

エンドポイント実装
```python
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}
```

テスト
```python
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型に変換する必要があります。

```python
client.post("/", json={"date":str(datetime.now())})
```


## リクエストヘッダを加える/レスポンスヘッダを確認する

リクエストヘッダを加える場合は、TestClientのリクエストを発行するメソッドに、
キーワード引数`headers`として、設定したいヘッダを辞書で指定します。
また、リクエストヘッダの設定値をテストする場合は、レスポンスの`headers`を確認します。

エンドポイント実装
```python
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 
```

テスト
```python
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形式で指定します。

エンドポイント実装
```python
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!"}
```

テスト
```python
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形式で指定します。

エンドポイント実装
```python
from fastapi import FastAPI, Form

app = FastAPI()

@app.post("/greetings")
def _greetings(name:str=Form()):
    return {"greetings": f"Hello, {name}!"}
```

テスト
```python
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`として、設定したいファイルを指定します（設定の仕方がちょっと複雑です）。

エンドポイント実装
```python
from fastapi import FastAPI, UploadFile, File, FileResponse

app = FastAPI()

@app.post("/file")
def _upload(upload_file: UploadFile=File(...)):
    return FileResponse("test.txt")
```

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


## 参考文献

- [テスト - FastAPI](https://fastapi.tiangolo.com/ja/tutorial/testing/)
- [HTTPX](https://www.python-httpx.org/)