クリックが例外を投げないことと、処理が成功したことは別物
Seleniumで何らかの「登録する」「実行する」といったアクションボタンを連打するタイプの自動化を書いていると、button.clickが例外を投げずに終わったので処理カウントを1件増やす、という実装にしてしまいがちです。しかし実際にはクリック自体はブラウザに届いているのに、裏側のAPIリクエストがレート制限や一時的なエラーで失敗し、画面上は何も変わっていない、というケースが少なからず発生します。
これに気づかずに「クリックした回数」をそのまま「成功した件数」として扱っていると、実行結果のログでは100件成功と表示されているのに、実際にサービス側で確認すると数十件しか反映されていない、という食い違いが起きます。原因はクリックの失敗ではなく、クリック後の非同期処理が失敗しているのに、こちらがそれを検知していないことにあります。
ボタンの見た目の変化を「成功の証拠」とする
対象にしているUIの多くは、アクションが実際に成功するとボタンのテキストやaria-labelが変化するようにできています。例えば「登録する」というボタンを押すと、処理が成功した場合だけ「登録済み」に変わる、といった具合です。この変化が起きて初めて「本当に処理が成功した」とみなすようにしました。
まず、ボタンの現在の状態をテキストとaria-labelの組み合わせで表現します。
def button_state(button)
{
text: (button.text rescue "").to_s.strip,
label: (button.attribute("aria-label") rescue "").to_s.strip
}
end
クリック前後でこの状態を比較し、どちらかが変化していれば「何らかの反応があった」と判定します。
def state_changed?(before, after)
before[:text] != after[:text] || before[:label] != after[:label]
end
クリック直後ではなく、変化するまでポーリングする
ここで注意が必要なのは、クリックした瞬間にボタンの状態を確認しても、まだ裏側の処理が終わっていないため変化前の状態のままのことが多い点です。非同期のリクエストが完了してDOMが更新されるまでには、数百ミリ秒から数秒のラグがあります。
そのため、クリック後は状態が変わるか、あるいはタイムアウトするまで短い間隔でポーリングします。
def wait_for_state_change(driver, button, before_state, timeout_seconds = 3.0, interval = 0.2)
deadline = Time.now + timeout_seconds
while Time.now < deadline
current_state = button_state(button)
return current_state if state_changed?(before_state, current_state)
sleep interval
end
button_state(button) # タイムアウト時点の状態をそのまま返す
rescue Selenium::WebDriver::Error::StaleElementReferenceError
before_state # DOM再描画で要素が失われた場合はクリック前の状態を返す
end
StaleElementReferenceErrorを明示的に拾っているのは、状態が変化する過程でボタン自体がDOMから作り直され、参照していた要素ハンドルが無効になることがあるためです。この場合は「要素は消えたが、それ自体は状態が変わった証拠にはならない」と割り切り、クリック前の状態を返してこの後の成否判定に委ねています。
クリック前後の状態から成否を判定する
実際の処理では、クリック前に「まだ処理が完了していない状態か」を確認し、クリック後に「完了した状態に変わったか」を突き合わせて、初めて成功としてカウントします。
def perform_action_and_count(driver, button)
before_state = button_state(button)
already_done = success_state?(before_state)
driver.execute_script("arguments[0].click();", button)
after_state = wait_for_state_change(driver, button, before_state)
became_done = state_changed?(before_state, after_state) && success_state?(after_state)
# すでに完了していたボタンではなく、今回のクリックで完了状態に変わった場合のみ成功
!already_done && became_done
end
def success_state?(state)
state[:text].include?("登録済み") || state[:label] == "登録済み"
end
ポイントは、単に「状態が変わったかどうか」だけでなく「クリック前が未完了で、クリック後に完了状態になったか」まで見ていることです。すでに完了済みのボタンをうっかりクリックしてしまった場合、状態は変化しない(あるいはエラーメッセージ表示などで別の変化をする)ため、これを成功として二重カウントしないようにできます。
タイムアウト設計とクリック数とのズレ
ポーリングのタイムアウトをどう設定するかは、対象サービスの応答速度に依存します。短すぎると、実際には成功しているのにタイムアウトしてしまい「失敗」として扱われ、実績よりも成功件数を過小評価してしまいます。逆に長すぎると、大量のボタンを処理するときに全体の実行時間が伸びてしまいます。
今回は経験的に3秒・0.2秒間隔のポーリングに落ち着きましたが、対象によっては非同期処理が数秒かかることもあるため、まずはログを見ながら「何秒待てば大半のケースで状態変化が確認できるか」を実測し、そこに余裕を持たせた値を設定するのがおすすめです。
また、状態変化の確認でエラーが起きた場合に「クリックは成功したとみなす」という従来通りのフォールバック動作にするか、「確認できなかったので失敗扱いにする」という保守的な動作にするかは、用途によって選択が分かれます。件数の正確さを重視するなら後者、処理を止めないことを優先するなら前者、というトレードオフになります。
まとめ
- Seleniumで
clickが例外を投げなかったことは、裏側の処理が成功した証拠にはならない - ボタンのテキストや
aria-labelなど、画面上の状態変化をポーリングして確認して初めて「成功」とカウントする - クリック直後は非同期処理が終わっていないことが多いため、変化するかタイムアウトするまで短い間隔でポーリングする
- 「クリック前は未完了、クリック後に完了状態」という前後の突き合わせをすることで、すでに完了しているボタンの誤クリックを二重カウントしない
- タイムアウトは対象サービスの応答速度に合わせて実測し、成功件数の過小評価と実行時間の増加のバランスを取る


コメント