CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: FastAPI の例外ハンドラを pytest でテストする

Web アプリケーションの例外ハンドリングでは、未処理の例外はキャッチして特定の HTTP レスポンスに変換するのが定石だろう。 このとき、未処理の例外をキャッチして HTTP レスポンスに変換する仕組みは例外ハンドラと呼ばれることが多い。 そして、FastAPI にも例外ハンドラの仕組みが用意されている。

fastapi.tiangolo.com

今回は例外ハンドラの振る舞いを pytest を使ってユニットテストする方法について書いてみる。 なお、FastAPI を使ったプロジェクトのユニットテストに関する基本的な内容は以下に書いた。

blog.amedama.jp

使った環境は次のとおり。

$ sw_vers   
ProductName:        macOS
ProductVersion:     26.3
BuildVersion:       25D125
$ uv --version
uv 0.10.6 (Homebrew 2026-02-24)
$ uv tree 2>/dev/null      
helloworld v0.1.0
├── fastapi v0.133.1
│   ├── annotated-doc v0.0.4
│   ├── pydantic v2.12.5
│   │   ├── annotated-types v0.7.0
│   │   ├── pydantic-core v2.41.5
│   │   │   └── typing-extensions v4.15.0
│   │   ├── typing-extensions v4.15.0
│   │   └── typing-inspection v0.4.2
│   │       └── typing-extensions v4.15.0
│   ├── starlette v0.52.1
│   │   └── anyio v4.12.1
│   │       └── idna v3.11
│   ├── typing-extensions v4.15.0
│   └── typing-inspection v0.4.2 (*)
├── uvicorn v0.41.0
│   ├── click v8.3.1
│   └── h11 v0.16.0
├── httpx v0.28.1 (group: dev)
│   ├── anyio v4.12.1 (*)
│   ├── certifi v2026.2.25
│   ├── httpcore v1.0.9
│   │   ├── certifi v2026.2.25
│   │   └── h11 v0.16.0
│   └── idna v3.11
└── pytest v9.0.2 (group: dev)
    ├── iniconfig v2.3.0
    ├── packaging v26.0
    ├── pluggy v1.6.0
    └── pygments v2.19.2
(*) Package tree already displayed

もくじ

例外ハンドラについて

前述したとおり、例外ハンドラは未処理の例外をキャッチして何らかの処理をする。 Web アプリケーションでは HTTP のレスポンスに変換するのが定石だろう。

FastAPI の例外ハンドラは fastapi.FastAPI のインスタンスに exception_handler() デコレータを使ってハンドラを登録する。 以下のサンプルコードでは Exception をキャッチして HTTP ステータスコード 500 のレスポンスに変換する。 Exception は多くの例外の基底クラスとなっているため、デフォルトの例外ハンドラとして動作する。

from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse

app = FastAPI()


@app.exception_handler(Exception)
def exception_handler(request: Request, exc: Exception):
    """アプリケーションで raise された例外をレスポンスに変換するハンドラ"""
    body = {
        "message": str(exc),
    }
    return JSONResponse(
        status_code=500,
        content=body,
    )


@app.get("/")
def root_get():
    raise Exception("Oops!")


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app)

上記の Python モジュールを helloworld パッケージ以下に main.py として用意する。 ディレクトリの構造は次のようになっている。

$ tree .
.
├── helloworld
│   ├── __init__.py
│   └── main.py
├── pyproject.toml
└── tests
    └── helloworld
        ├── __init__.py
        └── test_main.py

4 directories, 5 files

tests 以下のディレクトリはテスト用で詳しくは後述する。

FastAPI のアプリケーションを Uvicorn から起動する。

$ uv run uvicorn helloworld.main:app --reload

上記で Uvicorn がフォアグラウンドで起動する。

別のターミナルを開いて curl で HTTP API のエンドポイントにアクセスする。

$ curl http://localhost:8000/
{"message":"Oops!"}

レスポンスは例外ハンドラで指定した形式の JSON になっている。 例外ハンドラによって例外が HTTP レスポンスに変換されていることが確認できる。

ユニットテストを書く

例外ハンドラのテストは、通常の FastAPI のテストと同様に fastapi.testclient.TestClient を使えば良い。 ただし、デフォルトでは例外ハンドラで処理された例外が改めてスローされる点に気をつける必要がある。 つまり pytest を使う場合であれば pytest.raises() を使って例外が上がることを確認しないといけない。

以下のサンプルコードでは、先ほどのアプリケーションに対応したテストを書いている。 先ほどのディレクトリ構成の test_main.py に対応する。

import pytest
from fastapi.testclient import TestClient

from helloworld.main import app


def test_root_get():
    """GET / をテストする"""
    # TestClient を使うとデフォルトでは例外ハンドラがあっても改めて例外が上がる
    client = TestClient(app)
    #  pytest.raises() を使ってどのような例外が上がったかまで検証できる
    with pytest.raises(Exception):
        response = client.get("/")
        assert response.status_code == 500
        assert response.json() == {"message": "Oops!"}


if __name__ == "__main__":
    pytest.main([__file__, "-s", "-v"])

上記のテストを実行してみよう。

$ uv run pytest -v                              
============================================= test session starts ==============================================
platform darwin -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0 -- /Users/amedama/Documents/example/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/example
configfile: pyproject.toml
plugins: anyio-4.12.1
collected 1 item                                                                                               

tests/helloworld/test_main.py::test_root_get PASSED                                                      [100%]

============================================== 1 passed in 0.13s ===============================================

テストがパスした。 pytest.raises() は例外が上がらないとむしろテストが失敗する。 つまり、例外ハンドラで処理された例外が改めて上がっていることが確認できる。

なお、この振る舞いを変更することもできる。 TestClient をインスタンス化するときに引数の raise_server_exceptionsFalse を指定してみよう。 こうすることで例外ハンドラで処理された例外が改めて上がる振る舞いが抑制できる。

以下のサンプルコードでは実際に引数を指定している。 今度はテストする際に改めて例外が上がらないので pytest.raises() が必要ない。

import pytest
from fastapi.testclient import TestClient

from helloworld.main import app


def test_root_get():
    """GET / をテストする"""
    # どのような例外が上がったかまで検証しなくて良いときは
    # raise_server_exceptions に False を指定する
    client = TestClient(app, raise_server_exceptions=False)
    response = client.get("/")
    assert response.status_code == 500
    assert response.json() == {"message": "Oops!"}


if __name__ == "__main__":
    pytest.main([__file__, "-s", "-v"])

テストを実行すると先ほどと同じようにパスする。

$ uv run pytest -v                              
============================================= test session starts ==============================================
platform darwin -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0 -- /Users/amedama/Documents/example/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/example
configfile: pyproject.toml
plugins: anyio-4.12.1
collected 1 item                                                                                               

tests/helloworld/test_main.py::test_root_get PASSED                                                      [100%]

============================================== 1 passed in 0.13s ===============================================

まとめ

今回は FastAPI の例外ハンドラを fastapi.testclient.TestClient でテストする際の注意点について紹介した。 デフォルトの振る舞いは処理したはずの例外が改めて上がってくるため、初見だと少し戸惑うかもしれない。