🐫

parallel_tests で RSpec 実行時にやっておくとうれしい設定など

に公開

この記事は parallel_tests を使ったテストの中で失敗が生じた場合に、
その原因を再現・調査するために役に立ついくつかの設定を記載したものです。

parallel_test

CI などで高速化を狙って並列で rspec を実行することがあります。
そのようなときに使う gem として parallel_test というものがあります。

https://github.com/grosser/parallel_tests

マルチコア CPU を効率よく使ってテストするのにとても便利です。
セットアップ手順などは README で十分なのですが、並列実行ゆえに flaky test などが生じたときの原因確認方法が難しくなります。

そこで、ここでは parallel_tests を使った CI で生じた flaky test を退治するうえでの TIPS をいくつか紹介します。

TIPS

全プロセスで seed を固定する

rspec 実行時に seed をうまく使うと、同じテスト順序で失敗を再現しやすいので有用です。
並列で実行するとき、個々のプロセスで rspec が実行されることになるのですがそれらプロセスは別々の seed 値でテストを実行します。
ただデバッグするためにはそれぞれの seed 値が固定されていると効率が良かったりするのでこれを設定します。

といっても、毎回同じ seed 値で実行するというわけではありません。
あらかじめ作った乱数を、明示的に全プロセスに渡るようにするというものです。

rspec で --seed オプションを渡さなかった場合、以下の場所で seed 値が決定されます。

https://github.com/rspec/rspec-core/blob/v3.13.2/lib/rspec/core/ordering.rb#L147-L152

rand(0xFFFF) で算出されていますね。
これと同じ計算方法で、 --seed にコマンドラインオプションを渡すことで全プロセスで同じ seed 値を適用できます。

まず ruby のワンライナーで試してみます。

$ ruby -e "puts rand(0xffff)"
22357

この出力を、テストを実行する際のコマンドラインに埋め込んでみます。
以下のような要領です。

$ bundle exec parallel_rspec -- --seed $(ruby -e "puts rand(0xFFFF)") -- spec/

なお、別記事で --seed を使った rspec での flaky test 退治方法をポストしているのでよければ御覧ください。

https://zenn.dev/hamajyotan/articles/0f2c0530088698

プロセスごとにどのファイルを引き受けたか知りたい

parallel_rspec を実行することで、テスト対象のファイル群がプロセスごとに分担されるわけですが、
通常、どのプロセスがどのテストファイルを引き受けたかは出力を見てもわかりません。
ここでは、それを把握可能な状態にしてみます。

そのためには rspec の before(:suite) hook を使います。

spec/rails_helper.rb
RSpec.configure do |config|
  config.before(:suite) do
    files = config.files_to_run
    normalized = files.map { Pathname.new(File.absolute_path(it)).relative_path_from(Rails.root) }
    banner = "PID (#{Process.pid}) #{normalized.count} files to run:"
    puts [banner, *normalized].join("\n\t")

    # ...
  end

  # ...
end

https://github.com/rspec/rspec-core/blob/v3.13.2/lib/rspec/core/configuration.rb#L1094-L1098

config.files_to_run は、実行対象ファイルを意味する配列になっているのでこれを出力すれば OK です。
それぞれのプロセスが、以下のように出力してくれます。

PID (1075695) 5 files to run:
    spec/controllers/bars_controller_spec.rb
    spec/controllers/foos_controller_spec.rb
    spec/models/bar_spec.rb
    spec/models/baz_spec.rb
    spec/models/foo_spec.rb
PID (1075696) 4 files to run:
    spec/controllers/bazs_controller_spec.rb
    spec/controllers/hoges_controller_spec.rb
    spec/controllers/root_controller_spec.rb
    spec/models/hoge_spec.rb

プロセスごとにファイルをどの順序で実行するのかを知りたい

例えば CI を実行する中で、テストファイルの実行順序に依存して失敗する課題が隠れていたとします。
今までの TIPS を使えば、どの seed 値を使って、どのファイル郡を対象としてテストを実行すれば flaky test を再現できるかがわかります。

たとえば以下の出力があったとして、 spec/models/foo_spec.rb が CI で失敗したときにどうするのか?

PID (1075695) 5 files to run:
    spec/controllers/bars_controller_spec.rb
    spec/controllers/foos_controller_spec.rb
    spec/models/bar_spec.rb
    spec/models/baz_spec.rb
    spec/models/foo_spec.rb
Randomized with seed 54242

手元での再現方法としては、以下のような実行方法になります。

bundle exec rspec --seed 54242 \
    spec/controllers/bars_controller_spec.rb \
    spec/controllers/foos_controller_spec.rb \
    spec/models/bar_spec.rb \
    spec/models/baz_spec.rb \
    spec/models/foo_spec.rb

もちろんこれでも良いのですが、 CI の実行ログを見ると以下みたいな出力だったらどうでしょうか?
(format progress としています)

...F...........

なんとなく、 spec/models/foo_spec.rb は早い順番で実行されたことが想像できます。
仮に、該当テストファイルが実は 2番目に実行されたと未来の自分は知っているとしてもここではとりあえず 5ファイル全てを対象にしないと再現はできません。
(もちろん、熟練者の勘が働くというチートもないわけではないですが…)

というわけで、実際のテストファイル実行順序がわかっていれば再現手順が省力化できることが期待できそうです。
先程のコードを、以下のように修正します。

spec/rails_helper.rb
 RSpec.configure do |config|
   config.before(:suite) do
-    files = config.files_to_run
+    files = config.world.ordered_example_groups.map { it.file_path }
     normalized = files.map { Pathname.new(File.absolute_path(it)).relative_path_from(Rails.root) }
     banner = "PID (#{Process.pid}) #{normalized.count} files to run:"
     puts [banner, *normalized].join("\n\t")

     # ...
   end

   # ...
 end

上記の調整をすると、出力が以下のようになります。
ファイルの順序が変わっていますね。

PID (1075695) 5 files to run:
    spec/controllers/foos_controller_spec.rb
    spec/models/foo_spec.rb
    spec/models/bar_spec.rb
    spec/models/baz_spec.rb
    spec/controllers/foos_controller_spec.rb

そして、ここで出力された順番どおりに rspec はテストを進めていきます。
また、失敗していたテストは spec/models/foo_spec.rb だったのでそれ以降は再現のためには実行する必要はありません。
手元で再現するには、以下で十分だということがわかります。再現手順がかなり省力化できます!

bundle exec rspec --seed 54242 \
    spec/controllers/foos_controller_spec.rb \
    spec/models/foo_spec.rb

とても便利なのですが、一つ問題があります。
それは、この仕組みを実現するためのポイントである ordered_example_groups が現時点でプライベート API であるということです。
更に、 world もプライベート API です。

https://github.com/rspec/rspec-core/blob/v3.13.2/lib/rspec/core/world.rb#L49-L55
https://github.com/rspec/rspec-core/blob/v3.13.2/lib/rspec/core.rb#L158-L162

というわけで、リリース情報に載ってこないサイレント修正があるかもしれないことは認識しておくと良いと思います。

まとめ

parallel_tests は CI 高速化のために有用なツールですが、並列でテストを実行するゆえに flaky test に伴う再現・調査の方法が難しくなります。
そこで、調査しやすくするための TIPS をいくつか紹介しました。
「なんだかときどき失敗しているけど並列テストの結果がごちゃごちゃしていてどこから手を付けていいのか……」みたいな状況も、コツコツ整理していけば原因にたどりつけるので CI の安寧を目指していきましょう。

Discussion