pytestを並列実行してCIを倍速にした話

こんにちは。クロスマートで請求書を始めとした帳票サービスの開発を行っているDev2 テックリードのたけじい(@pouhiroshi)です。

先月はWebサービス開発に導入して良かった ツール・ライブラリ6選(2023年度版)と言う記事を寄稿させていただきました。まだご覧になってない方はぜひそちらも読んでみてください。

さて、今回はpytestを並列実行してCIを倍速にしてきましたので動機ややり方などを紹介させていただきます。 先に結果をお伝えしておくと、10分かかっていたテスト実行を半分の5分程度に短縮することができました

それでは、詳細は以下をご覧ください。

開発作業を進める上で、CI(Continuous Integration)のテスト実行は欠かせない仕組みです。テスト実行の時間は、開発の効率やリリース速度に大きな影響を与えます。特に大規模なプロジェクトになりますと、テスト実行時間が長引くことで開発のスピードが遅くなってしまうことも珍しくありません。

弊社のCI環境では、何かプログラムを修正しgitにpushするたびにCircleCIによるテストやlintチェック、mypyチェックなどが実行されます。

実際のCIの様子

こまめに間違っているところがないか、失敗するテストがないかなどを確認できて品質向上にとても寄与している素敵な仕組みです。

しかしながら、プロダクトが進化し機能が増えると当然テストも増えていき、徐々にテストが完了するまでの待ち時間が増えてきます。これらのテストやチェックが完了した後に、staging環境(機能を本番にリリースする前にテストや機能確認するための環境)や本番環境へリリースをするわけですが、上記のように10分ほど待つ必要があり、開発体験を損ねているなという課題感が出てきていました。 品質向上のために開発者の皆さんが作っているテストが、逆にリリースサイクルの妨げになってしまうのは本末転倒ですね。 10分も待ち時間があったら、コーヒーが2杯(え)くらい飲めてしまいます。集中力も途切れてしまうかもしれません。

私はこの問題を「テストを並列実行することでトータル時間を短縮できないか」と考えました。pytestの並列実行を行う方法を調べてみるとpytest-xdistとpytest-parallelという2つのプラグインがあることがわかりました。

ChatGPT

pytest-xdistとpytest-parallelは、Pythonのテストフレームワークであるpytestを
使用してテストを並列に実行するためのプラグインです。両者は似たような目的を
持っていますが、実装の詳細や提供する機能に違いがあります。

pytest-xdist
pytest-xdistは、複数のCPUコアを利用してテストを並列に実行することができます。
分散テストの実行をサポートしており、異なるマシン上でテストを実行することが可能です。
-nオプションを使用して、並列に実行するプロセス数を指定できます。
ロードバランシング機能により、各プロセスにテストが均等に分配されるように努めます。
より成熟しており、広く使われているプラグインです。

pytest-parallel
pytest-parallelもまたテストを並列に実行することを目的としたプラグインですが、
よりシンプルなアプローチを提供します。
並列実行のためにスレッドとプロセスを使用することができます。
--workers オプションでプロセス数、--tests-per-workerオプションで各プロセスで
同時に実行されるテストの数を指定できます。
pytest-xdistに比べると新しく、機能は少なめですが、必要な基本的な並列実行機能
を提供します。
両プラグインは似た機能を持っていますが、pytest-xdistはより複雑な分散テストの
シナリオや詳細な設定を必要とする場合に適している可能性が高く、pytest-parallel
はよりシンプルで直感的な使い方を好む場合に適しています。
プロジェクトの要件や好みに応じて選択すると良いでしょう。

ChatGPTに聞くと、pytest-xdistの方がより成熟しており広く使われているプラグインです、とのことなので、今回はpytest-xdistを使うことにしました。

テストを並列実行するにあたって、現在のテストにおいてクリアしなければならない大きな問題がありました。テストに利用しているデータベースはただ1つであるため、並列に動かしている他のテストの結果に影響が出てしまうという問題です。 ※以前は以下のようにデータベースの指定は固定でtest_invoiceになっていました

database_url: str = "mysql://{user}:{password}@{host}:{port}/{database}?charset=utf8".format(
            **{
                "user": settings.MYSQL_USER,
                "password": settings.MYSQL_PASSWORD,
                "host": settings.MYSQL_HOST,
                "port": 3306,
                "database": "test_invoice",
            }
        )
engine = create_engine(database_url)

この問題を解決するために、pytest-xdistが提供する並列実行するワーカーIDを利用することにしました。具体的には、ワーカーIDをテストデータベースの名前に含めるようにしました。

