pytest¶
Note
pytest 自体は汎用のテストフレームワークで、 FastAPI 専用ってわけじゃない。
テストで使う FastAPI の TestClient も、 Starlette の TestClient をそのまま再エクスポートしてるだけ(中身は httpx ベース)。
pytest で FastAPI アプリのテストを書ける。
- インストール: cf. 公式ドキュメント
まずは FastAPI 公式ドキュメント: https://fastapi.tiangolo.com/tutorial/testing/ を読むといい。
pytest の基本¶
例えば下記のコード。渡された整数値に +1 するエンドポイントがあるだけ。
from fastapi import FastAPI
app = FastAPI()
@app.get("/inc")
def inc(x: int):
return {"result": x + 1}
curl で機能を確認。
このコードにテストコードを書いてみる。
テストの命名規則
test_*.py というファイル名にしておくと、 pytest コマンドを引数なしで実行しても自動で検出されてテストが走るし、テストコードだと一目で分かる。
ファイルの中の関数も、 test_ から始めておけば自動で検出される。
cf. https://docs.pytest.org/en/stable/explanation/goodpractices.html#conventions-for-python-test-discovery
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 で各テストの結果を表示。
わざとテストが失敗するように書き換えてみる; main.py にバグを仕込んだ想定で。
from fastapi import FastAPI
app = FastAPI()
@app.get("/inc")
def inc(x: int):
return {"result": x + 2} # Fail point
もう一度実行する。
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_positive と test_inc_zero で同じような処理を2回繰り返してる気がする。
x=5->status_code == 200,{"result": 6}x=0->status_code == 200,{"result": 1}
シンプルに for 文でまとめてみる。
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 のバグは修正してから
まとめられたけど、結果を見ると test_inc_success が1つ pass したという表示になっていて、関数の中の2ケースが pass したことが分からない。
そこで @pytest.mark.parametrize で関数をパラメータ化すると、ケースごとに表示されるようになる。
- cf. https://docs.pytest.org/en/stable/reference/reference.html#pytest-mark-parametrize
- cf. https://docs.pytest.org/en/stable/how-to/parametrize.html
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
実行してみる。
test_inc_success[5-6] ・ test_inc_success[0-1] のように、ケースごとに pass したことが分かるようになる。
pytest のフィクスチャ¶
pytest で DB に繋ぎたいときなどに使える。 FastAPI でいうところの Depends(get_db) みたいなことが @pytest.fixture でできる。
import pytest
@pytest.fixture
def db():
db = "Connected DB"
return db
def test_something(db):
assert db == "Connected DB"
実行してみる。
fixture だけを conftest.py にまとめておくと、複数のテストファイルから読み込める( import も不要)。
test_fixture.py では、 conftest.py で DB に繋がってるので引数として db をもらうだけ。
--setup-show を付けると、テストと fixture の処理順序も表示される。
test_fixture.py を編集して、 db を使うテスト関数を3つにしてみる。
def test_something1(db):
assert db == "Connected DB"
def test_something2(db):
assert db == "Connected DB"
def test_something3(db):
assert db == "Connected DB"
実行してみる。
結果を見ると、3回も同じ DB に繋いでいて効率が悪そう。 fixture のスコープを変えると、DB には1回だけ繋いで3つのテストで使い回せる。
import pytest
@pytest.fixture(scope="session") # default: scope="function"
def db():
db = "Connected DB"
return db
実行してみる。
SETUP ・ TEARDOWN が1回ずつになった。
Note
DB の状態が変わらないなら、接続回数を減らした方がテストは速くなる。
けど DB の状態が変わるテストだと、使い回すと前のテストの影響が残るので、それが面倒ならテスト関数ごとに繋ぐ( scope="function" )のもアリ。
モック (unittest.mock.patch)¶
unittest.mock.patch を使えば、例えばメール送信機能のテストで、実際にはメールを送らずに「送信関数が呼ばれたか」だけを見てテストできる。
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} !!"
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 を表示させながら実行する。
Sent Email to ... が表示されてるので、本当ならメールが送信されている。テストのたびに送信されては困るので、 patch で send_email を何もしない mock_send に置き換える。
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回だけ呼ばれたかをテスト(より厳しい)
実行してみる。
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 が使える。
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)
実行してみる。
mock_send の正体(型と使えるメソッド)も調べてみる。
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_send は MagicMock で、 assert_called_once などのメソッドが使える。
モック (monkeypatch)¶
環境変数や設定値を「テストのときだけ」差し替える程度なら、 pytest 組み込みの monkeypatch が便利。
設定値として MAX_CAPACITY を pydantic-settings で定義しておく。
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
MAX_CAPACITY: int = 100
settings = Settings()
MAX_CAPACITY を超えてるかどうかで分岐するコード。
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.setattr で MAX_CAPACITY を書き換えられるので、その前後で返り値が変わるはず。
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"}
実行してみる。
Note
monkeypatch で変更した内容は、そのテスト関数が終わると自動で元に戻る。なので他のテストに影響しない。
Full Stack FastAPI Template での pytest 使用例¶
フルスタックテンプレートのテスト一式はかなり参考になる。
https://github.com/fastapi/full-stack-fastapi-template/tree/master/backend/tests