pytest高速化の秘訣
以下の文章はこちらの記事を翻訳したものです。
pytest スイートの実行速度を向上させるためのチェックリストを以下にまとめました。項目ごとに確認してみてください。
- 高速ハードウェアの利用
- 高速コレクション
- PYTHONDONTWRITEBYTECODE=1 の設定
- 組み込みpytestプラグインの無効化
- 必要なテストだけを実行
- ネットワークアクセスの制限
- ディスクアクセスの制限
- データベースアクセスの最適化
- テストの並列処理
詳しい手順や説明は、以下の動画リンクからも確認できます: YouTubeリンク
まずはこのガイドラインを確認し、最後の追加ヒントまでぜひ参照してください。
まずは測定から!
何より大切なのは、測定の重要性を理解することです。「2回測定し、1回最適化する(measure twice, optimize once)」という言葉を心に留めて、pytestの設定を変える前には、その効果を計測することを習慣にしましょう。具体的な手順は以下のとおりです:
- 一つの変更を実施する。
- ローカル環境とCIでその効果を計測する。
- 効果が確認できれば、その変更をコミットし、プッシュする。
- その変更を数日間試用する。
- 上記の手順を繰り返す。
テストスイート全体の実行時間を計測するには、 hyperfine
というツールがおすすめです。
特定のテストの実行時間を知りたい場合は、pytest --durations 10
で最も時間のかかる10のテストを確認することができます。
CPUの使用率やメモリの使用状況を確認するなら、pytest-monitor
が便利です。
テストの関数の呼び出しに関する詳細なプロファイリングにはpytest-profiling
が適しています。もしくは、直接cProfileモジュールを利用する方法もあります。
pyinstrument
という人気プロファイラから、pytestと併用する例が提供されていますので参考にしてみてください。
テストの実行について
高速ハードウェア
現代のノートパソコンは非常に高性能となっています。テストの完了を待ちながらの時間浪費は避けたいもの。ハードウェアへの投資を考えるのはどうでしょうか。まず、マシンの性能を上げることから始めてみる価値があります。
CIで多くのCPUコアを使用することのコストが問題となっていませんか?多くのCIプロバイダーは、self-hosted runner を安価に提供しています。Mac Miniを複数台導入し、ハイパフォーマンスなコアを活用してテストを行うのはいかがでしょうか。
高速コレクション
コレクションはテストスイート実行時の最初のステップであり、どのテストを実行すべきかを決定する過程です。1つのテストを実行する場合でさえこのステップは必要となりますので、迅速に行われることが望まれます。
--collect-only
オプションを使用して、コレクションの速度を確認してください。最新のノートパソコンであれば、1000件のテストのコレクションに1秒程度、大規模なコードベースでも2〜3秒を想定するとよいでしょう。
コレクションが遅い場合、以下の手法を試みることができます:
- pytestに特定のフォルダを探索させない設定をする
# pytest.ini
[pytest]
norecursedirs = docs *.egg-info .git .tox var/large_ml_model/
- pytestにテストが存在する具体的な場所だけを探索させる
# pytest.ini
[pytest]
testpaths = tests
または
pytest src/my_app/tests
- conftest.pyの一部のコードがコレクションを遅くしている可能性
以下のコマンドで確認することができます:
pytest --collect-only --noconftest
速度が向上した場合、conftest.py
内で遅延を引き起こす要因が存在することを示しています。
- インポートによる遅延の確認
以下のステップを試してみてください:
python -X importtime -m pytest
- 生成された出力を このサイト にペースト
- 遅延の主要な原因となるモジュールを特定
- トップレベルでのインポートを関数内へ移動させる(特にpytorch、opencv、Django、Ploneなどの大規模なライブラリの場合、遅延の原因となる可能性が高い)
PYTHONDONTWRITEBYTECODE
The Hitchhiker's Guide to Pythonによれば、開発環境においてバイトコードファイルの生成は不要とされています。
特にCI環境では、この生成はさらに不要であり、逆にテストスイートの実行速度を低下させる原因となる可能性があります。
この設定による効果の大小はケースによるでしょう。しかし、設定が簡単であるため、~/.profile
やCIの設定ファイルにexport PYTHONDONTWRITEBYTECODE=1
を追加して試してみる価値は十分にあると考えられます。
組み込みプラグイン
ご存知でしたか?pytestには30以上の組み込みプラグインが付属しています。しかしながら、これらのすべてを常に必要とするわけではありません。
現在使用しているプラグインの一覧を表示するためには、以下のコマンドを実行してください。
pytest --trace-config
不要と感じるプラグインを無効化するには、次のようにコマンドを入力します。
pytest -p no:doctest
速度向上の効果が大きいわけではありませんが、特に古いものや不要なプラグインを無効化することで、わずかながらもテストの実行速度を向上させることができます。例として、-p no:pastebin -p no:nose -p no:doctest
のようなコマンドで特定のプラグインを無効化することができます。
必要なテストだけを実行
私の考えでは、常に全てのテストを実行する必要は必ずしもありません。
pytest-skip-slow
を使用して、@pytest.mark.slow
デコレータを活用すれば、遅いテストをスキップすることができます。これはローカルの開発やCIの特定のブランチでの実行時に特に有効です。一方で、メインのCI実行時には--slow
オプションを利用して、全てのテストを実行するのが良いでしょう。
pytest-incremental
は、テストの実行の間にコードベースやファイルの変更を検出し、実行すべきテストを判定する機能を持っています。これはローカル開発だけでなくCI環境でも役立ちます。特定の変更に基づくテストの実行が問題なく完了した後に、全てのテストスイートを実行することで、CPU使用時間を節約することができます。
pytest-testmon
も類似の機能を提供していますが、変更されたコード行に基づいて実行するテストを判定するという、より高度なアルゴリズムを採用しています。
テストの実行方法
ネットワークアクセス
多くのユニットテストではインターネットへのアクセスは不要です。ネットワークに関する操作は大抵モックで置き換えられ、さまざまなレスポンスのテストを待機時間なしに迅速に実施することができます。
しかしながら、テストを行う過程で意図せずネットワークを利用していることがあるかもしれません。たとえば、誰かがGravatarからのプロファイル写真取得の機能を追加した場合、裏側で数々のテストがGravatarのAPIを呼び出していることが考えられます。
pytest-socket
は、意図しないネットワークアクセスを防止するための便利なプラグインです。このプラグインの使用は直感的であり、テストコード中で必要とされるネットワークアクセスにも柔軟に対応します。
ディスクアクセス
理想的には、ユニットテストは現在のファイルシステムに依存せずに動作すべきです。もしそうでなければ、ファイルシステムの変更によってテストの結果に不具合が生じるリスクがあります。さらに、ディスクへの書き込みはテストの実行速度を遅くする要因ともなり得ます。この問題に対処する方法の一つとして、ディスクのI/O操作をモック化することが考えられます。また、pyfakefs
を利用することで、仮想的なインメモリのファイルシステムを作成し、テストをより安全かつ高速に実行することが可能です。
データベースアクセス
ウェブアプリケーションのテストを行う際、データベースへのアクセスが求められる場合があります。しかし、テストの最適化のためにも、アクセスの仕方や必要性を考慮することが重要です。
すべてのテストにデータベースは必要なのか?
全てのテストでデータベースへのアクセスが必要とは限りません。例えば、ユーザーの年齢を計算する際に、実際のデータベースから生年月日を取得する必要はありません。計算関数にダミーの生年月日を渡して、テストをデータベースから独立させる方法を考えることができます。
すべてのテストでデータベース全体が必要なのか?
データベースのダミーデータをセットアップするのは時間がかかることがあります。しかし、全てのテストで完全なダミーデータセットが必要でしょうか?例えば、ユーザープロフィール機能のテストを行う場合、データベース内の無関係なテーブルを用意する必要は少ないかもしれません。
データベースの準備は一度きりで良い
各テスト実行ごとにダミーデータベースをセットアップし、その後破棄するのは非効率的です。もっと効率的なアプローチを採用することが望ましいです。
Truncate
テスト実行後にデータベースを完全に破壊する代わりに、テーブルをTRUNCATE
する方法を考えると良いでしょう。これにより、データベースに依存する各テストごとにデータベースを再作成する必要がなくなります。
- テスト用のデータベースを一度だけ作成
- 必要なダミーデータを投入
- テスト実行
TRUNCATE
を使用してテーブルのデータを迅速に削除- 次のテストデータを投入し、テストを実行、これを繰り返す
ロールバック
データの投入には時間がかかります。それを最小限にするためにデータの保存やキャッシュを考慮するのはどうでしょうか?全てのテーブルをTRUNCATE
する代わりに、テストのトランザクションをロールバックする方法があります。その結果、次のテストデータの準備なしに即座にテストを実行できるようになります。
- テスト用のデータベースを一度だけ作成
- 必要なダミーデータを一度だけ投入
- テストを実行するが、この際、データベースへのコミットを避ける
- トランザクションをロールバック
この手法を採用する場合、テストコードを書く際の注意が必要です。具体的には、テストはデータベースにコミットしてはいけません。コミットが行われた場合、その変更を手動で戻す処理が必要となります。
並列処理
pytestはデフォルトで単一のCPUコアのみを使用します。しかし、多くのノートパソコンやCIランナーは複数のコアを持っています。これらの全てのコアを活用しないことは、実行時間の最適化の観点から見れば非効率的です。
pytest-xdist
複数のコアを有効に使用するための最も人気のあるツールはpytest-xdist
というプラグインです。このプラグインは、リモートマシン上でさえも、複数のコアやCPUを使用したテストの並行実行をサポートしています。
しかし、実際のプロジェクトや複雑なフィクスチャ、データベースが関与する場合、すぐに動作するわけではありません。主な問題点は、session
スコープのフィクスチャが各ワーカーにおいて繰り返し実行されることにあります。これを回避する方法も存在しますが、必ずしも容易ではありません。
例として、各pytest-xdist
ワーカープロセスのために独立したデータベースを作成し、そのワーカーのIDをデータベース名の接尾辞として使用する方法が考えられます。実際に、pytest-django
はこの手法をデフォルトで採用しています。
pytest-split
pytest-xdist
とは異なり、pytest-split
は使いやすさを特徴としています。ローカルの開発環境での使用には必ずしも適していないかもしれませんが、テストコードに多くの修正を加えることなく、CIの実行時間を大幅に短縮することが可能です。
pytest-split
の仕組みは、テストの実行時間に基づいてテストスイートを均等に分割することにあります。そして、これらのサブスイートは、利用可能なCIワーカーを最大限に使用して並行に実行されます。
注意点:
- 100%のテストカバレッジを達成するためには、全てのテストが完了した後で、追加のCIランナーを使用してカバレッジレポートを集計およびマージする必要があります。
pytest-randomly
のシードはあらかじめ決定し、すべてのCIランナーに渡す必要があります。
追加ヒント
テストを高速な状態に保つ
テストスイートを高速にする努力をしても、数ヶ月後にコードベースを見返した際、再び遅くなっていることは避けたいものです。そうした問題を防ぐために、BlueRacer.ioを試してみることをおすすめします。これは、テストが遅くなるとPull RequestのマージをブロックするGitHubアプリケーションです:https://github.com/apps/blueracer-io
pytest --lf
pytest --lf
もしくは pytest --last-failed
は、前回pytestの実行で失敗したテストのみを再実行するオプションです。ローカル開発の際、迅速なフィードバックを得るために有用です。
便利なpytestプラグイン
https://niteo.co/blog/indispensable-pytest-plugins でおすすめのpytestプラグインが紹介されています。ご参照ください。
config.scan()
Pyramidの config.scan()
を使用している場合、大規模なコードベースにおいてパフォーマンスのボトルネックになる可能性があります。テストのディレクトリを無視するように設定をすることで、高速化することができます。
config.scan("myapp", ignore=re.compile("tests?$").search)