Ruby on Railsの並列テスト徹底解説:高速化のメリットと落とし穴、リスクへの対処戦略
「テストが速すぎて困る」という不満を耳にしたことがあるでしょうか?おそらく一度もないはずです。
テストが速いということは、フィードバックも速いということです。ローカル環境でもCIパイプラインでも、テストが早く完了するほど、失敗に素早く気づき、コードを迅速に改善できます。生産性の向上に加えて、遅いテストが開発者を不機嫌にさせることもよく知られています。不機嫌な開発者を好む人はいませんよね。
とはいえ、稲妻のように速いテストスイートを作るのは、期待ほど簡単ではありません。幸いなことに、Rails 6では並列テスト(parallel testing)という強力な機能が標準搭載されました。導入は非常に簡単で、テスト実行時間を大幅に短縮できます。ただし、いくつか注意すべき落とし穴も存在します。
並列テストとは何か?
そもそも「並列テスト」とは何を意味するのでしょうか?
通常、テストスイートを実行すると、テストランナーは単一のプロセスを起動し、テストを1つずつ順番に、つまり直列(シリアル)に実行します。1つのテストプロセスが使用できるCPUコアは1つだけです。ご想像のとおり、この方式では、数十個ものCPUコアを搭載する現代のハードウェアの性能を十分に活かせません。
10コアの高性能なMacBookを持っていても、残念ながらそれだけでテストが速くなることはありません!
これを変えるのが、個々のテストを複数のワーカープロセスへ振り分ける方法です。テストは次々と実行されるのではなく、並行して実行されるようになります。2つのワーカーでテストスイートを実行すれば、1つのワーカーの場合の約2倍速くなります。
マシンのコア数が多ければ多いほど、起動できるワーカープロセスも増え、テストスイートはより速く完了します。たとえば、通常8分かかるテストを4つのワーカープロセスで実行すれば、実行時間は約2分に短縮できます!
さらに、16コアのマシンで16個のワーカーを起動することを想像してみてください。実に素晴らしいことです。
Railsで並列テストを設定する
では、どうやって実現するのでしょうか?以前はサードパーティのgemを使う必要がありましたが、Rails 6からは並列テストが標準機能になりました。テストにparallelizeを追加するだけです。
この設定により、Railsはマシンのプロセッサ数に基づいて自動的にワーカープロセスを起動します。また、テスト実行用に名前空間付きのデータベース(database-test-0、database-test-1など)も自動的に作成されます。
始めるのに必要なのはこれだけです!もちろん、必要に応じて追加の設定オプションも用意されています。
並列テストのために特別なセットアップやクリーンアップが必要になることもあります。そのために、Railsはparallelize_setupとparallelize_teardownという2つのフックを提供しています。これらは新しいワーカープロセスが起動する前後に呼び出されます。
ワーカー数を手動で指定することもできます。
また、PARALLEL_WORKERS環境変数を使えば、既存の設定を上書きできます。
ワーカーの代わりにスレッドを使って並列化するオプションもあります。
with: :threadsは、JRubyやTruffleRubyを使用する場合のデフォルトです。理論上、スレッドはプロセスよりもオーバーヘッドが少なく、わずかに高いパフォーマンスが期待できます。しかし実際には、スレッドが特に役立つと感じたことはほとんどなく、多くの場合はプロセスベースの並列化で十分でしょう。
落とし穴に注意
既存のテストにparallelizeを追加するだけで、驚異的な高速化が得られる――本当にそんなに簡単なのでしょうか?
運が良ければ、その通りかもしれません。しかし、既存のテストスイートに並列化を導入した際に、予期しない問題にぶつかる可能性の方が高いでしょう。筆者自身もまさにそうでした!
まず一つ確認しておきましょう。MinitestではなくRSpecを使用している場合、残念ながらRails 6の組み込み並列テストは利用できません。RSpecはこの機能をサポートしておらず、状況を変えようという議論は進行中ですが、しばらく大きな進展はありません。RSpecで並列テストを行いたい場合は、grosser/parallel_testsのようなサードパーティgemを使うのが最善策です。
もう一つの予期しない問題は、少数のテストを並列で実行すると、直列実行よりも遅くなる場合があるということです。並列テストのセットアップには、複数のデータベース作成など大きなオーバーヘッドが伴い、並列化によるメリットを打ち消してしまうことがあります。
少数のテストしか実行しない場合は、並列テストを無効にした方が良いかもしれません。PARALLEL_WORKERS環境変数で無効化できます。
Rails 7では、多数のテストを実行する場合にのみ並列実行が有効になることで、この問題に対処しました。すでにアップグレード済みであれば、この問題は発生しません。デフォルトのしきい値は50ですが、変更することも可能です。
最後に取り上げる問題は、最も遭遇しやすく、同時に最も厄介で対処が難しいものです。並列テストを有効にすると、ランダムなテスト失敗(flaky test)が発生し始めることがあります。並列化がなぜこのような問題を引き起こすのか、シンプルなテストケースで見てみましょう。
あまり実用的ではないにせよ、このテスト自体は完全に正常です。直列に実行される限り、100%成功します。各テストは独立しており、ランダムな順序で実行しても失敗しません。ところが、複数のプロセスやスレッドが絡んでくると話が変わります。
テストを並列実行すると、CPUスケジューリングの影響で、テスト内の個々の処理の実行順序が入れ替わることがあります。先ほどの例では、次のような実行順序が発生することがあります。
Worker 2がWorker 1のアサーション実行前にファイルを削除してしまうため、最初のテストは――ときどき――失敗します。さらに厄介なことに、実行順序によっては今度は2番目のテストが失敗し、1番目が成功することもあります。
このシンプルな例が示す問題は、ファイルだけでなく、テストがスレッドセーフでない方法でアクセスするあらゆるシングルトンリソースに及びます。RedisデータベースやElasticsearchインデックスに書き込む場合も、同様の問題に直面する可能性が高いでしょう。さらに悪いことに、ランダムな失敗を引き起こすすべてのテストを特定するのに時間がかかり、修正にもさらに時間がかかります。
不安定な並列テストに対する銀の弾丸は存在しません。基本的には、複数のテストプロセスがリソースを共有しないようにする必要があります。ファイルにはTempfileを使う、parallelize_setupで名前空間付きのリソース(Redisデータベースなど)を作成する、といった具合です。
既存のRailsテストに並列テストを段階的に導入する
並列化によるランダムなテスト失敗に悩まされていて、すぐには修正する時間がない――それでも並列テストの恩恵は受けたい。そんな場合は、一部のテストだけに限定して有効にするのが良いでしょう。
parallelizeを呼び出しているテストだけが並列化されるため、concernや親クラスを利用すれば、テストクラスを1つずつ並列テストへ移行できます。たとえば、次のようなモジュールを作れます。
このモジュールをincludeしたテストクラスは、並列で実行されるようになります。
あるいは、ParallelTestのような新しいテスト基底クラスを作成する方法もあります。
そして、並列実行しても問題ないテストだけがそのクラスを継承するようにすれば、問題のあるテストは除外できます。
並列テストは「絆創膏」にすぎない
並列テストは、わずかな手間で印象的な速度向上をもたらします。ただし、勘違いしないでください。並列テストはテストスイートを高速化する他のアプローチの代替にはならず、あくまで補完的な手段です。
テストスイートが遅いと感じたら、時間を割けるのであれば、プロファイリングを行って遅さの根本原因を突き止め、対処しましょう。遅いテストスイートに並列テストを追加すれば速くはなりますが、もともと速いテストスイートを並列実行する場合ほど速くはなりません!
まとめ
この記事では、並列テストとは何か、そのセットアップ方法と設定方法について見てきました。テストを高速化したいなら、並列テストはまさにうってつけの機能です。
テストスイートに並列テストを導入する際には、いくつかの障害に直面するかもしれません。何年も安定して動いていたテストが突然失敗し始めても驚かないでください。テストスイートの一部だけを並列化することで、こうした問題を回避できます。
どのアプローチを選んでも、並列テストはテストを高速化する素晴らしいツールです!
それでは、Happy coding!
P.S. Ruby Magicの記事を公開と同時にお読みになりたい方は、Ruby Magicニュースレターを購読すれば、記事を見逃すことはありません!
Hans-Jörg Schnedlitz(ハンス=ヨルグ・シュネトリッツ)
ゲスト執筆者のHansは、オーストリア・ウィーン在住のRailsエンジニアです。コーディングやコーディング関連の書籍・記事を読むことに大半の時間を費やし、自身のブログで執筆することもあります。画面の前に座っていないときは、山登りをしていることでしょう。
-
GoによるWeb開発入門:ミドルウェア、テンプレート、JSON、データベースの基礎
このシリーズの前回の記事では、Goのnet/httpパッケージと、それを本番環境向けのWebアプリケーションに活用する方法について詳しく解説しました。主にルーティングの仕組みと、http.ServeMux型のさまざまな特徴や機能に焦点を当てました。 本記事ではServeMuxに関する議論を締めくくるべく、標準のルーターでミドルウェア関数を実装する方法をデモするとともに、GoでWebサービスを開発する際に必ず役立つその他の標準ライブラリパッケージを紹介します。 Goにおけるミドルウェア 多くの(あるいはすべての)HTTPリクエストに対して共通の処理を実行できるよう機能を設定する手法は、ミド
-
インフラの可視性を高める:StatsDとAppSignalスタンドアローンエージェントであらゆるシステムを監視する方法
アプリケーション本体だけを監視していても、システム全体の状況を把握できるとは限りません。サテライトアプリ(補助的なアプリケーション)で動作するサービスが、日々の運用に大きな影響を与えることは珍しくありません。データベースサーバーはその代表例です。また、バックアップスクリプトやその他のバックグラウンドジョブも、システムを遅延させる原因になりながら、見落とされがちです。 Node.js向けAppSignal APM、Ruby APM、Elixir APMは、アプリケーション自体を自動的に計測(instrument)します。しかし、デフォルトではAppSignalはこれらのサテライトプロセスを監視し