センニジュウヨン

意味なんてない

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年ほど前のソースまでさかのぼってみたが、そんなことなかった。

*1:とおもったけど、下の方、タプル使わずに呼んでるけど良いのかな?

*2:名前の方を all じゃなくて、any にしろって issue 投げるべきだと思うが、色々めんどくさくて……。この記事書くのすらめんどいのに。

*3:この段落に関しては、ちょっと推測も入っている。一応 Selenium のソースも見た上で書いてるが、全部読んでるわけではないので、ページ全体の読み込みを待つマジックがある可能性も無くはない。でも今の時代、何を基準に読み込み終了を決められるだろうか。