プラットフォーム事業部のtaki(@yuyasat)です。
昨年10月のブログでenechange の feature spec がランダムで落ちる事象への対応について、2つのリトライ機構を利用している旨を紹介いたしました。 一つは、rspec-retry gem の利用、もう一つは rspec-retry_ex gem の利用です。前者のrspec-retry gemは、feature specのscenario単位でのリトライを行うgemでこれはすでに公開されているgemを利用しました。一方、後者の rspec-retry_ex は scenario の中の expect 単位でリトライを行う gem です。こちらは、公開されている gem がなく、私が実装を行い、gem として公開いたしました。 本記事では rspec-retry_ex を gem として公開するまでの経緯を紹介したいと思います。
事の発端はcapybaraのjavascript_driver を、これまで用いていたwebkit からHeadless Chromeに変更したことでした。理由はまだ調査中ですが、Headless Chromeに変更したことで処理が重くなり、JavaScriptのアニメーションの挙動にもたつきが出たと推測しています。もたつきは毎回必ずではなく、もう一度テストを実行すればテストが通ることも多くありました。 テストが落ちている箇所は、図1のようなアコーディオンの箇所でアニメーションがあるのがほとんどでした。
エネチェンジの申込フォームにはこのようなアコーディオンは、申込完了に至るまで、複数回存在します。つまり、一つのfeature specのscenarioの中でテストがランダムに落ちうる箇所が複数存在するということになります。この状況の中で、scenarioごとのリトライをしてもどこかで失敗してしまうと最初からやり直しになり、リトライしてもテストが成功しないという状況が起きてくるようになりました。 そこで、scenarioの中の一部分だけ失敗してもリトライできるようにできないだろうかと考えました。
scenarioブロック(itやexampleでも同じ)内でテストを書くと途中で失敗すると通常は、それ以降のテストは実行されません。例えばリスト1において、expect(user.given_name).to eq '次郎'
でテストが失敗するので、expect(user.family_name_kana).to eq 'テスト'
やexpect(user.given_name_kana).to eq 'ジロウ'
は実行されません。
RSpec.describe User, type: :model do let(:user) { build(:user, family_name: 'てすと', given_name: '太郎', family_name_kana: 'テスト', given_name_kana: 'タロウ') } it 'check attributes' do expect(user.family_name).to eq 'てすと' expect(user.given_name).to eq '次郎' # <= ここでテストが失敗すると expect(user.family_name_kana).to eq 'テスト' # <= それ以降が実行されない expect(user.given_name_kana).to eq 'ジロウ' end end
こうした挙動における問題点は、それ以降のテストが実行されず、他でも失敗していることに気づけないことです。例えば、リスト1では、expect(user.given_name_kana).to eq 'ジロウ'
でテストが再び落ちますが、図2に示すように、RSpecはそれを指摘してくれません。
そのため、以前(4年ほど前)は、どこで落ちたかを明確にするため、it(scenario)ブロック内でまとめて書かかずに、あえて分けて書いていた時期もありました。
RSpec.describe User, type: :model do let(:user) { build(:user, family_name: 'てすと', given_name: '太郎', family_name_kana: 'テスト', given_name_kana: 'タロウ') } it { expect(user.family_name).to eq 'てすと' } it { expect(user.given_name).to eq '次郎' } it { expect(user.family_name_kana).to eq 'テスト' } it { expect(user.given_name_kana).to eq 'ジロウ' } end
しかし、これではテストにかかる時間が増えてしまいますし、feature specでは画面操作の流れが必要になってきますので、リスト2のように分けて書くことは望ましくありません。
そこで利用するのがRSpec3.3から追加されたaggregate_failures
です。リスト3にaggregate_failures
を示します。
RSpec.describe User, type: :model do let(:user) { build(:user, family_name: 'てすと', given_name: '太郎', family_name_kana: 'テスト', given_name_kana: 'タロウ') } it 'check attributes' do aggregate_failures do expect(user.family_name).to eq 'てすと' expect(user.given_name).to eq '次郎' expect(user.family_name_kana).to eq 'テスト' expect(user.given_name_kana).to eq 'ジロウ' end end end
すべてのテストケースにaggregate_failures
を適用するには伊藤淳一さんのQiitaの記事が詳しいです。
aggregate_failures
を用いるとit
ブロックの中の一連のテストの途中でテストが落ちたとしても先に進んでくれます。aggregate_failures
がどのような実装で実現しているかが気になりました。それが分かれば、特定のexpectにおいてテストが落ちた時にリトライする機構を実装することができます。
aggregate_failures
の実装自体は、rspec-expectations/blob/master/lib/rspec/matchers.rbにあります(図3)。
ここから、一つ一つコードを追っていくとどうやら、RSpec::Expectations::ExpectationNotMetError
をrescueすれば良さそうだということがわかりました。
リスト4にコアとなる実装箇所を示します。実装はシンプルでインスタンス変数@counter
には指定されたリトライ回数が格納され、その回数に達するまでリトライを繰り返します。
def run @counter += 1 yield rescue RSpec::Expectations::ExpectationNotMetError => e retry if @counter < count raise e end
詳細は GitHub をご覧ください。
使い方はシンプルでリトライしたいexpect
をretry_ex
ブロックで囲みます。引数にはリトライしたい回数が指定できます。
簡単な使用例をリスト5に示します。この例では現在時刻の秒数の一桁目が5
であることをテストしており、expect
の前でsleep 1
を実行しているので、10回行えば必ずテストが通ります。
(include RSpec::RetryEx
は、spec_helper.rbやrails_helper.rbに記述しておくと良いでしょう。)
include RSpec::RetryEx RSpec.describe 'RetryEx sample' do it do retry_ex(count: 10) do sleep 1 expect(Time.zone.now.strftime('%S')[-1]).to eq '5' end end end
また、テストによっては、失敗した場合再度やり直したいという場合もあります。そのため、失敗した後に実行するコードを引数に取れるようにしています。リスト6は、リスト5の例で、sleep 1
としているのを失敗した後に実行している例です。retry_ex
はキーワード引数にafter_retry
を持ち、ラムダ式で渡してあげることで、テスト失敗後に任意のコードを実行することができます。
include RSpec::RetryEx RSpec.describe 'RetryEx sample' do it do after_retry = -> { sleep 1 } retry_ex(count: 10, after_retry: after_retry) do expect(Time.zone.now.strftime(’%S’)[-1]).to eq '5' end end end
feature specでの具体的な例ではをリスト7に示します。このコードの例では、最初に、providerというセレクトボックスで関西電力を選び、current_plan_keyに従量電灯Aが選ばれていることを期待しています。失敗した場合は、一度東京電力を選んでからまた関西電力を選んでテストを実行しています。プルダウンの値を変化させることでイベントを再発火させるという意図です。
select '関西電力', from: 'provider' retry_ex(count: 3, after_retry: -> { select '東京電力', from: 'provider' select '関西電力', from: 'provider' }) do expect(page).to have_select('current_plan_key', selected: '従量電灯A') end
rspec-retry_exを利用することで、scenarioをもう一度実行することなく、特定のexpectのみをリトライすることができました。その結果、CIでランダムに落ちていたテストが通るようになり、社内に平穏が訪れました。
追記
2019年2月20日のOtemachi.rbにおいて簡単に紹介を行いました。こちらのスライドも合わせてご覧ください。