Selenium for Python 要素の状態変化のための待機と取得に関して統一的な方法を模索する

# はじめに

Seleniumには、要素の状態変化を調べるのに`expected_conditions`が用意されています。
しかし、

- `enabled/disabled`の状態変化を調べるものが用意されていない
- `clickable`の状態変化を調べる関数は、`locator`を渡すタイプのものしか用意されていない
- XPathで要素を取得した場合、ある要素の相対パスで表される要素の状態変化を調べる場合にどうしたらよいか分からない

など使いづらい面が多くあります。
これらを何とかしようというのがこの記事の目的です。

# 想定読者

取り合えずseleniumを一通り使ったことある方を対象としています。
かと言って上級者向けでもありません。
XPATHや`find_element()`などが何か分かっている方向けです。

# 環境

Python 3.8.3
selenium 3.141.0
geckodriver v0.26.0
Firefox 77.0.1 (64 ビット)

# 結果のソース

取り合えず説明もなしに結果のソースだけ表示します。(Pythonなのにsnake_caseではなくてcamelCaseなので石を投げられそうですが)
自分では、使っていない条件分岐もありますので完全にテストが出来ているわけではありません。

また、この記事では下記のソースに示されるモジュールを`import`している前提で進めていきます。

```python:python
import logging

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import (UnexpectedAlertPresentException, NoAlertPresentException,
                                        ElementNotVisibleException, TimeoutException, NoSuchElementException)

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())

class CheckState():

  def __init__(self, locator=(), element=None, state="enabled"):
    self.locator = locator
    self.element = element
    self.state = state

  def __call__(self, driver):
    try:
      if self.element is not None and self.locator == ():
        element = self.element
      elif self.element is not None and self.locator != ():
        element = self.element.find_element(*self.locator)
      elif self.locator != ():
        element = driver.find_element(*self.locator)
      else:
        return False

      if self.state == "enabled":
        return element if element.is_enabled() == True else False
      elif self.state == "disabled":
        return element if element.is_enabled() == False else False
      elif self.state == "selected":
        return element if element.is_selected() == True else False
      elif self.state == "unselected":
        return element if element.is_selected() == False else False
      elif self.state == "displayed":
        return element if element.is_displayed() == True else False
      elif self.state == "undisplayed":
        return element if element.is_displayed() == False else False
      elif self.state == "clickable":
        if element.is_enabled() == False:
          return False
        return element if element.is_displayed() == True else False
      else:
        return False
    except Exception as e:
      logger.debug(f"CheckState: {type(e)}, {e}, {self.locator}, {self.element}, {self.state}")
      return False


def findElement(driver, locator=(), element=None, state="enabled", must=True, wait=30, interval=0.5, ignore=False):
  try:
    if element is None and locator == ():
      raise ValueError

    driverWait = WebDriverWait(driver, wait, interval)
    return driverWait.until(CheckState(locator=locator, element=element, state=state))

  except TimeoutException:
    if must == True and ignore == False:
      logger.error(f"findElement: {locator}, {element}, {state}, {must}, {wait}, {interval}, {ignore}")
      raise ValueError
    return None
  except Exception as e:
    if ignore == True:
      return None
    logger.error(f"findElement: {type(e)}, {e}")
    raise e


def isDriver(driver):
  if isinstance(driver, webdriver.remote.webdriver.WebDriver):
    return True
  return False
```

# 要素の現在の状態を調べる関数

例えば、
`element = driver.find_element(by, value)`
などで要素を取得した場合、

- `element.is_enabled()`: `enabled`かどうか
- `element.is_displayed()`: 画面に表示されているかどうか
- `element.is_selected()`: 選択されているかどうか
- `element.get_attribute(name)`: attributeもしくはpropertyを得る
- `element.get_property(name)`: propertyを得る
- `element.value_of_css_property(property_name)`: CSS propertyの値を得る

などで状態を調べる事が出来ます。
この記事では、一番単純な`element.is_enabled()`、`element.is_displayed()`、`element.is_selected()`について考えていきたいと思います。

# `expected_conditions`で状態変化を調べる関数

`expected_conditions`では、

- `element.is_displayed()`
- `element.is_selected()`

の状態変化を調べるのに以下の関数が用意されています。

