Chrome 134以降でもSelenium testを安定させたい

 はじめに

参加しているプロジェクトで、Chrome 133以前では正常に通っていた RSpec の system テストがChromeのバージョンアップにより通らなくなるという現象が発生しました。

調査したところ、Capybara の GitHub リポジトリでも同様の issue が報告されているのを確認できましたが、2025/8/13 時点ではまだ Open のままとなっています。

Chrome 134 breaks Selenium tests – intermittent failures with visit(visit_uri) #2800

この問題の根本原因は visit が不安定になっていることにあり、それにより後続の DOM 操作も正しく動作しなくなるという状況のようです。

issueでは

  • Chrome側の修正を待つべきか
  • Gem側で対応すべきか
  • Dockerで一時的にChrome 133 を使い続けるべきか

…といった議論が出ているものの「とにかく今すぐ通したい」というのが現場の状況だったため、応急処置として独自に修正を加え、ある程度安定して動作させることができました。

本記事では実際に実施した対応内容についてまとめてみました。

 

目次

全体の安定化対策

ActiveStorageクリーンアップ

テスト間でのActiveStorageデータの完全削除をあらためて実施 

# spec/rails_helper.rb
# ActiveStorageのクリーンアップ処理
config.before(:each) do
  # 前回の残留データを確実にクリア(テスト実行途中での強制終了対策)
  ActiveStorage::VariantRecord.unscoped.delete_all if defined?(ActiveStorage::VariantRecord)
  ActiveStorage::Attachment.unscoped.delete_all
  ActiveStorage::Blob.unscoped.delete_all
  FileUtils.rm_rf(Rails.root.join('path_to_tmp_storage'))
rescue StandardError => e
  Rails.logger.warn "ActiveStorage cleanup error in before: #{e.message}"
end

config.after(:each) do |example|
  # 全テストタイプで先にセッションリセット
  Capybara.reset_sessions!

  # systemテストの場合は少し待機
  sleep 0.5 if example.metadata[:type] == :system

  # ActiveStorageクリーンアップ
  ActiveStorage::VariantRecord.unscoped.delete_all if defined?(ActiveStorage::VariantRecord)
  ActiveStorage::Attachment.unscoped.delete_all
  ActiveStorage::Blob.unscoped.delete_all
  FileUtils.rm_rf(Rails.root.join('path_to_tmp_storage'))
rescue StandardError => e
  Rails.logger.warn "ActiveStorage cleanup error in after: #{e.message}"
end

個別メソッドの安定化対策

以下の2つのアプローチでメソッドの安定化を実現しています

1. オーバーライド方式

既存のCapybaraメソッドを専用モジュール内でオーバーライドし、RSpec設定でtype: :systemテストにextendして安定化処理を追加

# 既存メソッドをオーバーライドする例
module CapybaraVisitOverride
  def visit(url)
    super # 元のメソッドを実行
    # 追加の安定化処理
  end
end

# テスト実行時にオーバーライドを適用
RSpec.configure do |config|
  config.before(:each, type: :system) do
    Capybara.current_session.extend(CapybaraVisitOverride)
  end
end

2. 新規メソッド追加方式

既存メソッドはそのままに、CapybaraCustomHelpersモジュール内で安定化された新しいメソッドを追加定義し、RSpec設定でtype: :systemテストにincludeする

# 新しいヘルパーメソッドを追加する例
module CapybaraCustomHelpers
  def wait_and_click_on(text, options = {})
    # 安定化されたクリック処理
  end
end

# テストで使用可能にする
RSpec.configure do |config|
  config.include CapybaraCustomHelpers, type: :system
end

非同期処理対応 【新規メソッド追加方式】

ページ読み込み完了の適切な待機を行う。

# ページ読み込み完了を待機するヘルパーメソッド
def wait_page_completed(wait: 10)
  # Capybaraのsynchronizeメソッドで指定時間内に条件が満たされるまで待機
  page.document.synchronize(wait) do
    # 1. HTMLドキュメントの読み込み完了を確認
    ready = page.evaluate_script('document.readyState') == 'complete'

    # 2. jQuery Ajaxリクエストの完了を確認(jQueryが使用されている場合)
    no_jquery_ajax = page.evaluate_script('typeof jQuery !== "undefined" ? jQuery.active == 0 : true')

    # 3. Fetch APIリクエストの完了を確認
    no_fetch_requests = page.evaluate_script(<<~JS)
      (function() {
        // 未定義の場合はfetchを監視するよう初期化
        if (typeof window.__pendingRequests === 'undefined') {
          window.__pendingRequests = 0;
          // 元のfetchメソッドを保持
          const originalFetch = window.fetch;
          // fetchメソッドをオーバーライドしてリクエスト数を追跡
          window.fetch = function(...args) {
            window.__pendingRequests++; // リクエスト開始時にカウント増加
            return originalFetch.apply(this, args).finally(() => {
              window.__pendingRequests--; // リクエスト完了時にカウント減少
            });
          };
        }
        return window.__pendingRequests === 0; // 進行中のリクエストがないことを確認
      })()
    JS

    # 全ての条件が満たされた場合にtrueを返す
    ready && no_jquery_ajax && no_fetch_requests
  end
