Capybara + Selenium の Flaky Test に抗う

RSpec + Capybara + Selenium で Headless Chrome による System Spec を実行しているプロジェクトで、たまに失敗する Flaky Test が存在する。

テストの内容は、ページ内の「保存」ボタンをクリックして、ページ遷移後に「変更を保存しました。」メッセージが表示されることをチェックするもの。 文章にするとシンプルなのだが、このテストがたまに失敗する。リトライすると大抵成功する。ページ遷移する前にテストが評価されているのかと思い、wait で待機してみたものの解決しなかった。

調査する時間が取れず根本的に解決するのが難しいため、失敗したらリトライするモジュールを作成した (Claude Code が)。

retry_on_stale_element ブロック内のテストが失敗したとき、エラークラス (Selenium::WebDriver::Error::UnknownError) とエラーメッセージ (Node with given id does not belong to the document) であれば 0.1 秒待ってリトライする。デフォルトでは 5 回までリトライする。

# spec/support/capybara_retry.rb
module Capybara
  module Retry
    # Retries a block of code when a specific Selenium "stale element" error occurs.
    # This is useful for handling race conditions in feature specs where the DOM
    # is updated while Capybara is performing an action.
    #
    # @param max_retries [Integer] The maximum number of times to retry.
    # @param wait_time [Float] The wait time in seconds before retry.
    # @yield The block of Capybara operations to execute.
    def retry_on_stale_element(max_retries: 5, wait_time: 0.1)
      retries = 0
      begin
        yield
      rescue ::Selenium::WebDriver::Error::UnknownError => e
        # Only retry for this specific "stale element" message from Selenium
        raise e unless e.message.include?('Node with given id does not belong to the document')

        if retries < max_retries
          retries += 1
          warn "WARN: Stale element detected. Retrying ##{retries}/#{max_retries} after #{wait_time}s..."
          sleep wait_time
          retry
        else
          warn "ERROR: Stale element error persisted after #{max_retries} retries."
          raise e
        end
      end
    end
  end
end

必要に応じて rails_helper.rb などで require する。

# spec/rails_helper.rb
Rails.root.glob('spec/support/**/*.rb').each { |f| require f }

System Spec などで以下のように使用する。

it 'example request spec', :js do
  click_on '保存'

  retry_on_stale_element do
    expect(page).to have_content '変更を保存しました。'
  end
end

こういったテストが CI で失敗すると残念な気持ちになるが、今回の対応でストレスが減った。 Playwright は wait で待機する必要がないらしいので、余裕ができたら移行したい。