- `EC.element_to_be_selected(element)`: `element`が`is_selected() == True`かどうか
- `EC.element_located_to_be_selected(locator)`: `locator`で示される要素が`is_selected() == True`かどうか
- `EC.element_selection_state_to_be(element, is_selected)`: `element`が`is_selected() == is_selected`かどうか
- `EC.element_located_selection_state_to_be(locator, is_selected)`: `is_selected() == is_selected`かどうか
- `EC.visibility_of(element)`: `element`が`is_displayed() == True`かどうか
- `EC.visibility_of_element_located(locator)`: `locator`で示される要素が`is_displayed() == True`かどうか
- `EC.invisibility_of_element(element)`: `element`が`is_displayed() == False`かどうか
- `EC.invisibility_of_element_located(locator)`: `locator`で示される要素が`is_displayed() == False`かどうか

ここでの引数は、

- `element`:`element = driver.find_element(By.XPATH, "//div[@class='cat']")`
などで取得した要素`element`を渡します。
- `locator`: `(By.XPATH, "//div[@class='cat']")`などを渡します。
- `is_selected`: 選択状態を検出したいならば`True`を、そうでないならば`False`を渡します。

となります。
一方で、

- `element.is_enabled()`

だけを調べる関数は、用意されておらず代用できるのは、

- `expected_conditions.element_to_be_clickable(locator)`

となります。
しかし、`clickable`は、`element.is_enabled() and element.is_displayed()`を調べているので 目的に合わないこともしばしばです。

## 使い方

一般的に、`expected_conditions`の関数は、

```python:python
driver = webdriver.Firefox(executable_path=path, options=options, service_log_path="nul")
driver.get(url)

locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30, 1).until(EC.visibility_of_element_located(locator))
```

のように`WebDriverWait().until()`などと組み合わせて使用します。
これをもう少し詳しく見ていきます。

### Expected conditions

まず、`EC.visibility_of_element_located()`です。
これは、以下のように実行すると、

```python:python
func = EC.visibility_of_element_located(locator)
element = func(driver)
```

`func`には、引数を1つ(`driver`を想定)取る関数が返されます。
そして、`func`に`driver`を渡して実行すると、`locator`で示される要素が`displayed`ならばその要素を、`displayed`でないならば`False`が返されます。
つまり、`func`は、失敗しても例外を投げない`find_element(locator)`ような関数となります。

また、`find_element()`は、以下のようにすると、

```python:python
relativeLocator = (By.XPATH, "./div[@class='meow']") # 相対パス
child = element.find_element(*relativeLocator)
```

`element`配下の要素(`child`)を取得することも出来ます。
`EC.element_to_be_clickable`で同じような事をしようとすると、以下のようになります.

```python:python
child = EC.visibility_of_element_located(relativeLocator)(element)
```

他の`expected_conditions`の関数も同じように相対パスの要素を取得できるようです。
ただ、(見つけられた)説明を読んでみると`driver`からの絶対パスを想定しているようです。
GitHubでソースを見てみると問題ないようですが、少し不安です。
そのため、相対パスを扱う場合には何かほかの手段も用意したいところです。

### WebDriverWait

少し戻って`WebDriverWait().until()`も見ていきます。

`WebDriverWait()`は、引数として以下のものを取ります(1つ省略)。

- `driver`: `driver`を想定
- `timeout`: 最大待機時間
- `poll_frequency`: 試行間隔

そして、`wait.until()`は、以下で説明される引数を1つ取ります。

- `method`: 引数として`driver`を1つとる関数。この関数は、失敗したら`False`を返し、成功したら`False`以外を返すもの

以下のように記述した場合、

```python:python
timeout = 30
poll_frequency = 1
wait = WebDriverWait(driver, timeout, poll_frequency)
element = wait.until(method)
```

`wait.until(method)`の動作は、

1. `method`には、`driver`変数の内容(通常`driver`インスタンスを想定される)が渡される。
2. `method(driver)`が成功する(`False`以外を返す)まで、1秒間隔で最大30秒の間実行し続ける。
3. `method(driver)`が成功したらその戻り値が返される。
4. 30秒経っても成功しなければ例外が投げられる。

となります。

### 組み合わせ

以上の説明から、下記のように記述した場合、

```python:python
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30).until(EC.visibility_of_element_located(locator))
```

`locator`が示す要素が存在し`displayed`であるならばその要素が`element`に代入されます。
もし、30秒経っても要素が見つからないもしくは`displayed`にならない場合は、例外が投げられます。
お気づきだと思いますが、以下のようにすると`element`からの相対要素を取得できます。

