Selenium の待機について誤りが蔓延している
最初、「Selenium の日本語記事はたいてい間違っている」……などと怒りに任せたタイトルにしようとしたが、大げさすぎるのでやめた。
正確には、Selenium WebDriver の Python 版の日本語での解説記事などで、待機処理に関して、ある誤りが蔓延している、ということ。
具体的には、明示的な待機の文脈でよく目にする
- presence_of_all_elements_located
- visibility_of_all_elements_located
の2つについて、使い方が根本的に間違っており、 正しい使い方をしたところで意図した結果にはならない。
↓Selenium WebDriver www.selenium.dev
間違い例
以下のように書かれたページが5個以上あった
# ページ上のすべての要素が読み込まれるまで待機
wait.until(EC.presence_of_all_elements_located)
visibility_... の方は presence_... よりは少なそうだが、こちらも複数見つけた。
# ページの全要素が見えるまで待機
wait.until(EC.visibility_of_all_elements_located)
自分もそれらのページを見て、「ふーん、そーなんだー」と、おまじないのように自分のプログラムに書いていた。
なにが間違いか
呼び方
API ドキュメントを見れば明らかだが、
selenium.webdriver.support.expected_conditions.presence_of_all_elements_located(locator)
selenium.webdriver.support.expected_conditions.visibility_of_all_elements_located(locator)
一番右側の方に書いてある通り、インターフェースとしては引数として locator が必要であり、
wait.until(EC.presence_of_all_elements_located(locator)) wait.until(EC.visibility_of_all_elements_located(locator))
というような呼び方が正しい。locator というのは、(By.ID, "ID") みたいなやつ。
誤りを流布しているサイトでも、同様のインターフェースである、all がつかないものは普通に正しい使い方で呼んでいるので、違和感持って良いところではある。
↓誤り流布サイトでの、他の正しそう*1な呼び出し (多少改変してます)
# CLASS名指定した要素が読み込まれるまで待機 wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'class_name'))) # ID指定したページ上の要素が読み込まれるまで待機 wait.until(EC.presence_of_element_located(By.ID, "ID"))
挙動の解釈
なんだ、正しく呼べばいいだけか、と思ったらそんなことはない。
正しく呼ぶとは何なのか?
ページの全要素を示すロケータってのを指定するのか?なにそれ?
→そもそもページ上の全要素なんてことはなかったです
API ドキュメントを読めば明らかだが、
presence_of_all_element_located についての記載
An expectation for checking that there is at least one element present on a web page. locator is used to find the element returns the list of WebElements once they are located
注目すべきは、"at least one element" の部分。全てとは……*2
その点、visibility_... の方は ……
visibility_of_all_elements_located についての記載
An expectation for checking that all elements are present on the DOM of a page and visible. Visibility means that the elements are not only displayed but also has a height and width that is greater than 0. locator - used to find the elements returns the list of WebElements once they are located and visible all elements
"all elements" と書いてあるから安心!
……とはおそらくならない。(ロケーターも指定して要素限定するし……)
presence_... の方も visibility_... の方も、wait の検査時点で DOM 内にあるものだけが対象で、そのなかでロケーターに合う要素だけを待つだろう。 そして、presence_... は一つでもロケーターに引っかかれば即 wait 終了。 visibility_... は検出したすべての要素が「可視」になるまでちゃんと待つが、wait を抜けた瞬間にロケーターが対象とする要素が「不可視」で DOM に追加されてるかもしれない。 *3
こういった挙動が、はじめに示した例にあるコメント……
ページ上のすべての要素が読み込まれるまで待機
ページの全要素が見えるまで待機
これら達成できるかといえば、まったくできなそうでしょ。
結局、ページの全要素の存在や「可視」を待てるわけではなさそうなので、 必要な要素を絞って、個別に wait させるしかないんでしょうね。
どう動いていたか
間違ってたのは理解した、でも今まで書いてたあれはどう動いてたのさ?
ソースを見れば明らかだが、結論から言うと、全く Wait していなかった。
presence_of_all_elements_located を含む現時点での最新ソース
↓一部だけ抜粋したもの
def presence_of_all_elements_located(locator): def _predicate(driver): return driver.find_elements(*locator) return _predicate def visibility_of_all_elements_located(locator): def _predicate(driver): try: elements = driver.find_elements(*locator) for element in elements: if _element_if_visible(element, visibility=False): return False return elements except InvalidSelectorException as e: raise e except StaleElementReferenceException: return False return _predicate
詳細はともかく、どちらも関数内で定義した _predicate() という関数を返しますね。
では、これを渡す先 である wait.until() の方のソースも見てみましょう。
until() を含む現時点での最新ソース
↓until() 部分のコードだけ抜粋したもの
def until(self, method, message=''): screen = None stacktrace = None end_time = time.time() + self._timeout while True: try: value = method(self._driver) if value: return value except InvalidSelectorException as e: raise e except self._ignored_exceptions as exc: screen = getattr(exc, 'screen', None) stacktrace = getattr(exc, 'stacktrace', None) time.sleep(self._poll) if time.time() > end_time: break raise TimeoutException(message, screen, stacktrace)
で、もともとの呼び出し方を思い出してみると、
wait.until(EC.presence_of_all_elements_located)
関数を返す関数である presence_... をそのまま until() に渡しています。 それが until の中でどう扱われているかというと、
value = method(self._driver)
if value:
retu**rn value
method と名前を変えて、while ループ内で呼ばれています。 関数を返す関数ですから、value には _predicate() 関数が入ります。
そして直後の if value ……。
悪い予感しかしませんね。ここまでくれば予感でもなんでも無いですが。
一応 Python 公式ドキュメントに当たると、 **
オブジェクトは、デフォルトでは真と判定されます
とのことで、関数についての明確な言及はないけど、 関数もオブジェクトだし、特に偽ともされていないので、真なのでしょう。
念の為、REPL でも確認。
Python 3.10.1 (tags/v3.10.1:2cd268a, Dec 6 2021, 19:10:37) [MSC v.1929 64 bit (AMD64)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> def hoge():return False ... >>> bool(hoge) True >>>
やはり、関数は True のようですね。
……ということで、
until() に渡された、関数を返す関数は、until() 内で関数を返し、if で True と評価され、ループを一周も回ること無く関数を抜けるのであった。完
ちなみに、until() が _predicate() を return しているので、使ってみると……
_predicate=wait.until(EC.presence_of_all_elements_located) _predicate(driver)
怒られた
TypeError: selenium.webdriver.remote.webdriver.WebDriver.find_elements() argument after * must be an iterable, not WebDriver
driver をロケーターのつもりで展開しようとして、できないというもの。そうでしょうね。
過去には正しかったか
もしかして、昔はその表記で正しかったりしたのかな? と、10年ほど前のソースまでさかのぼってみたが、そんなことなかった。