sleepでAjax完了を待つと結果が安定しない理由
一覧画面で検索条件を変えると裏側で非同期にデータを取り直し、DOMだけが差し替わる、というAjaxベースの画面はよくあります。ページ全体のリロードが発生しないためdriver.navigate後の読み込み待ちのようなタイミングが取りづらく、ついsleep(2)を挟んでから次の操作に進む、という書き方をしてしまいがちです。
しかしAjaxの応答時間はサーバーの負荷やクエリ内容によって大きく変動します。軽い検索なら数百ミリ秒で終わるのに、絞り込み条件が複雑になると2秒を超えることも珍しくありません。固定sleepで運用していると、軽いリクエストの日は無駄に待ち、重いリクエストの日は更新が終わる前に次の操作をしてしまい、古いDOMに対して要素を探してNoSuchElementErrorや、更新途中の中途半端な要素を拾ってしまう、といった不安定さにつながります。
これを避けるには、「何秒待つか」ではなく「非同期処理が終わったと判定できる状態になったか」を監視して待つ必要があります。Ajaxの完了を検知する手がかりはページの作り次第でいくつかあるので、代表的なものを見ていきます。
ローディングスピナー要素が消えるのを待つ
多くの画面では、Ajaxリクエスト中だけローディングスピナーやオーバーレイがDOMに表示され、レスポンスが返ってくると同時に消える作りになっています。このスピナー要素が存在しなくなったことをもって「表示上は完了した」と判定できます。
wait = Selenium::WebDriver::Wait.new(timeout: 10, interval: 0.2)
wait.until do
driver.find_elements(:css, ".loading-spinner").empty?
end
find_elementではなくfind_elementsを使っている点がポイントです。単数形だとスピナーが最初から存在しない瞬間にNoSuchElementErrorで落ちてしまいますが、複数形なら見つからない場合は空配列が返るだけなので、「スピナーが0件になった」という条件をそのまま素直に書けます。
スピナーが一瞬だけ表示されて即座に消えるケースでは、監視を始めた時点ですでに消えた後、という早すぎる判定をしてしまう可能性もあります。気になる場合は、クリックなどのトリガー操作の直後に一度スピナーの出現を確認してから、消えるのを待つという二段階にすると取りこぼしにくくなります。
jQuery使用サイトはjQuery.activeを監視する
対象サイトがjQueryでAjax通信をしている場合は、もっと直接的な手がかりがあります。jQueryは実行中のAjaxリクエスト数をjQuery.activeというグローバル変数で管理しており、通信中は1以上、すべて完了すると0に戻ります。スピナーのようなDOM要素の有無に頼らず、通信そのものの状態を直接確認できるのが利点です。
def jquery_idle?(driver)
driver.execute_script(
"return (typeof jQuery !== 'undefined') ? jQuery.active === 0 : true"
)
end
wait.until { jquery_idle?(driver) }
typeof jQuery !== 'undefined'のチェックを入れているのは、execute_scriptはJavaScriptとしてそのまま評価されるため、jQueryを読み込んでいないページで同じ判定関数を使い回すとjQuery is not definedという例外で落ちてしまうためです。jQuery未使用のページでは常にtrueを返すようにしておくと、後述の共通ヘルパーに安全に組み込めます。
なお、jQuery.activeはjQueryの内部実装に依存した値なので、fetchやaxiosなど別の手段で通信している部分はここには反映されません。対象ページの通信手段を把握したうえで、あくまで「jQuery経由の通信が空いているか」の判定として使うのが安全です。
フレームワーク非依存:要素数が安定するまで待つ
スピナーが存在しない、jQueryも使っていない、という画面では、もう少し泥臭い方法になりますが「対象のDOM要素数が一定回数連続で変化しなくなったら、描画が落ち着いた」とみなす手が使えます。更新中は要素が増減を繰り返し、更新が終わると数値が一定間隔でも変わらなくなる、という前提に基づく判定です。
def wait_until_count_stable(driver, selector, timeout: 10, interval: 0.3, stable_checks: 3)
deadline = Time.now + timeout
last_count = -1
stable_count = 0
while Time.now < deadline
current_count = driver.find_elements(:css, selector).size
if current_count == last_count
stable_count += 1
return current_count if stable_count >= stable_checks
else
stable_count = 0
end
last_count = current_count
sleep interval
end
raise Selenium::WebDriver::Error::TimeoutError,
"#{selector}の件数が安定しませんでした(最終値: #{last_count})"
end
stable_checks回連続で同じ件数が観測されて初めて「安定した」と判定しているのは、たまたま計測したタイミングで一時的に件数が一致しただけの偽陽性を減らすためです。ページの更新頻度に応じてintervalとstable_checksのバランスを調整し、更新が速いページでは短い間隔で多めの回数を、更新に時間がかかるページでは間隔を広げて回数を減らす、といった調整が必要になります。
複数の判定を組み合わせた待機ヘルパー
実際の現場では、スピナーの消失とjQueryの状態の両方を確認してから次に進みたい、というケースが多くなります。個別にwait.untilを重ねてもよいのですが、条件をまとめて1つのヘルパーにしておくと呼び出し側がすっきりします。
def wait_for_ajax_complete(driver, timeout: 10, interval: 0.2, spinner_selector: ".loading-spinner")
deadline = Time.now + timeout
loop do
spinner_gone = driver.find_elements(:css, spinner_selector).empty?
jquery_idle = driver.execute_script(
"return (typeof jQuery !== 'undefined') ? jQuery.active === 0 : true"
)
return true if spinner_gone && jquery_idle
if Time.now > deadline
raise Selenium::WebDriver::Error::TimeoutError,
"Ajax完了待機がタイムアウトしました(spinner_gone: #{spinner_gone}, jquery_idle: #{jquery_idle})"
end
sleep interval
end
end
呼び出し側では、検索ボタンをクリックした直後にこのヘルパーを呼ぶだけで済みます。
driver.find_element(:css, ".search-button").click
wait_for_ajax_complete(driver)
results = driver.find_elements(:css, ".result-row")
対象ページの作りに応じて、スピナー判定・jQuery判定・要素数安定判定のうちどれを使うか、あるいは組み合わせるかを選べるようにしておくと、複数のサイトを相手にするスクレイピングや自動テストでも同じヘルパーを使い回しやすくなります。判定条件が真になるまでポーリングするという考え方自体は、WebDriverWaitによる明示的待機の設計と同じものです。この基本的な考え方や、タイムアウト時のメッセージ設計についてはSeleniumの明示的待機を正しく設計する記事で詳しく解説しているので、あわせて参照してください。
まとめ
- Ajaxベースの画面では、サーバー側の応答時間が変動するため固定
sleepは待ちすぎと待ち不足の両方を引き起こす - ローディングスピナーが使われているページでは、
find_elementsでスピナー要素が0件になったことを待機条件にできる - jQueryを使っているサイトでは、
execute_scriptでjQuery.active === 0を監視すると通信状態を直接確認できる。jQuery未使用ページ向けのフォールバックも忘れずに用意する - スピナーもjQueryもない場合は、対象要素数が一定回数連続で変化しなくなったことを安定の目安にする方法がある
- 複数の判定条件は1つの待機ヘルパーにまとめておくと、サイトごとの違いを吸収しつつ呼び出し側をシンプルに保てる


コメント