😯

FastAPIのテストコードを書いてDIの重要性を知った話

に公開

きっかけ

こんにちは、ランサーズでエンジニアをしている岡田です。

当時は開発スピード優先でテストコードなんて二の次。
DIの概念は知りつつも、「DIを意識した開発コードって?」「DIを意識すると何が良いの?」と分からないままでした。

それでも、FastAPI+sqlalchemyの開発では、DBを使う時に大抵エンドポイントの引数にdb: Session = Depends(get_db)が出てきます。
「どうしてエンドポイントの内部ではなく引数で書くのか?」といまいち納得しないまま、FastAPIの書き方に従っておまじないのようにDependsを書いてました。

そして、開発コードも増えて「ずっと手動でテストしてるわけにもいかないし、そろそろテストコードを書いて品質担保しないとな」と重い腰を上げた時、とうとうDIの重要性とDependsの真価を実感することになりました。

そもそもDI(Dependency Injection: 依存性注入)とは?

自分もまだまだ甘い理解ですが、DIとは関数内で生成したり直接参照したりしそうなオブジェクト(DBセッションや外部APIクライアントなど)を、外側から引数として「注入」してもらう設計パターンのことを言うそうです。
実は、FastAPIでは、この「外から注入する」という仕組みをDependsが実現してくれています。

https://fastapi.tiangolo.com/ja/tutorial/dependencies/

テストコードを書いていて気づいた課題

初めてpytestを使って、拙いながらも簡単な関数の単体テストはできることが分かりました。
依存している関数も一つや二つなら、monkeypatchで簡単に差し替えできます。
しかし、入力から出力まで一つのエンドポイントの挙動を確認するテストコードを書こうとしたとき、壁にぶち当たります。
APIエンドポイントは大抵複数の処理をひとまとめにしているため、ユーザー認証だったり、DB接続だったり、色々な依存関係のあるオブジェクトを扱うことが多いです。
ここで依存している全ての関数を monkeypatch で差し替えようとするとテストコードが汚くなったり、今後開発コードを変えたときに monkeypatch も一緒に変え忘れないように注意を払う必要があったり、と色々な課題があることが分かりました😱

FastAPIのDependsの真価

この依存を解決してくれたのがFastAPIのDependsdependency_overridesです。
まず、通常の開発コードで、引数にDBセッションを Depends(get_db) で受け取るように書きます。

開発コード例

router.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from .database import get_db  # これが依存性
from . import crud

router = APIRouter()

@router.post("/something")
def my_function(
    db: Session = Depends(get_db),  # ★ここで Depends(get_db) を使っているのがポイント
):
    # ここでは db を使って何かしらの処理を行う
    a = crud.do_something_1(db)
    b = crud.do_something_2(db)
    c = crud.do_something_3(db)
    d = crud.do_something_4(db)

    ...

    return hoge

ここで、monkeypatch 等を使って、crud 関数を置き換えるのではなく、Depends を使って、依存関数を置き換えることができます。
以下は、pytest の fixture と app.dependency_overrides を使って、テスト用DBに差し替えた例です。

テストコード例

test_xxx.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient

from .main import app
from .database import get_db  # 上書き対象の依存関数

# テスト用のDBを準備(オンメモリや専用のSQLiteなど)
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
    SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

test_db = TestingSessionLocal()

@pytest.fixture(autouse=True)
def override_get_db():
    # テスト用の get_db を定義
    def _get_test_db():
        try:
            yield test_db
        finally:
            test_db.close()

    ### ここで依存性を上書きする ###
    app.dependency_overrides[get_db] = _get_test_db

    # fixture 自体は何も返さないが、テスト実行前に override が効いた状態になる
    yield

    # 後片付け(テスト実行後に関数を元に戻す)
    app.dependency_overrides.clear()

client = TestClient(app)

def test_my_function():
    response = client.post("/something", json={...})
    assert response.status_code == 200

これだけで、本番のコードはそのままに、DBをテスト用に差し替えただけで入力から出力まで丸っと一つのエンドポイントがテストできます。

まとめ

実際にテストコードを書いてみて

  • テスト側で app.dependency_overrides[get_db] = _get_test_db とするだけで、同じエンドポイントがテスト用DBへすり替わってくれる
  • 依存関係のあるオブジェクトはDependsで引数に与えておけば、エンドポイント内にある幾つもの関数を monkeypatch で置き換えることなく、数行でテストコードに反映できる

「あ、これはDI意識して開発コード書いた方が絶対に良いわ。今度から依存の強いオブジェクトはDependsで引数に与えておこう。」と腑に落ちた瞬間でした。

一つだけ注意点としては、FastAPIのDependsはエンドポイントの関数にのみ利用可能で、エンドポイント以外の関数でDependsを使うとエラー表示はされないですが、予期せぬバグに繋がるので避けておいた方が良いでしょう。

さいごに

やっぱり書いてみないと・体験してみないと、大切さを理解できないことってありますよね。
「AIに任せてれば全て最適な方法で書いてくれるわ」となる前に知ることができたのは、とても良い体験だったと思います✨

ランサーズ株式会社

Discussion