Selenium Python

SeleniumはWebアプリケーションをE2Eテストするフレームワークです。
ブラウザをスクリプト操作することで、実機の利用環境に近いテストを実装できます。Javascriptのみのインテグレーションテストについては、 deno testがWebAssemblyやWeb Workersなどを含む高度なブラウザ標準もサポートして選択肢が増えましたが、Seleniumは適切なレンダリングをテストできる希少な手段と言えます。

前提環境

実行環境は、 Docker Hubのseleniumイメージを利用するのが手軽です。

standalone-chromeなどのstandalone-イメージに単体動作用のパッケージが完結しています。
ただし、テストコードを動作させる各言語バインディングは追加インストールする必要があります。たとえば、Pythonとそのseleniumライブラリといった組み合わせです。

なお、seleniumイメージはseleniumプロジェクトが配布するイメージではありますが、Docker Officialではありません。
品質上の懸念は少ないものの、Docker Hubが配信数に上限をセットするため利用できない場合があることには注意が必要です。

テスト対象サーバー

テスト対象のアプリケーションは、seleniumランタイムとは別に用意する必要があります。
対象URLはテストコードに記述するため、任意の構成が可能です。

kubernetesやDockerコンテナを活用する場合には、同一pod内のサイドカー構成にすると、とくにネットワーク構成することなくlocalhostとしてアクセスできて手軽です。

ポートはサーバープロセスがListenしているポートを指定します。

PythonのSeleniumテスト

Seleniumのテストコードはブラウザ操作を記述するもので、テスト対象のコードと同じ言語である必要はありません。
サポート言語のうち、PythonやRuby、Javascriptを利用するとテストコードはコンパイル不要で動作します。

Pythonの場合、 pytestの規約に沿って実装します。
共通機能はconftest.pyに記述できます。Seleniumの場合、以下のようにWebDriverインスタンスをフィクスチャに登録しておくと便利です。

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

@pytest.fixture(autouse=True)
def driver():
    options = Options()
    options.add_argument('--headless')
    options.add_argument('--disable-gpu')
    d = webdriver.Chrome(options=options)
    yield d
    d.quit()

この例のようにChromeを--headless--disable-gpuのオプションセットで起動する場合には、Xは不要です。SeleniumHQがビルドしているコンテナイメージは、環境変数SE_START_SVFB=falseをセットすることでXの起動を抑制できます。
動作している様子は描画されませんが、スクリーンショットは保存できます。

クッキー操作

状態の異なるテストを実行したい場合、クッキーの値に応じて挙動を変えるテストサーバーを実装できます。
Seleniumにはadd_cookie()メソッドで任意のクッキーをセットできます。

    driver.add_cookie({'name' : 'role', 'value' : 'valid_user'})

Javascriptログ

get_log('browser')メソッドでJavascriptのコンソールログを取得できます。ログの配列が返り、何も出力していなければ[]になります。

    logs = driver.get_log('browser')
    assert logs == []

また、フィクスチャでprint()も可能です。正常終了した場合にはフィルタされ、エラー時のJavascriptログを取得できます。

@pytest.fixture(autouse=True)
def driver():
    options = Options()
    options.add_argument('--headless')
    d = webdriver.Chrome(options=options)
    yield d
    logs = d.get_log('browser')
    print(*d, sep='\n')
    d.quit()

統合テストでは、エラーの詳細が分かりにくいケースが多くエラー時のログを増やすと便利です。
同様にスクリーンショットもフィクスチャで取得できるでしょう。

表示位置を変更する

画面外の要素やオーバーレイ要素に隠れたものは操作できないため、スクロールで表示位置を変える必要があります。

JavascriptのscrollIntoView()メソッドを利用できます。

    driver.execute_script("document.querySelector('#some-elem').scrollIntoView()")

aタグが機能しない場合

基本的にaタグのリンクは.click()メソッドで機能しますが、URLが不適切な場合に意図どおりに動作しません。
URLを動的に生成している場合に、パス区切りが重複して//が含まれるとスキームとドメインの区切り記号と解釈する挙動などがあります。

get_attribute("href")でリンク文字列を取得できるので、リンクが動作しないときチェックすると切り分けやすくなります。

DOM描画待ちのイディオム

各ページのアクセス直後など、レンタリング完了を待つ必要がある場合には、WebDriverWait()で一定時間DOMを監視できます。
importと該当箇所のイディオムは以下のとおりです。

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


    el = WebDriverWait(driver, 5).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, '.button'))
            )
    el.click()

