RampのPythonモノリスのテストスイートは、チームが5,000件を超えるテスト(主に機能/統合スタイル)へと拡大するにつれて、20分に近づきつつあった。スイートを書き直す代わりに、インフラチームは実行時間そのものに取り組んだ。並列ワーカーを4つに制限していたデータベースセットアップのボトルネックを修正してCIの並行性を高め、ローカル実行間でテストデータベースを毎回破棄してゼロから再構築するのをやめ、特定のコード変更に関連するテストだけを実行するインクリメンタルテストランナーを構築した。

インクリメンタルランナーの最初のバージョンは、coverage.pyを使って各テストがどのコード行を実行したかをマッピングし、新しいコミットで変更ファイルをそのマップと差分比較していた。これは機能したが、特定のギャップがあった。RampのコードベースはFlask-SQLAlchemyのモデルクラスを使用しており、カバレッジ測定は実行のほとんどをモデルクラスの宣言自体ではなくSQLAlchemyの内部に帰属させる。モデルにカラムを追加しても、純粋な行カバレッジでは「このクラスが変更された」とは記録されないため、インクリメンタルランナーはスキーマ変更のバグを捕捉すべきテストをスキップする可能性があった。

これを修正するため、チームはcoverage.pyをPython組み込みのsys.settraceに置き換え、`call`イベントの`self`または`cls`引数を検査し、そのオブジェクトをそのクラスに解決し、`__bases__`を幅優先で辿ってすべての祖先クラスをそれが定義されたファイルにマッピングした。これによりスナップショットは、テストが実行した行だけでなく、その完全な継承チェーン内のどのクラスに依存しているかも記録できるようになった。そのため、基底クラスの宣言への変更は、サブクラスのコードパスが直接実行されていなくても、すべてのサブクラスのテストを正しく無効化した。

並行性の修正とデータベースの完全再構築のスキップと組み合わせることで、インクリメンタルランナーは典型的なコミットでスイートの30〜40%をカバーし、フル実行が捕捉する障害のおよそ90%を捕捉し、エンジニアに完全な10〜20分ではなく数分で実用的なシグナルを提供した。

実行時にクラスの継承を追跡し、実行されていない基底クラスの編集でもそのテストがトリガーされるようにする

coverage.pyは実行された行を記録するが、それらが属するクラスは記録しないため、SQLAlchemyのORM内部での実行はユーザーのモデルクラスに帰属されない。

sys.settraceはコールフレームを直接公開するため、`self`/`cls`を検査することで、行レベルの実行がどこで起きたかとは独立に「どのクラスか」を復元できる。

そのクラスから`__bases__`を辿ることで、1つのコールイベントが完全な祖先リストになり、基底クラスの変更がチェーン内のすべてのファイルに帰属される。

インクリメンタルランナーをフルスイートの代わりではなく並行して実行したことで、約10%の検出ギャップが本番リスクになることはなかった。

カバレッジベースの依存関係ショートカットは、カバレッジツールが変更されたコードに帰属させない継承のような実際の依存関係を見逃す可能性がある。高速パスを信頼する前に、その盲点を確認すること。

Rampはこの時点以降、インクリメンタルランナーにさらなる投資をする価値はないと判断した。残りの約10%の検出ギャップを埋めるにはBazelのようなものを中心とした再構築が必要であり、当時それは最優先ではなかった。並行性の修正(冗長なワーカーごとのデータベースリセットを削除した後にマシンサイズを倍増)だけでCI時間が半減し、ローカルでのデータベース完全再構築のスキップは実行ごとに10〜30秒を削減した。チームはsys.settraceベースの継承トラバーサルを、Rampの並列化サポートを欠いていたpytest-testmonのようなオープンソースの選択肢の代替として公開した。

出典をたどる

出典

  1. Faster Python Tests Ramp Engineering (Builders Blog) · builders.ramp.com ·