ENECHANGE Developer Blog

ENECHANGE開発者ブログ

scenario内の一部分をリトライできるrspec-retry_exの紹介

プラットフォーム事業部の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のようなアコーディオンの箇所でアニメーションがあるのがほとんどでした。

f:id:yuyasat:20190221185732g:plain
図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. itの挙動の説明(scenarioはitのエイリアスなので、同様の挙動をする)

こうした挙動における問題点は、それ以降のテストが実行されず、他でも失敗していることに気づけないことです。例えば、リスト1では、expect(user.given_name_kana).to eq 'ジロウ'でテストが再び落ちますが、図2に示すように、RSpecはそれを指摘してくれません。

f:id:yuyasat:20190221190006p:plain
図2. テストの実行結果

そのため、以前(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
リスト2. itブロック内のexpectを分けて書く例

しかし、これではテストにかかる時間が増えてしまいますし、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
リスト3. aggregate_fauluresの利用例

すべてのテストケースにaggregate_failuresを適用するには伊藤淳一さんのQiitaの記事が詳しいです。

aggregate_failuresを用いるとitブロックの中の一連のテストの途中でテストが落ちたとしても先に進んでくれます。aggregate_failuresがどのような実装で実現しているかが気になりました。それが分かれば、特定のexpectにおいてテストが落ちた時にリトライする機構を実装することができます。

aggregate_failuresの実装自体は、rspec-expectations/blob/master/lib/rspec/matchers.rbにあります(図3)。

f:id:yuyasat:20190221190116p:plain
図3. aggregate_failuresの実装箇所

ここから、一つ一つコードを追っていくとどうやら、RSpec::Expectations::ExpectationNotMetErrorをrescueすれば良さそうだということがわかりました。 リスト4にコアとなる実装箇所を示します。実装はシンプルでインスタンス変数@counterには指定されたリトライ回数が格納され、その回数に達するまでリトライを繰り返します。

def run
  @counter += 1
  yield
rescue RSpec::Expectations::ExpectationNotMetError => e
  retry if @counter < count
  raise e
end
リスト4. rspec-retry_exの実装のコア部分

詳細は GitHub をご覧ください。

使い方はシンプルでリトライしたいexpectretry_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
リスト5. rspec-retry_exの使用例

また、テストによっては、失敗した場合再度やり直したいという場合もあります。そのため、失敗した後に実行するコードを引数に取れるようにしています。リスト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

リスト6. after_retryオプションの使用例

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
リスト7. after_retryの実際の利用シーン

rspec-retry_exを利用することで、scenarioをもう一度実行することなく、特定のexpectのみをリトライすることができました。その結果、CIでランダムに落ちていたテストが通るようになり、社内に平穏が訪れました。

追記

2019年2月20日のOtemachi.rbにおいて簡単に紹介を行いました。こちらのスライドも合わせてご覧ください。