※以下のように@pytest.fixtureを使ってデータベースの接続を定義する際、引数にworker_idを入れることで、並列実行するワーカーの名前が取れるようになります。(gw0, gw1,…と言ったように並列実行数を増やすと数字部分が増えていきます。)

@pytest.fixture(autouse=True)
def _setup(self, worker_id):
    # worker_idには並列実行の際はgw0,gw1,...と言うワーカー名が入る。並列実行しない場合はmasterが入る
    database_url: str = "mysql://{user}:{password}@{host}:{port}/{database}?charset=utf8".format(
        **{
            "user": settings.MYSQL_USER,
            "password": settings.MYSQL_PASSWORD,
            "host": settings.MYSQL_HOST,
            "port": 3306,
            "database": f"test_invoice_{worker_id}",
        }
    )
    engine = create_engine(database_url)

このように、ワーカーそれぞれでテストに使うデータベースを分けることで、他のワーカーにデータベース操作の影響を及ぼさないようにしました。(ちなみに、並列実行しない場合はworker_idには”master”と言う文字列が入るようです。)これで並列実行についてのハードルはクリアになりました。

最適な並列実行数を求めて

テスト実行時間の短縮を目指して、並列実行の数(n)がどれくらいが最適かをローカル開発環境で実験していきました。テストの数は全体で367件ありました。

実験の様子

n=1(並列実行しない場合) 5分35秒 ここから早くなっていくか・・・?

ちなみにローカル開発環境では私はPyCharmProを使っており、以下のように他の引数 の部分で-n 並列実行数で指定します。

1つ注意点があり、並列実行時(-n 指定時)はブレークポイントを入れたデバッグが効かないようです。そのため、普段は通常の実行にしてデバッグを行い、まとめて実行したい場合のみ-nを指定すると良さそうです。

ここからは、nを変えていって最適な並列実行数を探るフェーズです。

n=4での結果は、、、 2分59秒!! n=1の5分35秒からだいぶ早くなりました!

n=2での結果は、、、3分19秒。 n=1より早いですがn=4よりは遅い。

調子に乗ってn=6とか行ってみます。2分38秒!! MacのマルチコアCPUは強いですねw

n=12では2分48秒でちょっと遅くなりました。 私のMac(Apple M2 Pro)だとこの辺が限界のようです。

思い切ってn=36とかにすると、テストが始まるまでの時間がかかってしまいました。実行自体もあまり早くないようです。やり過ぎは禁物ですね。

実際のCIにはCircleCIを利用しているのですが、こちらはMacよりスペックが劣る環境となっています。

こちらも色々と並列実行数をを試した結果、n=4がちょうど良さそうなことがわかりました。

Before

以前はlint_and_testに10分とかかかっていました。

After 並列実行数n=4*1で、テストは5分にまで短縮することができました!(ほぼ倍速!!

この並列実行はCircleCIでの実行環境の性能を上げることで、さらなる高速化が期待できますが、お値段も上がると思います。この辺はコストパフォーマンスを意識してやると良いと思います。

今回のpytest-xdistはマルチコアCPUに実行を振り分けて並列実行を実現するものでしたが、もう1つのプラグイン「pytest-parallel」はCPUではなく、スレッドやプロセスに分けて並列実行することが可能で、さらに性能向上が期待できるかもしれません。またテストが増えてきて時間がストレスになってきたら導入を検討しても良いと考えています。

以上、私がpytestを並列化して倍速にした話の紹介でした。この記事が皆さんの参考になれば幸いです。 たかが5分?!と思われるかもしれませんが、開発に関わる全てのメンバーの毎コミットのたびの5分とお考えください。 相当の時間節約になることに違いありません。 テストの並列実行は、テスト時間の短縮を通じて開発全体の効率化にも寄与します。ぜひ、皆さんのプロジェクトにも適用してみてください。

クロスマートではエンジニアやBizDevなど広く一緒に働く仲間を募集中です。 積極的に開発者の開発体験を改善していきたい!!という改善意欲に満ち溢れたエンジニアさんにはとてもお薦めな会社です。 ぜひ一緒に外食産業のためになるサービスを楽しく開発しましょう!!

xorder.notion.site

*1:後日談 インフラチームの方から、このn=4に関してコメントいただき、テストはvCPU=4で実行しているのでその通りの結果ですね。ということを教えていただきました。

Configuration reference - CircleCI

CircleCIのlargeを使っている