この記事でできること
ファイル版SQLiteを使うテストで、個々のテストは速いのにスイート全体が遅い場合、テーブルの作成・削除に伴うディスク同期がボトルネックになっていることがあります。
この記事では、テストDBをインメモリSQLiteへ置き換えず、次のPRAGMAをテスト用接続だけに設定して実行時間を短縮する方法を扱います。
PRAGMA synchronous=OFF;
PRAGMA journal_mode=MEMORY;
実際のバックエンドテスト667件では、実行時間が337.8秒から50.8秒まで短縮されました。ただし、耐久性を下げる設定なので、適用先は失っても作り直せるテストDBに限定します。
この方法が向くケース
PRAGMAを変更する前に、次の条件を満たしているか確認します。
- テスト用DBがファイル版SQLiteである
- テストごとにテーブルの作成・削除や大量のトランザクションを繰り返している
- テスト終了後のDBファイルを保存しておく必要がない
- DBファイルが壊れても、テストを最初から再実行すればよい
個々のテストが遅い場合は、先にそのテストの処理を改善します。pytest --durationsで上位のテストを合計しても全体時間を説明できず、共通のDB初期化を外すと速くなる場合に、この設定を検討します。
インメモリSQLiteへ置き換えられるなら、それも選択肢です。ただし、複数セッションによる同時更新、一意制約への並行書き込み、別トランザクション間のロックをテストしている場合は、接続の共有方法を変えることでテスト条件まで変わらないか確認します。ファイル版の接続構成を維持したい場合に、PRAGMAだけを変更する方法が使えます。
1. SQLAlchemyの接続時にPRAGMAを設定する
SQLAlchemyでは、EngineのconnectイベントでDBAPI接続が作られた直後に設定できます。イベントリスナー内でDBAPI接続からカーソルを取得し、2つのPRAGMAを実行してカーソルを閉じます。
connectイベントを使うことで、Engineが新しいDBAPI接続を作るたびに同じ設定を適用できます。実装時は本番用Engineではなく、テスト用Engineのインスタンスだけをイベントの対象にします。SQLAlchemyのイベント仕様は公式ドキュメントで確認できます。
2つのPRAGMAの役割は異なります。
-
synchronous=OFF: OSへ書き込みを渡した後の同期処理を待たない -
journal_mode=MEMORY: ロールバックジャーナルをディスク上のファイルではなくメモリに置く
journal_mode=OFFとは違い、MEMORYでは通常実行中のROLLBACKは使えます。一方、プロセスやOSの異常終了、電源断が書き込み途中に起きた場合の復旧性は失われます。詳細はSQLite公式のsynchronousとjournal_modeを参照してください。
2. 設定値そのものをテストする
この高速化設定が後から外れても、機能テストは失敗しません。実行時間が元に戻るだけなので、CIが遅くなるまで退行に気づけない可能性があります。
そこで、テスト用Engineから実際に接続し、PRAGMA synchronousとPRAGMA journal_modeの問い合わせ結果を回帰テストで固定します。期待値はそれぞれ0とmemoryです。
設定したコードが存在することではなく、実際の接続で値が有効になっていることを確認するのがポイントです。イベントリスナーが外れた場合や、別のEngineにだけ設定してしまった場合も検出できます。
3. 同じ条件で変更前後を測る
高速化の効果は、テスト件数、実行環境、並列数を揃えて比較します。全体時間だけでなく、全テストが引き続き通ることと、複数回実行して不安定になっていないことも確認します。
PRAGMAの変更前後でテスト用Engineや接続プールまで同時に変更すると、どの変更が効いたのか分からなくなります。まずPRAGMAだけを変更して測定します。
適用してはいけない範囲
この設定を本番DBへそのまま適用してはいけません。今回許容したのは、テストが途中で止まれば削除して最初から作り直せるDBだからです。
また、テストの目的がクラッシュ後の復旧や永続性の確認である場合にも使えません。高速化によって捨てる性質が、テスト対象そのものになってしまいます。
適用前に、次の3点を確認します。
- DBファイルを失っても、テストを最初から再実行すればよいか
- 別接続・別トランザクションの構成を維持する必要があるか
- 耐久性や異常終了からの復旧を検証するテストではないか
結果
原因調査とは別に変更前後を同じ条件で測り直し、667件のバックエンドテストで次の結果になりました。
| 実行時間 | |
|---|---|
| 変更前 | 337.8秒 |
| 変更後 | 50.8秒 |
インメモリSQLiteへ置き換えなくても、テストでは不要なディスク同期だけを外すことで、接続構成を変えずに大部分の待ち時間を削れました。
なぜインメモリ化を見送り、この方法を選んだのかはZenn版にまとめています。