```python:python
relativeLocator = (By.XPATH, "./div[@class='meow']") # 相対パス
child = WebDriverWait(element, 30).until(EC.visibility_of_element_located(relativeLocator))
```

# `enabled`/`disabled`に対応させる

`expected_conditions`には、`enabled`(`disabled`)に対応した関数が用意されていませんが対応したものは簡単に作ることができます。
`WebDriverWait().until()`から呼び出されるのを前提に考えると

- 引数として`driver`を1つとる関数。この関数は、失敗したら`False`を返し、成功したら`False`以外を返すもの

という関数を作ればよいことが分かります。
ただ関数だと、大域変数を使うなどしない限り`driver`以外が渡せないことになりますので`Class`を作ることになります。
一番単純に作ると、以下のようになります。

```python:python
class IsEnabled():

  def __init__(self, locator=(), state=True):
    self.locator = locator
    self.state = state

  def __call__(self, driver):
    try:
      if self.locator == ():
        return False
      element = driver.find_element(*self.locator)
      return element if element.is_enabled() == self.state else False

    except Exception as e:
      return False
```

これは、以下のように使えます。

```python:python
locator = (By.XPATH, "//div[@id='cat']")
element = WebDriverWait(driver, 30, 1).until(IsEnabled(locator))
```

`expected_conditions`には、`locator`を取るタイプと`element`を取るタイプが存在しています。
しかし、作成したものは、`locator`を指定しなければいけません。そして相対パスにも対応していません。
これらにも対応できるように改良してみます。

```python:python
class IsEnabled():

  def __init__(self, locator=(), element=None, state=True):
    self.locator = locator
    self.element = element
    self.state = state

  def __call__(self, driver):
    try:
      if self.element is not None and self.locator == ():
        element = self.element
      elif self.element is not None and self.locator != ():
        element = self.element.find_element(*self.locator)
      elif self.locator != ():
        element = driver.find_element(*self.locator)
      else:
        return False

      return element if element.is_enabled() == state else False

    except Exception as e:
      return False
```

こうすることで、以下のように使えるようになります。

```python:python
# locatorが示す要素をenableになったら取得する
element = WebDriverWait(driver, 30, 1).until(IsEnabled(locator=locator))

# elementをenableになったらelementを返す
element = WebDriverWait(driver, 30, 1).until(IsEnabled(element=element))

# elementからの相対要素がenableになったら取得する
child = WebDriverWait(driver, 30, 1).until(IsEnabled(element=element, locator=relativeLocator))
```

更に別の状態にも対応させたのが最初に示した`CheckState`になります。

# `find_element()`に変わる関数を用意する
これで`find_element()`のように、同じ記述で要素を取得できるようになりました。
その上、こちらは状態の変化を見ながら取得できます。
ただ、毎回以下のように書くのも面倒ですし、`find_element()`と併用すると混乱のもとになりかねません。

```python:python
element = WebDriverWait(driver, 30, 1).until(CheckState(element=element, state="clickable"))
```

そこで`find_element()`変わるラッパー関数を定義します。
個人的には、関数の名前から要素を取得しているように思えないのもあります。

あまりラッパー関数を作りすぎて何を使っているか分からなくなるのも問題だとは思いますが。

```python:python
def findElement(driver, locator=(), element=None, state="enabled", must=True, wait=30, interval=0.5, ignore=False):
  try:
    if element is None and locator == ():
      raise ValueError

    driverWait = WebDriverWait(driver, wait, interval)
    return driverWait.until(CheckState(locator=locator, element=element, state=state))

  except TimeoutException:
    if must == True and ignore == False:
      logger.error(f"findElement: {locator}, {element}, {state}, {must}, {wait}, {interval}, {ignore}")
      raise ValueError
    return None
  except Exception as e:
    if ignore == True:
      return None
    logger.error(f"findElement: {type(e)}, {e}")
    raise e
```

これは、以下のように使います。

```python:python
locator = (By.XPATH, "//div[@id='cat']")
element = findElement(driver, locator=locator):
```

# 最後に

これで、すっきりと記述できるようになったかと思います。
ただ、サイトの挙動によっては、色々と調整する必要があったりするのが難点です。
今までうまく行っていても、サイトの反応が鈍くて思わぬところで躓いたりするのも良くあります。
結局、`time.sleep()`が手放せなかったりします
自分が下手なだけという話もありますが(´・ω・`)