FastAPI + Dependency Injector において Configuration をモックする
概要
FastAPIとDependency Injectorを併用する記事 の延長です。
Configuration
プロバイダにより、環境変数やYAML設定ファイルなどによる設定値の注入が行えます。これに関する課題です。
雛型に従う場合、DIコンテナの初期化時 (FastAPIの初期化時) に設定値を読みに行く実装をするため、そこにモックをしかけるテストを書きづらいのに苦労しました。この問題は環境変数のConfigurationで顕著です。モックをしかける前にFastAPI初期化とともに環境変数を見に行かれてしまい、環境変数が無くて死ぬ、ということです。[1]
まあまあ無難な解決法を見出したので書き残します。
先に結論
-
@app.on_event("startup")
で初期化時のコールバックを定義し、環境変数はそこで読む。 - テストでは
@mock.patch.dict(os.environ, {"ENV_NAME": "hogepiyo"})
の要領で環境変数を定義。
問題となるコード例
/
にGETすると、MY_ENV
という環境変数の値を {"result": "***"}
の形式で返すWebAPIを例にとって示します。
環境
[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.79.0"
uvicorn = "^0.18.2"
dependency-injector = "^4.39.1"
DIコンテナ
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
wiring_config = containers.WiringConfiguration(modules=[".endpoints"])
env_config = providers.Configuration()
FastAPI
from fastapi import FastAPI
from . import endpoints
from .containers import Container
container = Container()
container.env_config.value.from_env("MY_ENV")
app = FastAPI()
app.include_router(endpoints.router)
from dependency_injector import providers
from dependency_injector.wiring import Provide, inject
from fastapi import APIRouter, Depends
from .containers import Container
router = APIRouter()
@router.get("/")
@inject
def get_env(
config: providers.Configuration = Depends(Provide[Container.env_config]),
) -> dict[str, str]:
return {"result": str(config["value"])}
テスト
環境変数MY_ENV
をセットしていないので、以下は失敗します。
環境変数をセットする方法はあまたあると思いますが、何通りも切り替えたとしてもテストしやすい方法を検討します。
import pytest
from fastapi.testclient import TestClient
from src.app import app, container
test_client = TestClient(app)
def test_get():
response = test_client_local.get("/")
assert response.json() == {"result": "AAAAA"}
解決策
mock.patch
による環境変数モック化
段階1. unittest.mock.patch
による、以下のような環境変数の設定が、公式ドキュメントでも紹介されており試しやすいです。
import os
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from src.app import app, container
test_client = TestClient(app)
@mock.patch.dict(os.environ, {"MY_ENV": "AAAAA"})
def test_get():
response = test_client.get("/")
assert response.json() == {"result": "AAAAA"}
しかしまだこれは意図通り動きません。mock.patch
する前に既に、app
をimportするところで環境変数を読み込みに行かれてしまっています。MY_ENV=AAAAA
の指定はFastAPIには効いていません。
@app.on_event("startup")
で環境変数読み込みを遅延
段階2. 仕様はこちらをご参照ください: https://fastapi.tiangolo.com/advanced/events/
from fastapi import Depends, FastAPI
from . import endpoints
from .containers import Container
container = Container()
app = FastAPI()
app.include_router(endpoints.router)
# 追加
@app.on_event("startup")
async def startup_event():
container.env_config.value.from_env("MY_ENV")
app.py
のimport時すぐには環境変数を読まずに、FastAPIの起動時に読むようになります。これで、実稼働でもテストでも問題ないはず・・・と思いきや、まだダメです。今の状態では、この startup_event
はテストの時は実行されません。
TestClient
をテストメソッド内部でつくり、withで括る
段階3. FastAPIは親切ですね。上の問題への対策は、ちょうどこのドキュメントに載っています。
テストをこのように変えて、完成です。
import os
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from src.app import app, container
@mock.patch.dict(os.environ, {"MY_ENV": "AAAAA"})
def test_get():
with TestClient(app) as test_client:
response = test_client.get("/")
assert response.json() == {"result": "AAAAA"}
テストメソッドが複数あって都度デコレータを書くのが面倒であれば、fixtureを使う手もあります。以下記事を参考にしました: https://stackoverflow.com/a/50048692
@pytest.fixture(autouse=True)
def setenv():
with mock.patch.dict(os.environ, {"MY_ENV": "AAAAA"}):
yield
def test_get():
with TestClient(app) as test_client:
response = test_client.get("/")
assert response.json() == {"result": "AAAAA"}
だんだん本題と逸れてきますが、テスト全体で一括で環境変数をセットしておきたいなら、pytest-envが便利かもしれません。pytest-envで環境変数デフォルト値をセットしておき、それ以外の値を試したい時にさらにmock.patch
で上書きする、という方法も考えられます。
(追記) FastAPI 0.94.0以降の仕様変更
@app.on_event("startup")
が非推奨になったようです。(2023年3月現在まだ使えます。)
‐ https://fastapi.tiangolo.com/release-notes/?h=lifespan#0940
lifespan
を使うようになりました。FastAPIの基盤となっているStarletteの仕様変更に由来するようです。
@asynccontextmanager
async def lifespan(app: FastAPI):
container.env_config.value.from_env("MY_ENV")
yield
app = FastAPI(lifespan=lifespan)
参考:
最終形のコード
app.py
と test_app.py
が変化しました。
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
wiring_config = containers.WiringConfiguration(modules=[".endpoints"])
env_config = providers.Configuration()
from fastapi import FastAPI
from . import endpoints
from .containers import Container
container = Container()
app = FastAPI()
app.include_router(endpoints.router)
@app.on_event("startup")
async def startup_event():
container.env_config.value.from_env("MY_ENV")
from dependency_injector import providers
from dependency_injector.wiring import Provide, inject
from fastapi import APIRouter, Depends
from .containers import Container
router = APIRouter()
@router.get("/")
@inject
def get_env(
config: providers.Configuration = Depends(Provide[Container.env_config]),
) -> dict[str, str]:
return {"result": str(config["value"])}
import os
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from src.app import app, container
@mock.patch.dict(os.environ, {"MY_ENV": "AAAAA"})
def test_get():
with TestClient(app) as test_client:
response = test_client.get("/")
assert response.json() == {"result": "AAAAA"}
TestClientをfixtureにしたい場合
@pytest.fixture(scope="function")
def test_client(request):
with mock.patch.dict("os.environ", {"MY_ENV": request.param}):
with TestClient(app) as client:
yield client
@pytest.mark.parametrize("test_client", ["AAAAA"], indirect=True)
def test_get(test_client: TestClient):
response = test_client.get("/")
assert response.json() == {"result": "AAAAA"}
-
テストでも事前に環境変数をセットすればよいかというと、それでエラーは回避できますがモックのしやすさに難が出てくると考えます。 ↩︎
Discussion