Skip to content

pytest

Note

pytest 自体は汎用のテストフレームワークで、 FastAPI 専用ってわけじゃない。
テストで使う FastAPI の TestClient も、 Starlette の TestClient をそのまま再エクスポートしてるだけ(中身は httpx ベース)。

pytest で FastAPI アプリのテストを書ける。

まずは FastAPI 公式ドキュメント: https://fastapi.tiangolo.com/tutorial/testing/ を読むといい。


pytest の基本

例えば下記のコード。渡された整数値に +1 するエンドポイントがあるだけ。

main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/inc")
def inc(x: int):
    return {"result": x + 1}

curl で機能を確認。

% curl -X 'GET' 'http://127.0.0.1:8000/inc?x=3' -H 'accept: application/json'
{"result":4}

このコードにテストコードを書いてみる。

テストの命名規則

test_*.py というファイル名にしておくと、 pytest コマンドを引数なしで実行しても自動で検出されてテストが走るし、テストコードだと一目で分かる。
ファイルの中の関数も、 test_ から始めておけば自動で検出される。
cf. https://docs.pytest.org/en/stable/explanation/goodpractices.html#conventions-for-python-test-discovery

test_main.py
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_inc_positive():
    response = client.get("/inc?x=5")
    assert response.status_code == 200
    assert response.json() == {"result": 6}


def test_inc_zero():
    response = client.get("/inc?x=0")
    assert response.status_code == 200
    assert response.json() == {"result": 1}


def test_inc_invalid_input():
    response = client.get("/inc?x=hello")
    assert response.status_code == 422

テストを実行する。 -v で各テストの結果を表示。

uv run pytest -v

わざとテストが失敗するように書き換えてみる; main.py にバグを仕込んだ想定で。

main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/inc")
def inc(x: int):
    return {"result": x + 2}  # Fail point

もう一度実行する。

uv run pytest -v

main.py でバグを作った( x + 2 )ので、 test_inc_positive(期待 {"result": 6} → 実際 {'result': 7} )と test_inc_zero(期待 {"result": 1} → 実際 {'result': 2} )の2つが失敗して、 2 failed, 1 passed になってる。

ここまでが pytest の基本。


関数のパラメータ化

上の test_main.py を見ると、 test_inc_positivetest_inc_zero で同じような処理を2回繰り返してる気がする。

  • x=5 -> status_code == 200, {"result": 6}
  • x=0 -> status_code == 200, {"result": 1}

シンプルに for 文でまとめてみる。

test_main.py
from fastapi.testclient import TestClient
from main import app


client = TestClient(app)


def test_inc_success():
    test_cases = [
        {"input": 5, "expected": 6},
        {"input": 0, "expected": 1},
    ]

    for case in test_cases:
        response = client.get(f"/inc?x={case['input']}")
        assert response.status_code == 200
        assert response.json() == {"result": case["expected"]}


def test_inc_invalid_input():
    response = client.get("/inc?x=hello")
    assert response.status_code == 422

実行してみる; main.py のバグは修正してから

uv run pytest -v

まとめられたけど、結果を見ると test_inc_success が1つ pass したという表示になっていて、関数の中の2ケースが pass したことが分からない。
そこで @pytest.mark.parametrize で関数をパラメータ化すると、ケースごとに表示されるようになる。

test_main.py
from fastapi.testclient import TestClient
import pytest
from main import app


client = TestClient(app)


@pytest.mark.parametrize("x_value, expected_result", [
    (5, 6),
    (0, 1),
])
def test_inc_success(x_value, expected_result):
    response = client.get(f"/inc?x={x_value}")
    assert response.status_code == 200
    assert response.json() == {"result": expected_result}


def test_inc_invalid_input():
    response = client.get("/inc?x=hello")
    assert response.status_code == 422

実行してみる。

uv run pytest -v

test_inc_success[5-6]test_inc_success[0-1] のように、ケースごとに pass したことが分かるようになる。


pytest のフィクスチャ

pytest で DB に繋ぎたいときなどに使える。 FastAPI でいうところの Depends(get_db) みたいなことが @pytest.fixture でできる。

test_fixture.py
import pytest


@pytest.fixture
def db():
    db = "Connected DB"
    return db


def test_something(db):
    assert db == "Connected DB"

実行してみる。

uv run pytest test_fixture.py

fixture だけを conftest.py にまとめておくと、複数のテストファイルから読み込める( import も不要)。

conftest.py
import pytest


@pytest.fixture()
def db():
    db = "Connected DB"
    return db

test_fixture.py では、 conftest.py で DB に繋がってるので引数として db をもらうだけ。

test_fixture.py
def test_something(db):
    assert db == "Connected DB"

--setup-show を付けると、テストと fixture の処理順序も表示される。

uv run pytest test_fixture.py --setup-show

test_fixture.py を編集して、 db を使うテスト関数を3つにしてみる。

test_fixture.py
def test_something1(db):
    assert db == "Connected DB"


def test_something2(db):
    assert db == "Connected DB"


def test_something3(db):
    assert db == "Connected DB"

実行してみる。

uv run pytest test_fixture.py --setup-show

結果を見ると、3回も同じ DB に繋いでいて効率が悪そう。 fixture のスコープを変えると、DB には1回だけ繋いで3つのテストで使い回せる。

conftest.py
import pytest


@pytest.fixture(scope="session")  # default: scope="function"
def db():
    db = "Connected DB"
    return db