end

 

  • 利用時の例
# visit後のページ読み込み待機
visit '/some/path'
wait_page_completed

# Ajax処理後の待機
click_on '送信'
wait_page_completed(wait: 15)

 

 

visitへの修正 【オーバーライド方式】

Chrome134での不安定な動作に対応するため、visitメソッドをオーバーライドしてページ読み込み完了を適切に待機する。

前提: wait_page_completedメソッドが定義されていること

module CapybaraVisitOverride
  def visit(url)
    # URLから期待するパスを抽出(//を/に正規化)
    expected_url = "/#{URI.parse(url).path}".gsub(%r{//}, '/')
    # 元のvisitメソッドを実行
    super

    # Capybaraのsynchronizeを使用して最大5秒間ページ読み込み完了を待機
    document.synchronize(5) do
      # 以下のいずれかの条件が満たされるまで待機
      current_path.start_with?(expected_url) ||  # パスが期待値で始まる
        current_path == expected_url ||           # パスが期待値と完全一致
        current_url.include?(expected_url)        # URLに期待値が含まれる
    end

    # JavaScript/Ajax処理を含む完全なページ読み込み待機
    wait_page_completed
  rescue Capybara::ElementNotFound
    # 権限チェックでエラーになるパターンもあるため、visitできなくてもエラーにしない
  end
end

 

  • 利用時の例
# システムテスト設定でオーバーライドを適用
RSpec.configure do |config|
  # 各システムテストの実行前にvisitメソッドのオーバーライドを適用
  config.before(:each, type: :system) do
    Capybara.current_session.extend(CapybaraVisitOverride)
  end
end

 

click_onの安定化 【新規メソッド追加方式】

クリック対象の要素が存在することを確認してから実行し、リンクの場合は遷移先も検証する。

def wait_and_click_on(text, wait: 10, expect_href: false, use_js_click: false)
  # 指定されたテキストのリンクまたはボタンが存在することを確認
  expect(page).to have_selector(:link_or_button, text, wait: wait)
  # 対象の要素を取得
  el = find(:link_or_button, text, wait: wait)
  # ボタンやinput要素の場合は無効化されていないことを確認
  expect(el.disabled?).to be false if %w[button input].include?(el.tag_name)
  # リンクの場合はhref属性を取得(遷移先確認用)
  href = el[:href] if expect_href && el.tag_name == 'a'

  # クリック実行方法を選択
  if use_js_click
    # JavaScriptでクリックを実行(より確実)
    page.execute_script('arguments[0].click()', el)
  else
    # 通常のクリック
    el.click
  end

  # href確認が不要な場合は終了
  return unless expect_href && href

  # リンクの遷移先が正しいかを確認
  expected_path = URI.parse(href).request_uri
  expect(page).to have_current_path(expected_path, wait: wait)
end
  • 利用時の例
# 通常のクリック
wait_and_click_on('保存')

# リンクの遷移先確認付き
wait_and_click_on('詳細', expect_href: true)

# JavaScript経由でのクリック
wait_and_click_on('送信', use_js_click: true)

 

fill_inの安定化 【新規メソッド追加方式】

フォームフィールドの存在確認後に値を入力し、入力完了を待機する。JavaScript経由での入力にも対応。

def wait_and_fill_in(field, with:, wait: 10, after_expect: true, use_js: false)
  # 対象のフィールドが存在することを確認
  expect(page).to have_field(field, wait: wait)

  # 入力方法を選択
  if use_js
    # JavaScript経由での入力(より確実)
    element = find_field(field, wait: wait)
    page.execute_script(<<~JS, element, with.to_s)
      const el = arguments[0];    // 対象の要素
      const value = arguments[1]; // 入力する値
      el.value = value;           // 値を直接設定
      // イベントを発火してJavaScriptフレームワークに変更を通知
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
    JS
  else
    # 通常のfill_in
    fill_in field, with: with
  end

  # 入力後の検証が不要な場合は終了
  return unless after_expect

  # 期待値をUTF-8に変換(文字化け防止)
  expected_value = with.to_s.force_encoding('UTF-8')
  # フィールドの値が期待値になるまで待機
  wait_for_field_value(field, expected_value, wait: wait)
  # 最終的に値が正しく設定されていることを確認
  expect(find_field(field).value).to eq(expected_value)
end

# 指定したフォームの値が指定値に変化するのを指定秒分待機
def wait_for_field_value(selector, expected_value, wait: 10)
  normalized_expected = expected_value.to_s.force_encoding('UTF-8')
  page.document.synchronize(wait) do
    find_field(selector, match: :first).value == normalized_expected
  end
end
  • 利用時の例
# 通常の入力
wait_and_fill_in('メールアドレス', with: 'foo@example.com')

# JavaScript経由での入力
wait_and_fill_in('パスワード', with: 'password123', use_js: true)

# 入力後の検証をスキップ
wait_and_fill_in('備考', with: '備考内容', after_expect: false)

chooseの安定化 【新規メソッド追加方式】

ラジオボタンの存在確認後に選択し、選択状態の確定を待機する。

def wait_and_choose(locator, wait: 10, use_js: false)
  # 指定されたラジオボタンが存在することを確認
  expect(page).to have_field(locator, type: 'radio', wait: wait)

  # 選択方法を決定
  if use_js
    # JavaScript経由での選択(より確実)
    element = find_field(locator, type: 'radio', wait: wait)
    page.execute_script(<<~JS, element)
      const el = arguments[0];    // 対象のラジオボタン
      el.checked = true;          // チェック状態に設定
      // changeイベントを発火してJavaScriptフレームワークに変更を通知
      el.dispatchEvent(new Event('change', { bubbles: true }));
    JS
  else
    # 通常のchoose
    choose(locator)
  end

  # ラジオボタンがチェック状態になるまで待機
  wait_for_checked(locator, type: 'radio', wait: wait)
  # 最終的にチェックされていることを確認
  expect(page).to have_checked_field(locator, wait: wait)
end

# 指定したdomがチェック状態になるまで待機
def wait_for_checked(locator, type:, wait: 10)
  page.document.synchronize(wait) do
    find_field(locator, type: type, match: :first).checked?
  end
end
  • 利用時の例
# 通常のラジオボタン選択
wait_and_choose('男性')

# JavaScript経由での選択
wait_and_choose('男性', use_js: true)

ファイルアップロードの安定化 【新規メソッド追加方式】

ラベルを基準にファイル入力要素を特定し、確実にファイルを添付する。

def attach_file_by_label(label_text, file, wait: 10, after_expect: true)
  # 指定されたテキストのラベル要素を取得
  label = page.find('label', text: label_text, wait: wait)
  # ラベルのfor属性を取得(対応するinput要素のIDを指定)
  input_id = label[:for]

  if input_id
    # for属性がある場合:対応するinput要素を直接指定
    expect(page).to have_selector("##{input_id}", wait: wait, visible: :all)
    # フィールドが有効化されるまで待機(初期表示で非活性の場合は入れておくと安定)
    expect(page).to have_selector("##{input_id}:not([disabled])", wait: wait, visible: :all)
    # ファイルを添付(非表示要素でも添付可能にする)
    attach_file(input_id, file, make_visible: true)
    # 添付先の要素を取得
    input = find("##{input_id}", visible: :all)
  else
    # for属性がない場合:ラベル内のinput[type="file"]を探す
    input = label.find('input[type="file"]', wait: wait, visible: :all)
    # クリック経由でファイル添付を実行
    attach_file(nil, file, make_visible: true, visible: true) { input.click }
  end

  # 添付後の検証が不要な場合は終了
  return unless after_expect

  # ファイル名のみを抽出
  expected_filename = File.basename(file)
  # input要素の値にファイル名が含まれていることを確認
  expect(input.value).to include(expected_filename), wait: wait
end
  • 利用時の例
# ラベル指定でのファイル添付
attach_file_by_label(attach_file_by_label('ファイル選択', Rails.root.join('path_to_file'))

# 添付後の検証をスキップ
attach_file_by_label('ファイル選択', file_path, after_expect: false)

 

ダウンロードファイル監視 【新規メソッド追加方式】

ファイルダウンロードの完了を確実に待機。

# CapybaraDownloadsHelperモジュール
module CapybaraDownloadsHelper
  PATH = Rails.root.join('tmp_path_for_downloads').to_s.freeze

  class << self
    # ダウンロード先のディレクトリ
    def path
      PATH # テスト並列実行しない場合は固定が安定
      # File.join(PATH, Thread.current.object_id.to_s) # テスト並列実行の時はこちら
    end

    # ダウンロードしたファイル一覧を返す
    def files
      Dir[File.join(path, '*')]
    end

    # ダウンロードの完了を待つ
    def wait_downloaded(timeout: 30.second)
      @file_sizes = {} # ファイルサイズ監視用のハッシュを初期化

      # 指定されたタイムアウト時間内でダウンロード完了を待機
      Timeout.timeout(timeout) do
        loop do
          # 以下の条件が全て満たされた場合にダウンロード完了と判定
          # 1. .crdownload拡張子のファイルが存在しない(Chrome一時ファイルなし)
          # 2. 何らかのファイルが存在する
          # 3. 全てのファイルのサイズが安定している
          break if files.grep(/\.crdownload$/).none? && files.any? && files.all? { |f| stable_file?(f) }

          sleep 0.2 # 0.2秒待機してから再チェック
        end
      end
    end

    # ファイルサイズが安定しているかを確認
    def stable_file?(file)
      # ファイルが存在しない場合は不安定と判定
      return false unless File.exist?(file)

      # 前回のサイズを取得
      previous_size = @file_sizes[file]
      # 現在のサイズを取得
      current_size = File.size(file)
      # 現在のサイズを記録
      @file_sizes[file] = current_size

      # 前回と今回のサイズが同じであれば安定と判定
      previous_size == current_size
    end
  end
end
  • 利用時の例
# ダウンロードボタンクリック後の待機
click_on 'CSVダウンロード'
CapybaraDownloadsHelper.wait_downloaded

# タイムアウト時間を変更した待機
click_on '大容量ファイルダウンロード'
CapybaraDownloadsHelper.wait_downloaded(timeout: 60.seconds)

※ パラメータの使い分けについて

  • wait_and_fill_in/wait_and_chooseuse_jsパラメータ
  • wait_and_click_onuse_js_clickパラメータ

は以下の形でそれぞれ利用可能です。

use_js: true / use_js_click: trueを使用するケース

  • 通常の操作で不安定な場合: Capybaraの標準的な操作がブラウザのレンダリングタイミングやCSSセレクタにより失敗する場合
  • 非表示要素の操作: CSSで非表示にされているけれど、DOM上には存在する要素を操作する場合
  • カスタムJavaScriptイベント: 独自のJavaScriptフレームワークやライブラリが特定のイベントを期待する場合
  • パフォーマンス重視: 大量のデータや複雑なUIのテストで、直接的なJavaScript実行が高速な場合
  • オーバーレイやモーダル内の要素: 通常のクリックが他の要素に遮られる場合(use_js_clickの場合)

 

use_js: false / use_js_click: false(デフォルト)を使用するケース

  • 通常のユーザー操作をシミュレート: 実際のユーザー操作に近いテストを実現したい場合
  • アクセシビリティの検証: スクリーンリーダーやキーボード操作でのアクセシビリティを確認したい場合
  • ブラウザの標準動作をテスト: フォームのバリデーションやデフォルトのHTML動作を確認したい場合
  • カスタムUIコンポーネントとの連携テスト: カレンダーピッカー、セレクトボックス、オートコンプリート等のカスタムUIが期待通りにユーザー操作に反応するかテストする場合(use_jsの場合)
  • 要素の表示状態を検証: 実際にユーザーがクリック可能な状態かを確認したい場合(use_js_clickの場合)

あとがき

本対策の位置づけについて

今回紹介した安定化対策は、Chrome134以降で発生したシステムテストの不安定性に対する応急処置にすぎません。そのため、以下の点に注意が必要です。

  • 根本解決ではない
    • ChromeやCapybara側での根本的な修正が行われた際には、これらの対策を段階的に撤去することを前提としています
  • 技術的負債の認識
    • オーバーライドや独自ヘルパーメソッドは保守性に影響を与える可能性があります
      • optionでuse_jsを渡す形式よりラッパーを用意して当てはめることで、一括変換で戻せるようにしておくとベター
  • 未対応のメソッドがまだまだある
    • visitclick_onfill_inchoose、ファイルアップロード等の基本的な操作のみ対応を入れています
    • プルダウン(select)、チェックボックス(check/uncheck)等の操作については今回記載していません
役に立ったらシェアしていただけると嬉しいです
  • URLをコピーしました!
  • URLをコピーしました!
目次