固定sleepが遅い、そして不安定な理由
Seleniumのスクリプトを書き始めたころ、要素が表示されるのを待つためにsleep(3)をあちこちに埋め込んでしまうことがあります。動くには動くのですが、この書き方には両方向のコストがあります。
対象のページが普段より軽く、1秒で描画が終わる状況でも律儀に3秒待ってしまうため、大量のページを処理するバッチ処理では待ち時間の合計がそのまま実行時間に跳ね返ります。逆に、ネットワークが混雑していたり裏側のAPIが重くなっていたりして描画に4秒かかる日には、3秒のsleepでは足りずNoSuchElementErrorで落ちます。同じコードなのに、実行するタイミングによって成功したり失敗したりする、いわゆる「フレーキーテスト」の典型的な原因の一つです。
固定sleepは「どれくらい待てば十分か」を実行前に決め打ちしている点が根本的な問題です。本来待ちたいのは秒数ではなく、「要素が出現した」「クリックできる状態になった」といった具体的な条件のはずです。これをそのままコードにできるのが、Seleniumの明示的待機(WebDriverWait)です。
WebDriverWaitの基本:条件が真になるまでuntilで待つ
Selenium::WebDriver::Waitは、渡されたブロックが真になる値を返すまで、指定した間隔でポーリングを繰り返してくれるオブジェクトです。タイムアウトするまでの間、ブロック内で発生するNoSuchElementErrorはデフォルトで無視されるため、「まだ要素が見つからない」という状態をそのままリトライの材料にできます。
require "selenium-webdriver"
driver = Selenium::WebDriver.for :chrome
wait = Selenium::WebDriver::Wait.new(timeout: 10, interval: 0.5)
要素の出現を待つ
element = wait.until do
driver.find_element(:css, ".result-item")
end
クリック可能になるまで待つ
「存在する」ことと「クリックできる」ことは別物です。表示されていて、かつenabledであることまで確認してから返すブロックにします。
button = wait.until do
el = driver.find_element(:css, ".submit-button")
el if el.displayed? && el.enabled?
end
button.click
テキストの変化を待つ
クリック後の非同期処理の完了を、表示テキストの変化で確認したい場合も同じ形で書けます。
before_text = driver.find_element(:css, ".status").text
wait.until do
driver.find_element(:css, ".status").text != before_text
end
いずれも「何秒待つか」ではなく「何が成り立てば次に進んでよいか」を書いている点が、固定sleepとの決定的な違いです。条件が早く満たされればすぐ次に進みますし、多少時間がかかってもtimeoutの範囲内なら待ち続けてくれます。
標準条件で足りないときの自作待機条件
WebDriverWait#untilはブロックが真を返せば何でも受け付けるので、Seleniumの標準的な条件(要素の出現やクリック可能状態)に収まらない、独自の完了条件を書くのにもそのまま使えます。ただし、複数箇所で同じような待機処理を書くようになってきたら、共通のポーリング関数として切り出しておくと見通しが良くなります。
def wait_until(timeout: 10, interval: 0.5, ignore: [], description: nil)
deadline = Time.now + timeout
last_error = nil
loop do
begin
result = yield
return result if result
rescue *ignore => e
last_error = e
end
if Time.now > deadline
raise Selenium::WebDriver::Error::TimeoutError, timeout_message(description, last_error)
end
sleep interval
end
end
このwait_untilは、ブロックがtruthyな値を返すまで待つという意味ではWebDriverWaitと同じですが、ignoreに渡した例外だけを黙って握りつぶしてリトライできる点、そしてタイムアウト時のメッセージを自分で組み立てられる点が独自の部分です。例えば、一覧に表示される件数が期待値に達するまで待つ、といった条件はこう書けます。
wait_until(
timeout: 15,
interval: 0.5,
ignore: [Selenium::WebDriver::Error::StaleElementReferenceError],
description: "一覧の行数が20件になる"
) do
driver.find_elements(:css, ".row").size >= 20
end
StaleElementReferenceErrorをignoreに入れているのは、一覧が再描画される途中で古い要素参照が無効になる瞬間があるためです。「今回はこの例外だけは失敗ではなく再試行の合図として扱う」という判断を、呼び出し側で明示的に書けるのがこの形の利点です。
タイムアウト時に「何を待っていたか」がわかるメッセージ設計
WebDriverWaitがそのままタイムアウトすると、既定のエラーメッセージは「真の値が返らないまま指定秒数が経過した」という程度の情報しか持っていません。ログにこれだけ出ても、どのページのどの要素を待っていて、直前にどんなエラーが起きていたのかは分からず、原因調査に時間がかかります。
Selenium::WebDriver::Wait.newにはmessage:オプションがあり、ここに「何を待っていたか」を渡しておくだけでも診断はかなり楽になります。
wait = Selenium::WebDriver::Wait.new(
timeout: 10,
interval: 0.5,
message: "送信ボタンがクリック可能になるのを待っていました"
)
先ほどの自作wait_untilでは、これをさらに一歩進めて、直前に握りつぶした例外の情報も一緒に埋め込んでいます。
def timeout_message(description, last_error)
detail = last_error ? "(直前のエラー: #{last_error.class}: #{last_error.message})" : ""
["#{description}がタイムアウトしました", detail].reject(&:empty?).join(" ")
end
こうしておくと、タイムアウト例外のメッセージだけで「何を待っていたか」と「その過程でどんな例外が起き続けていたか」の両方が分かります。呼び出し側でこれをそのまま握りつぶさず、ページのURLや直前のスクリーンショットパスなど、原因調査に使える情報を添えてログに出しておくと、後から見返したときの手がかりがさらに増えます。
なお、「変化するまで待つ」という考え方そのものは、クリックの成否をボタンの状態変化から判定する場面にも応用できます。その具体的な実装はクリックの成否をボタンの状態変化で判定する話で扱っているので、あわせて参照してください。
まとめ
- 固定
sleepは「待つべき秒数」を実行前に決め打ちするため、環境が軽ければ無駄に遅く、重ければタイムアウトして不安定になる Selenium::WebDriver::Wait#untilは、ブロックが真を返すまでポーリングしてくれるため、「何秒待つか」ではなく「何が成り立てば進んでよいか」を書ける- 要素の出現・クリック可能状態・テキスト変化はいずれも同じ
untilブロックの形で表現できる - 標準条件で足りない場合は、無視したい例外とタイムアウトメッセージを指定できる自作の
wait_untilを用意しておくと、複雑な完了条件も同じ形で扱える - タイムアウト時のメッセージに「何を待っていたか」と「直前の例外」を含めておくと、原因調査にかかる時間を大きく減らせる


コメント