実行してみる。

uv run pytest test_fixture.py --setup-show

SETUPTEARDOWN が1回ずつになった。

Note

DB の状態が変わらないなら、接続回数を減らした方がテストは速くなる。
けど DB の状態が変わるテストだと、使い回すと前のテストの影響が残るので、それが面倒ならテスト関数ごとに繋ぐ( scope="function" )のもアリ。


モック (unittest.mock.patch)

unittest.mock.patch を使えば、例えばメール送信機能のテストで、実際にはメールを送らずに「送信関数が呼ばれたか」だけを見てテストできる。

regist.py
def send_email(email):
    print(f"Sent Email to {email}")  # これが表示されたらメールが送信されたとみなす
    return True


def regist_user(name: str, email: str):
    send_email(email)
    return f"Regist completed, {name} !!"
test_regist.py
from regist import regist_user


def test_regist_user():
    test_name = "Alice"
    test_email = "alice@example.com"
    assert regist_user(test_name, test_email) == "Regist completed, Alice !!"

-s を付けて print を表示させながら実行する。

uv run pytest -s test_regist.py

Sent Email to ... が表示されてるので、本当ならメールが送信されている。テストのたびに送信されては困るので、 patchsend_email を何もしない mock_send に置き換える。

test_regist.py
from regist import regist_user

from unittest.mock import patch


def test_regist_user():
    with patch("regist.send_email") as mock_send:
        test_name = "Alice"
        test_email = "alice@example.com"
        result = regist_user(test_name, test_email)

        assert result == "Regist completed, Alice !!"
        mock_send.assert_called_once()  # send_email が1回だけ呼ばれたかをテスト
        mock_send.assert_called_once_with(test_email)  # test_email を引数に1回だけ呼ばれたかをテスト(より厳しい)

実行してみる。

uv run pytest -s test_regist.py

print が出ていないのでメール送信は無効化できていて、かつ assert_called_once で関数が呼ばれたかどうかはテストできている。

assert_called_once()assert_called_once_with()

どちらもモック( mock_send )が呼ばれたかを検証するメソッド。呼ばれていなかったり回数が違ったりすると AssertionError になる。

  • assert_called_once() … そのモックが1回だけ呼ばれたかをチェックする(引数は問わない)。
  • assert_called_once_with(test_email) … 1回だけ、しかもその引数( test_email )で呼ばれたかをチェックする(引数もチェックするので厳しい)。

cf. https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_once
cf. https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_once_with

mock_send を「何もしないもの」にするのではなくて別の処理に差し替えたいときは、 side_effect が使える。

test_regist.py
from regist import regist_user

from unittest.mock import patch


def test_regist_user():
    def mock_print(email):
        print(f"--- [TEST] Sending to {email} (Simulated) ---")
        return True

    with patch(
        "regist.send_email",
        side_effect=mock_print,
    ) as mock_send:
        test_name = "Alice"
        test_email = "alice@example.com"
        result = regist_user(test_name, test_email)

        assert result == "Regist completed, Alice !!"
        mock_send.assert_called_once()
        mock_send.assert_called_once_with(test_email)

実行してみる。

uv run pytest -s test_regist.py

mock_send の正体(型と使えるメソッド)も調べてみる。

mock_type.py
from unittest.mock import patch

with patch("regist.send_email") as mock_send:
    print(type(mock_send))
    print(dir(mock_send))

% uv run python mock_type.py
<class 'unittest.mock.MagicMock'>
['assert_any_call', 'assert_called', 'assert_called_once', 'assert_called_once_with', 'assert_called_with', 'assert_has_calls', 'assert_not_called', 'attach_mock', 'call_args', 'call_args_list', 'call_count', 'called', 'configure_mock', 'method_calls', 'mock_add_spec', 'mock_calls', 'reset_mock', 'return_value', 'side_effect']

mock_sendMagicMock で、 assert_called_once などのメソッドが使える。


モック (monkeypatch)

環境変数や設定値を「テストのときだけ」差し替える程度なら、 pytest 組み込みの monkeypatch が便利。

設定値として MAX_CAPACITY を pydantic-settings で定義しておく。

config.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    MAX_CAPACITY: int = 100


settings = Settings()

MAX_CAPACITY を超えてるかどうかで分岐するコード。

capacity.py
from config import settings

current_count = 10  # DBとかから取得した値とみなす


def check_capacity(n):
    if n > settings.MAX_CAPACITY:
        return {"status": "Full"}
    else:
        return {"status": "Open"}


def main():
    check_capacity(current_count)


if __name__ == "__main__":
    main()

monkeypatch.setattrMAX_CAPACITY を書き換えられるので、その前後で返り値が変わるはず。

test_capacity.py
from capacity import check_capacity
from config import settings


def test_check_capacity(monkeypatch):
    assert check_capacity(2) == {"status": "Open"}
    monkeypatch.setattr(settings, "MAX_CAPACITY", 1)
    assert check_capacity(2) == {"status": "Full"}

実行してみる。

uv run pytest test_capacity.py

Note

monkeypatch で変更した内容は、そのテスト関数が終わると自動で元に戻る。なので他のテストに影響しない。


Full Stack FastAPI Template での pytest 使用例

フルスタックテンプレートのテスト一式はかなり参考になる。

https://github.com/fastapi/full-stack-fastapi-template/tree/master/backend/tests