😖

FastAPI + Dependency Injector において Configuration をモックする

2022/07/20に公開

概要

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を例にとって示します。

環境

pyproject.toml
[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.79.0"
uvicorn = "^0.18.2"
dependency-injector = "^4.39.1"

DIコンテナ

containers.py
from dependency_injector import containers, providers

class Container(containers.DeclarativeContainer):
    wiring_config = containers.WiringConfiguration(modules=[".endpoints"])
    env_config = providers.Configuration()

FastAPI

app.py
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)
endpoints.py
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をセットしていないので、以下は失敗します。

環境変数をセットする方法はあまたあると思いますが、何通りも切り替えたとしてもテストしやすい方法を検討します。

test_app.py
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"}

解決策

段階1. mock.patchによる環境変数モック化

unittest.mock.patchによる、以下のような環境変数の設定が、公式ドキュメントでも紹介されており試しやすいです。

test_app.py
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には効いていません。

段階2. @app.on_event("startup") で環境変数読み込みを遅延

仕様はこちらをご参照ください: https://fastapi.tiangolo.com/advanced/events/

app.py
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 はテストの時は実行されません。

段階3. TestClientをテストメソッド内部でつくり、withで括る

FastAPIは親切ですね。上の問題への対策は、ちょうどこのドキュメントに載っています。
https://fastapi.tiangolo.com/advanced/testing-events/

テストをこのように変えて、完成です。

test_app.py
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.pytest_app.py が変化しました。

containers.py
from dependency_injector import containers, providers

class Container(containers.DeclarativeContainer):
    wiring_config = containers.WiringConfiguration(modules=[".endpoints"])
    env_config = providers.Configuration()
app.py
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")
endpoints.py
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"])}
test_app.py
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"}
脚注
  1. テストでも事前に環境変数をセットすればよいかというと、それでエラーは回避できますがモックのしやすさに難が出てくると考えます。 ↩︎

Discussion