久しぶりにCIの料金管理画面を見に行ったとき、腰が抜けました。
料金が前の2倍くらいになっている……!
しかも、PRを出してからテストが終わるまでの待ち時間も長い。数分で終わっていたはずのテスト部分だけで、気づけば10分近くかかるようになっていました。
僕はAIを活用しながら、FastAPIでバックエンドを開発しています。機能を作るペースが上がるにつれ、既存の動作を壊していないか確認するためにテストもどんどん増やしていました。
ただ、テストを増やすのに夢中で、「テストを動かすための裏側の処理」 はあまり見直していませんでした。ここが完全に盲点でした。開発スピードが上がったのに、CI待ちで手が止まるのも、このまま課金してCPUを増やし続けるのもつらいところです。
そこでテストの処理を見直してみたところ、vCPU数を増やさず、テストの検証内容も変えずに、約9分半かかっていたテストを約3分半まで短縮できました。
何を見直してどう速くなったのか、そのとき対応したことをまとめます。
一度は約3分になったのに、また10分に逆戻り
最初は「とりあえずCPUのコア数を増やせば速くなるでしょ」とインフラ側で対応していました。
テストにはpytestを使っています。以前遅くなったときは、pytest-xdistでworkerを増やしたり、CIのランナーをBlacksmithに移行したりすることで、一度は約10分から約3分まで短縮できていました。
ところが、開発が進んで機能とテストが増えると、また5分、10分と延びていき、気づけば元の木阿弥に。しかもCIの料金まで跳ね上がっています。
workerやrunnerを増やすアプローチは確かに効きますが、遅くなるたびにスペックを上げていたらお金が持ちません。そこで今回は、インフラ環境はそのままに、テスト側の無駄を削れないか調べ始めました。
とりあえず、何がそんなに遅いんだ?
まずはボトルネックの特定です。pytestの--durationsオプションや、JUnit形式で出力したテスト結果を使って、テスト本体だけでなく前後のセットアップ・後片付けに時間がかかっていないか確認しました。
pytest --durations=20
pytestの公式ドキュメントにもある通り、これで実行時間の長いテストや処理を洗い出せます。
調べてみると、テストの判定処理そのもの以外に、テストごとに何度も繰り返されている重い処理が見つかりました。具体的には以下の2つです。
- APIテストごとのFastAPIアプリの生成
- リクエストの状態をリセットするSQLの実行
1回あたりは小さくても、テスト数が増えるとセットアップや片付けの積み重ねがバカにならなくなっていました。
改善1:テスト用アプリの生成を使い回す
今回いちばん効果があったのが、FastAPIのアプリオブジェクトの再利用です。
アプリオブジェクトを生成する際は、ルーティングの登録やミドルウェアの設定、OpenAPIスキーマの準備などが走ります。以前はこれをテストごとに毎回ゼロから作っていました。
【改善前】
正常なリクエストのテスト
→ アプリを生成する
→ リクエストを送って、結果を確認する
不正なリクエストのテスト
→ またアプリを生成する
→ リクエストを送って、結果を確認する
リクエストに応じた結果を確認したいだけなのに、毎回受付を丸ごと作り直していたような状態です。
そこで、同じ設定で動かせるテストでは、workerごとに用意した共通のアプリを使い回すように変更しました(pytest-xdistのworkerは別プロセスで動くため、公式ドキュメントにある通りworker単位でインスタンスを持つ形になります)。
【改善後】
worker内で、共通のアプリを用意する
→ 正常なリクエストのテストを実行する
→ テストが変更した状態を元に戻す
→ 同じアプリで、不正なリクエストのテストを実行する
→ テストが変更した状態を元に戻す
ここで気をつけたのが、「アプリは使い回しても、前のテストの状態は引き継がない」 ことです。速くなった代わりにテストの実行順で結果が変わってしまったら大問題です。
そのため、以下のルールを徹底しました。
- テストで差し替えた依存関係(Dependency Overrides)、アプリの状態、OpenAPI仕様はテスト終了時に元に戻す
- ミドルウェアの設定を戻し、実体(インスタンス)は次のリクエスト時に作り直す
- HTTPクライアントは引き続きテストごとに生成し、DBの分離も維持する
テストが途中で失敗した場合でも、変更が次のテストに残らないことを確認して導入しました。
設定変更時の「作り直し」も見直す
アプリを共通化したあとも、設定値を変えるテストでは毎回アプリを作り直していました。ここもまだ削れる余地がありました。
設定には「アプリ起動時に取り込むもの(CORSやミドルウェア構成など)」と、「リクエスト処理のたびに読み直すもの」の2種類があります。後者の設定を変えるテストであれば、わざわざアプリ全体を作り直す必要はありません。
リクエストごとに参照されることが確実な設定については、変更時にアプリを再生成しないように見直しました。設定変更のテスト群をローカルの1プロセスで計測したところ、アプリ生成の回数は32回から1回に減りました。
OpenAPIの検証テストでもアプリを共有する
同様に、OpenAPIの仕様を検証するテスト(「必要な項目があるか」「レスポンス定義が合っているか」など)でも、仕様を読むだけなのに毎回新しいアプリを作っていました。
ここも、参照用のアプリを共有するように変更しました。ただし、「別々に作ったアプリから同じAPI仕様が出力されるか」を検証するテストだけは、テストの意図を壊さないよう2回生成するままにしています。
これにより、OpenAPIテスト群(ローカル1プロセス)でのアプリ生成は33回から3回(共有用1回+比較用2回) に減りました。
改善2:DB設定リセットのSQLを1回にまとめる
次に見直したのが、前のリクエストの状態を残さないための「DB接続上の設定リセット処理」です。
テストごとに変更した設定が次のテストに影響しないよう、片付け処理として設定を戻しているのですが、以前はここでSQLを7回に分けて実行していました。
【改善前:設定を1つずつ戻す】
アプリ → DB:設定Aを戻す → 完了を待つ
アプリ → DB:設定Bを戻す → 完了を待つ
……(設定Gまで繰り返す:計7往復)
【改善後:まとめて戻す】
アプリ → DB:設定A〜Gをまとめて戻す → 完了を待つ(計1往復)
同じDBに命令を送るなら、7回に分けて往復するのではなく1回でまとめて伝えれば済みます。リセットする7項目やトランザクションの扱いは変えずに、SQLの実行を1回にまとめました。
アプリの再利用とSQLのまとめ処理を適用し、代表的なAPIテスト群(設定リセットが41回発生するテスト群)をローカルの4workerで動かした結果がこちらです。
| 計測した処理 | 変更前 | 改善後 |
|---|---|---|
| アプリ生成(初回読込を除く) | 32回 | 4回 |
| 設定リセット用SQLの実行 | 287回(41回×7) | 41回(41回×1) |
アプリの使い回しほどの劇的な時間短縮ではないものの、テストのたびに発生していた無駄な通信の往復をスッキリ減らすことができました。
改善3:並列ジョブの振り分け調整
CIで並列実行しているテストジョブの振り分けも、実際の実行時間を見ながら調整しました。
テストを複数のジョブに分けて走らせる場合、片方が早く終わっても、もう片方が終わるまで全体の待ち時間は減りません。件数ベースで均等に分けても、テストごとの重さが違うため偏りが出ます。
最初は重いテストを別ジョブにゴッソリ移してみたのですが、今度は移した先のジョブの方が遅くなってしまいました。そのため、移す量を微調整しながら計測し、両方のジョブが完了するまでのトータル時間が一番短くなるバランスに落ち着けました。
結果:約9分半から約3分半に短縮!
ここまでの改善をすべて反映した、テスト実行時間の比較がこちらです(環境のセットアップ等を除く、テスト実行部分の時間です)。
| 比較 | テスト実行時間 |
|---|---|
| 改善前 | 約9分半 |
| 改善後(3回平均) | 約3分半 |
vCPU数を増やさず、テストの検証内容も一切削らずに、実行時間を約6割短縮できました! 同じスペックのまま実行時間を大幅に減らせたため、待ち時間のストレスだけでなく、膨らんでいたCIコストの抑制にも直結しています。
参考になった記事と次に試したいこと
今回の改善にあたり、Trail of Bitsのブログ記事「Making PyPI's test suite 81% faster」がとても参考になりました。並列化だけでなく、DBの分離やカバレッジ計測、テスト収集の時間まで踏み込んで分析されており、「そこも疑えばいいのか」と視点が広がりました。
その中でも次に試してみたいと思っているのが、Python 3.12以降で使えるsys.monitoringを利用したカバレッジ計測です。上記記事では、これを導入したことでテスト時間が58秒から27秒に短縮された事例が紹介されています。
coverage.pyの公式ドキュメントによると、Python 3.12・3.13時点では分岐カバレッジ(branch coverage)にはまだ非対応とのことなので、現在の計測要件と照らし合わせながら導入を検討してみたいところです。
まとめ:CPUを増やす前に、まず中身を測る
AIと一緒に機能を作るのは楽しくて、動くコードもテストもどんどん増えていきます。ただ、そのテストを支える裏側の処理までは、開発と同じペースで見直せていませんでした。
今回プロファイリングをしてみると、アプリの生成やリセット用のSQLなど、テストの本質ではない「準備と片付け」に多くの時間を使っていました。またCIが遅くなってきたら、次は課金してCPUを増やす前に、まず「何に時間を使っているのか」を測るところから始めようと思います。料金管理画面で、もう一度腰を抜かす前に……。