この記事でできること
FastAPIとSQLAlchemyのアプリをpytestでテストするとき、テスト用のDBへ差し替える方法としてよく使われるのが、app.dependency_overridesでget_dbを差し替える方法です。
この差し替えはリクエストを処理する経路には効きますが、アプリの他の処理まで差し替わるとは限りません。差し替えから漏れた処理は、テスト中に開発用のDBを開きます。テスト自体は通るので、気づくきっかけは「全テストのあとに見覚えのないDBファイルが残っている」くらいしかありません。
この記事は、個人開発しているDevPickで実際に起きた漏れをもとに、テストが本当にテスト用のDBだけを使っているかを点検する手順をまとめたものです。対象は、SQLAlchemyのengineをモジュールの読み込み時に作り、ローカルとテストでSQLiteを使っている構成です。
点検1:テスト用の接続先を、アプリのimportより前に決めているか
多くのアプリでは、DB接続用のモジュールを読み込んだ時点で、設定値のDATABASE_URLからengineとSessionLocalが作られます。conftest.pyでアプリをimportすると、この時点で「開発用DBに向いたengine」ができあがります。
そのあとでテスト用のengineを別に作り、get_dbだけを差し替えても、アプリ側のengineは開発用DBに向いたまま残ります。
まず確認すること:
-
conftest.pyで、アプリやDB接続のモジュールをimportする前に、os.environ["DATABASE_URL"]へテスト用のURLを入れているか -
setdefaultではなく上書きしているか。開発者のシェルやCIの環境変数に開発用・本番用のDATABASE_URLが入っていると、setdefaultではそちらが使われる - テスト用に2つ目のengineを作らず、アプリのengineそのものをテスト用DBに向けているか
アプリのengineがテスト用DBを指していれば、どの経路からSessionLocalを呼んでも同じDBに着きます。差し替えの漏れを1つずつ探すより、出どころを1つにしたほうが確実です。
点検2:get_dbを通らない処理を洗い出したか
get_dbの差し替えから漏れるのは、get_dbを通らずにSessionLocalを直接呼ぶ処理です。たとえば次のようなものがあります。
- 「新しいセッションで処理する」ために、関数の中で
SessionLocal()を開くヘルパー - FastAPIの
BackgroundTasksやasyncio.create_taskで、レスポンスのあとに走る処理 - APSchedulerなどのスケジューラから呼ばれるジョブ
DevPickでは、記事一覧を返したあとに裏で記事の分析を始める処理がその一つで、1つ目と2つ目を組み合わせた形でした。リクエストのテストを書いたつもりでも、その中から呼ばれた処理は別の経路でDBを開いていたことになります。
点検1で出どころを1つにしたら、こうした処理がテスト用DBを本当に使っているかを、次のようなテストで確かめておきます。
-
SessionLocal()で開いたセッションのget_bind().urlが、テスト用のURLと一致することを確かめる - 全テストを流したあとに、開発用のDBファイルが作られていないことを
git statusで見る
点検3:本番用の接続時設定がテストにも乗っていないか
SQLiteでは、event.listens_for(engine, "connect")で接続のたびにPRAGMA journal_mode=WALなどを設定していることがあります。アプリのengineをテストでも使うと、この設定もテストに持ち込まれます。
- テスト用に別の
PRAGMA(synchronous=OFFやjournal_mode=MEMORYなど)を設定しているなら、本番用のリスナーをevent.removeで外し、テスト用の設定だけが効くようにする - WALモードのSQLiteは、接続を開いている間、
*.dbのほかに*.db-shmと*.db-walを作る。最後の接続をきちんと閉じれば消えるが、閉じきらないままプロセスが終わると残る(SQLiteのドキュメント)。.gitignoreが*.dbだけだと、残ったファイルは未追跡ファイルとして出てくる
.gitignoreへの追加は、開発サーバーを動かしたときにもこれらのファイルができるので、それ自体は必要です。ただし、テストのあとに-shm・-walが出てきたなら、それはテストがWAL設定のengine、つまり開発用DBを開いた跡です。隠す前に点検1と2を見てください。
点検4:SQLiteのURLを絶対パスにしているか
sqlite:///./test.dbのような相対パスは、URLを書いた時点ではなく、接続を開く時点の作業ディレクトリを基準に解決されます。
テストの中にmonkeypatch.chdirなどで作業ディレクトリを移すものがあると、そのあとで作られた接続プールは別の場所のファイルを開きます。ファイルが無ければ空のDBが新しく作られ、テーブルが無いエラーで落ちます。結果は直前に走ったテストの順序に左右されます。
- テスト用DBのURLは、
Path(...).resolve()などで絶対パスにしてから組み立てる - 作業ディレクトリを移したあとに新しくengineを作っても同じDBに着くことを、テストで確かめる
点検5:テスト用DBを「プロセスごと」に分けているか
pytest-xdistで並列に流す場合、PYTEST_XDIST_WORKER(gw0など)をファイル名に混ぜてワーカーごとにDBを分ける方法がよく使われます。ただし、ワーカーIDは1回のpytest実行の中で一意なだけです。
同じチェックアウトで、別々のターミナルやworktreeから2つのpytestを同時に起動すると、両方にgw0が現れます。点検4で絶対パスにしていると、両方が同じファイルを使います。テストごとにテーブルを初期化する作りだと、片方の実行がもう片方のデータを消し、テストがときどき落ちます。
- テスト用DBは、
tempfile.TemporaryDirectoryなどでpytestのプロセスごとに作った一時ディレクトリに置く。xdistのワーカーも別プロセスなので、これでワーカー単位の分離も兼ねられる -
TemporaryDirectoryのオブジェクトはモジュールの変数に持っておき、プロセスの終了時に片付けさせる
まとめ
-
get_dbの差し替えはリクエストの経路にしか効かない。テスト用の接続先は、アプリをimportする前にDATABASE_URLで渡し、アプリのengineそのものをテスト用DBに向ける -
get_dbを通らない処理(バックグラウンド処理、スケジューラ、SessionLocalを直接開くヘルパー)がテスト用DBを使っていることを、テストで確かめる - 本番用の
PRAGMAのリスナーはテストでは外す。テスト後に-shm・-walが残るなら、.gitignoreで隠す前に接続先を疑う - SQLiteの相対パスは接続時の作業ディレクトリで決まるので、絶対パスにする
- ワーカーIDだけでは別のpytest実行と衝突するので、プロセスごとの一時ディレクトリに置く
1つ直すたびに次の問題が見えてきた経緯と、.gitignoreに足して終わりにしなかった理由はZenn版に書いています。