Skip to content

pytest

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

まずは FastAPI 公式ドキュメント: https://fastapi.tiangolo.com/tutorial/testing/#using-testclient


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

まずは pytest をインストール(uv コマンドについては uv のページ を参照)。

uv add pytest

# 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
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 3 items

test_main.py::test_inc_positive PASSED                                   [ 33%]
test_main.py::test_inc_zero PASSED                                       [ 66%]
test_main.py::test_inc_invalid_input PASSED                              [100%]

============================== 3 passed in 0.13s ===============================

わざとテストに失敗するように書き換えてみる。

# 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": 9}  # FAIL


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

% uv run pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 3 items

test_main.py::test_inc_positive FAILED                                   [ 33%]
test_main.py::test_inc_zero PASSED                                       [ 66%]
test_main.py::test_inc_invalid_input PASSED                              [100%]

=================================== FAILURES ===================================
______________________________ test_inc_positive _______________________________

    def test_inc_positive():
        response = client.get("/inc?x=5")
        assert response.status_code == 200
>       assert response.json() == {"result": 9}  # FAIL
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: assert {'result': 6} == {'result': 9}
E         
E         Differing items:
E         {'result': 6} != {'result': 9}
E         
E         Full diff:
E           {
E         -     'result': 9,...
E         
E         ...Full output truncated (4 lines hidden), use '-vv' to show

test_main.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_main.py::test_inc_positive - AssertionError: assert {'result': 6}...
========================= 1 failed, 2 passed in 0.20s ==========================

期待値( {"result": 9} )と実際の値( {'result': 6} )が違ったからテストは失敗になってる。ここまでが基本。


関数のパラメータ化

上の 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

% uv run pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 2 items

test_main.py::test_inc_success PASSED                                    [ 50%]
test_main.py::test_inc_invalid_input PASSED                              [100%]

============================== 2 passed in 0.13s ===============================

まとめられたけど、結果を見ると 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 session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 3 items

test_main.py::test_inc_success[5-6] PASSED                               [ 33%]
test_main.py::test_inc_success[0-1] PASSED                               [ 66%]
test_main.py::test_inc_invalid_input PASSED                              [100%]

============================== 3 passed in 0.18s ===============================

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
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 1 item

test_fixture.py .                                                        [100%]

============================== 1 passed in 0.00s ===============================

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 --setup-show test_fixture.py
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 1 item

test_fixture.py 
        SETUP    F db
        test_fixture.py::test_something (fixtures used: db).
        TEARDOWN F db

============================== 1 passed in 0.01s ===============================

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 --setup-show test_fixture.py
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 3 items

test_fixture.py 
        SETUP    F db
        test_fixture.py::test_something1 (fixtures used: db).
        TEARDOWN F db
        SETUP    F db
        test_fixture.py::test_something2 (fixtures used: db).
        TEARDOWN F db
        SETUP    F db
        test_fixture.py::test_something3 (fixtures used: db).
        TEARDOWN F db

============================== 3 passed in 0.01s ===============================

結果を見ると、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 --setup-show test_fixture.py
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 3 items

test_fixture.py 
SETUP    S db
        test_fixture.py::test_something1 (fixtures used: db).
        test_fixture.py::test_something2 (fixtures used: db).
        test_fixture.py::test_something3 (fixtures used: db).
TEARDOWN S db

============================== 3 passed in 0.01s ===============================

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
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 1 item

test_regist.py Sent Email to alice@example.com
.

============================== 1 passed in 0.01s ===============================

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
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 1 item

test_regist.py .

============================== 1 passed in 0.01s ===============================

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
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 1 item

test_regist.py --- [TEST] Sending to alice@example.com (Simulated) ---
.

============================== 1 passed in 0.01s ===============================

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


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
============================= test session starts ==============================
platform darwin -- Python 3.14.5, pytest-9.0.3, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /Users/alice/code/my-fastapi-app
plugins: anyio-4.13.0
collected 1 item

test_capacity.py .                                                       [100%]

============================== 1 passed in 0.04s ===============================

Note

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


Full Stack FastAPI Template での pytest 使用例

実プロジェクトでの書き方は、テンプレートのテスト一式が参考になる。

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