期待どおりに描画される場合には要素を返すため、後続処理でクリックなどの操作に利用できます。
なおクリック操作を想定する場合にはEC.element_to_be_clickable()の方が動作が安定します。

非表示の要素やレンダリングが崩れている場合にはクリック操作は失敗します。EC.element_to_be_clickable()が成立していてもclick()が失敗する挙動はあり得ます。

たとえば、同じ位置にオーバーレイ要素が表示されている場合には、エラーログにその要素が記録されます。また、disabled状態のコンポーネントのようにクリック対象が動作しない場合には、イベントチェーンをバブルアップしてエラーログに親要素が記録されます。

disabled属性についてはbutton:not([disabled])のようにCSSセレクタで除外する手があります。

sleep

要素の状態ではなく指定秒数のウエイトをおく場合には、Pythonのtime.sleep()を使えます。
複数のAPIコールを待つ必要があるケースなどでは、UI状態で判定できないケースがあるため使い分けます。

ただし、処理にどの程度の時間がかかるかは実行環境依存であり、sleep()にはたまたま動作しやすくする効果しかありません。

確実な動作のためには、DOM待ちの処理を実装することが第一です。タイミングに依存するケースはflakyになりやすいため、最少限にとどめます。

ShadowDOMのイディオム

Web Componentsを利用する場合、内部要素はShadowDOM内に描画され、driverから直接アクセスできません。

以下のように、ホストDOMの.shadow_rootプロパティを対象に探索すると取得できます。

    shadow_host = WebDriverWait(driver, 5).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, '#dialog'))
    )
    shadow_root = shadow_host.shadow_root
    el = WebDriverWait(shadow_root, 5).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, '#ok_button'))
    )
    el.click()

ShadowDOMの分かりづらいポイントとして、slot内の要素はLightDOMに配置される点があります。
Developerツールで確認するとShadowDOM内の要素とLightDOMを切りわけられます。

コンポーネントの状態変化

Javascriptで開発したUIもSeleniumでテストできます。
ただし、ReduxなどのJavascriptフレームワークが保持している状態には直接アクセスできず、HTMLのid属性やclass属性を操作してチェックする必要があります。

また、Seleniumの問題ではなくJavascriptのUIライブラリの問題なのですが、多くの場合、DOMを直接操作するようなコードは使えず、コンポーネントのステート操作を経由する必要があります。

import {html, LitElement} from 'lit';
import {customElement, state} from 'lit/decorators.js';

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  @state() _changed = false;

  render() {
    return html`<p
        @click=${_e => this.changeHandler()}
        class="${this._changed ? 'changed' : ''}"
      >Hello!</p>`;
  }

  changeHandler () {
    // DOMを直接変更する方法は機能しない
    // document.querySelector('p').classList.add('changed');

    // UIコンポーネントのステート経由で描画させる必要がある
    this._changed = true;
  }
}

多くのUIライブラリは内部状態に応じてリアクティブに再描画する挙動をとります。
DOMの直接操作は描画されるのですが、他の状態が変化した際の再レンダリングによって簡単に打ち消されます。

再レンダリングがどのように動作するかはコード上には表現されないため、DOM操作を確実に制御する方法はありません。
書き方を誤ると分かりづらいバグの原因となります。

操作に失敗するflakyパターン

JavascriptのリアクティブUIコンポーネントをテストする場合、ロジック上のバグがなくてもクリックなどの操作に失敗するケースがあります。

典型的なパターンはfind_element().click()のイディオムで、find_element()でSeleniumがつかんだDOMがclick()するまでのわずかな間の再描画で失われる挙動です。
場合によっては、人間の操作では問題がなくとも、Seleniumには絶対に押せないボタンになります。

リアクティブな再描画はコード上に表現されておらず、レース挙動の制御もできないため確実に防ぐ方法はありません。
とくにReactの関数コンポーネントは過レンダリングを防ぐ機構が貧弱であるため、制御不能になりやすいでしょう。

このような不安定なテストケースがあると、テストスイートのうち何か1つのケースがErrorやFailに遭遇すると、他のケースも連鎖的にFailする事象も起きます。
原則として各ケースは隔離されているのですが、flakyなケースが含まれる場合、完璧に動作しない展開がよく起きます。

この問題は、リアクティブUIコンポーネントに潜在している機能不足からくるもので、テストツール側で改善は見込めません。

⁋ 2022/07/14↻ 2024/12/18
中馬崇尋
Chuma Takahiro