parallel_tests gem の parallel_rspec コマンドを使ったテストで、ランダムに失敗するという現象が出ていて、 一部だけ実行しても失敗しなかったり、全体を実行しても失敗の場所がバラバラだったりして、 デバッグプリントを入れるのも難しかったので、失敗したときに debug gem で調査しました。

対象バージョン

  • Ruby 3.2.4
  • parallel_tests 4.7.1
  • rspec-core 3.13.0
  • debug 1.9.2

binding.break に挑戦 (失敗)

ランダムに失敗するので、失敗するときに発生する例外を調べてみたところ、 RSpec::Expectations::ExpectationNotMetError でした。 それを TracePoint でみつけて binding.break すればいいかと思って試してみたところ、うまくいきませんでした。

確認用に RuntimeError で実験してみたところ、 tp.binding.break はすぐに返ってきて pbindinginspect が表示されて、 rdbg --attach --port=10000 で確認すると sleep の行が実行中でした。 (sleep がないとすぐ終わってしまうので attach するタイミングをつくるために入れました。)

require 'debug/session'
::DEBUGGER__.open_tcp host: nil, port: 10000, nonstop: true
TracePoint.trace(:raise) do |tp|
  case tp.raised_exception
  when RuntimeError
    p tp.binding.break
    sleep 10
  end
end
raise "foo"

.rdbgrc で catch

TracePoint と debug gem は相性が悪そうということで、 ちゃんと debug gem の機能を使うことにしました。

ためしに ~/.rdbgrc に以下のように書くとうまくいきました。

catch RuntimeError

コードに埋め込み

しかし、テスト対象の環境は docker コンテナで、毎回ファイルを作成するのは面倒なので、 Ruby のコード中でできないか探してみたところ、 .rdbgrc の内容は最終的に https://github.com/ruby/debug/blob/8abc50acba3d079aec8107c60ecae8a0285413ac/lib/debug/session.rb#L374add_preset_commands で実行しているようだったので、 直接このメソッドを呼ぶことにしました。

その結果、最低限の rspec 環境ではこれを spec/spec_helper.rb に書くとうまくいきました。

require 'debug/session'
::DEBUGGER__.open_tcp host: nil, port: 10000+ENV.fetch('TEST_ENV_NUMBER', '').to_i, nonstop: true
::DEBUGGER__::SESSION.add_preset_commands __FILE__, <<'COMMANDS'.lines # inline ~/.rdbgrc
catch RSpec::Expectations::ExpectationNotMetError
COMMANDS

https://st0012.dev/ruby-debug-cheatsheet によると binding.b(do: "catch StandardError") というのがあったので、 これに書き換えました。

実際のテスト対象の環境では config/initializers/ に置いてみたら、他の処理が失敗したので、 spring gem のガードを追加して、最終的には以下になりました。

unless ENV.fetch('DISABLE_SPRING', '0') != '1'
  require 'debug/session'
  ::DEBUGGER__.open_tcp host: nil, port: 10000+ENV.fetch('TEST_ENV_NUMBER', '').to_i, nonstop: true
  binding.b(do: 'catch RSpec::Expectations::ExpectationNotMetError')
end

そして、 docker compose exec app env DISABLE_SPRING=1 RAILS_EAGER_LOAD=1 RUBYOPT='-W:deprecated' bundle exec parallel_rspec -n 4 --test-options="--seed $RANDOM --fail-fast" のように実行して、止まったところで bundle exec rdbg --attach --port 10000 などでアタッチして調査しました。

--test-options の方に --fail-fast はつけても大丈夫でしたが、 parallel_rspec の方にも --fail-fast をつけると attach する前に終了してしまってうまくいきませんでした。

socket を使った理由

parallel_tests は複数プロセスで同時実行していて、 tty が使いにくかったり、出力は親プロセスでキャプチャしていて対話環境に使えなかったりしたので、 socket 接続を使いました。

複数同時待受するのに、Unix Domain Socket のパスを考えるよりポート番号をずらす方が楽そうだったので、

ポート番号は適当に 10000 からにしましたが、ローカルだけなので他と重ならなければ何番でも良さそうです。 parallel_tests 環境なので、重ならないように TEST_ENV_NUMBER を足しています。

まとめ

spec/spec_helper.rb などに以下のようなコードを追加すると失敗時にデバッガで調査できることがわかりました。

require 'debug/session'
::DEBUGGER__.open_tcp host: nil, port: 10000+ENV.fetch('TEST_ENV_NUMBER', '').to_i, nonstop: true
binding.b(do: 'catch RSpec::Expectations::ExpectationNotMetError')

実際に止まる場所は自分の spec の中ではなく、 rspec の中なので非常にわかりにくいですが、 再現性が微妙なときにはそんなことも言っていられないので、 頑張って upframe で移動して info などのコマンドや p メソッドを使って調査しました。

Disqus Comments

Kazuhiro NISHIYAMA

Ruby のコミッターとかやってます。 フルスタックエンジニア(って何?)かもしれません。 About znzに主なアカウントをまとめました。

znz znz


Published