自動テスト入門:必須ツールSeleniumの基本と仕組み

はじめに

近年、IT業界の発展に伴い、ソフトウェアテスト人材の需要はますます高まっています。多くの人がテスト分野への参入を検討していますが、その中でも収入面で魅力のあるのが自動化テストです。本記事では、自動化テストに不可欠なツールであるSeleniumについて基本からその仕組みまで解説します。

SeleniumはWebアプリケーション向けのオープンソーステストツールで、ブラウザ上で直接実行されます。Firefox、IE、Chromeなどの主要ブラウザをサポートし、Java、Pythonなどの主流言語にも対応しています。

フレームワークの基盤ではJavaを使用し、実際のユーザーがブラウザを操作するのを模倣します。テストスクリプト実行時、ブラウザはスクリプトコードに従ってクリック、入力、開く、検証などの操作を自動的に実行し、エンドユーザーの視点でアプリケーションをテストします。Seleniumテストはブラウザ上で直接実行され、まるで実際のユーザーが操作しているかのように動作します。

Seleniumとは

SeleniumはWebアプリケーションの自動化テストツールです。Seleniumテストはブラウザ上で直接実行され、実際のユーザーが操作しているかのように動作します。サポートされているブラウザには、IE(7, 8, 9, 10, 11)、Mozilla Firefox、Safari、Google Chrome、Operaなどがあります。

主な機能には以下のものがあります:

  • ブラウザ互換性のテスト - アプリケーションが異なるブラウザとオペレーティングシステム上で適切に動作するかを確認します。
  • システム機能のテスト - 回帰テストを作成し、ソフトウェア機能とユーザー要件を検証します。
  • 自動録画機能と自動スクリプト生成 - .Net、Java、Perlなど異なる言語のテストスクリプトを自動生成します(主にSelenium IDE向け)。

Seleniumの沿革

2004年、Selenium Coreが誕生しました。Selenium CoreはブラウザベースでJavaScript言語を使用するテストツールで、ブラウザのセキュリティサンドボックス内で実行されます。その設計思想は、テスト対象製品、Selenium Core、テストスクリプトをすべて同じサーバーにデプロイして自動化テストを実行するというものでした。

2005年、Selenium RCが登場し、これがSelenium 1に相当します。この時点で、Selenium CoreはSelenium RCの中核をなしていました。

Selenium RCは、テスト対象製品、Selenium Core、テストスクリプトを3つの異なるサーバーに分散させました(テストスクリプトは指定されたURLにHTTPリクエストを送信することにのみ関心があり、Selenium自体はHTTPリクエストがどのプログラミング言語で作成されたかを気にしません)。

Selenium RCは2つの部分で構成されています:1つはSelenium RCサーバー、もう1つは様々なプログラミング言語のクライアントドライバを使用してテストスクリプトを作成するためのものです。

2007年、WebDriverが誕生しました。WebDriverの設計思想は、エンドツーエンドテストを下位の具体的なテストツールから分離し、アダプタデザインパターンを使用して目標を達成することでした。WebDriverのAPIはよりオブジェクト指向で組織されていました。

2008/09年、Selenium 2が登場しました。Selenium 2はSelenium RCとWebDriverの統合であり、統合の根本的な理由は互いの欠点を補完するためでした。

2009年、Selenium 3が登場し、このバージョンではSelenium RCが削除され、主にSelenium WebDriverとSelenium Gridで構成されています。私たちが日常的に使用しているのはSelenium WebDriverであり、Selenium Gridは分散環境で自動化テストを実装するツールです。

ここでは、Selenium 3(Selenium WebDriver)の動作原理について説明します。以下ではSeleniumと略称します(上記の具体的な時期は正確でない可能性があり、ネット上の情報から得たものです)。

Seleniumの基本原理

Seleniumを使用して自動化テストを実装するためには、主に3つのものが必要です。

  1. テストスクリプト - PythonやJavaで作成されたスクリプトプログラム(クライアント側とも呼ばれます)
  2. ブラウザドライバ - これは異なるブラウザ用に開発されたもので、異なるブラウザでは異なるWebDriverドライバプログラムを使用し、対応するブラウザバージョンが必要です。例:geckodriver.exe(Chrome用)
  3. ブラウザ - 現在Seleniumは市場のほとんどのブラウザをサポートしています。例:Firefox、Google Chrome、IEなど

Seleniumスクリプトの例

まず、簡単なコード例を見てみましょう。

"""
------------------------------------
@Time : 2023/10/15 10:30
@Auth : Test Developer
@File : selenium_demo.py
@IDE : VS Code
@Motto: テストは品質の守り手!
------------------------------------
"""

from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager

# Chromeドライバの自動セットアップ
service = Service(ChromeDriverManager().install())
browser = webdriver.Chrome(service=service)

# 指定したURLにアクセス
browser.get("https://example.com")

# ページタイトルの取得
print("ページタイトル:", browser.title)

# ブラウザを閉じる
browser.quit()

上記のコードを実行すると、プログラムはChromeブラウザを開きます(前提条件:Chromeドライバが正しく設定され、対応するバージョンがインストールされていること)。

では、Seleniumがどのようにこのプロセスを実装しているのでしょうか?次に、ソースコードの分析を通じてSeleniumの動作原理を理解します。

ソースコード分析

WebDriverのソースコードを見てみましょう(Ctrlキーを押しながらChromeをクリック): /usr/local/lib/python3.9/site-packages/selenium/webdriver/chromium/webdriver.py

class ChromiumDriver(RemoteWebDriver):
    """
    ChromeDriverやChromiumDriverを制御し、ブラウザを操作します。
    
    ChromeDriverの実行ファイルは以下からダウンロードしてください:
    http://chromedriver.storage.googleapis.com/index.html
    """
    
    def __init__(self, executable_path=None, port=0,
                 options=None, service_args=None,
                 desired_capabilities=None, service_log_path=None,
                 chrome_options=None, keep_alive=True):
        """
        Chromeドライバの新しいインスタンスを作成します。
        
        サービスを起動してから、新しいChromeドライバのインスタンスを作成します。
        
        :Args:
        - executable_path - 実行ファイルへのパス。デフォルトを使用する場合は実行ファイルが$PATHにあると仮定します
        - port - サービスを実行するポート。0に設定すると、空きポートが見つかります
        - options - ChromeOptionsのインスタンスを取ります
        - service_args - ドライバサービスに渡す引数のリスト
        - desired_capabilities - ブラウザに依存しない機能の辞書オブジェクトのみ、例えば"proxy"や"loggingPref"
        - service_log_path - ドライバからの情報をログに記録する場所
        - chrome_options - 非推奨のoptions引数
        - keep_alive - ChromeRemoteConnectionがHTTPキープアライブを使用するように構成するかどうか
        """
        if chrome_options:
            warnings.warn('optionsを使用してください。chrome_optionsは非推奨です',
                         DeprecationWarning, stacklevel=2)
            options = chrome_options
        
        if options is None:
            if desired_capabilities is None:
                desired_capabilities = self.create_options().to_capabilities()
        
        self.service = Service(
            executable_path,
            port=port,
            service_args=service_args,
            log_path=service_log_path)
        self.service.start()
        
        try:
            RemoteWebDriver.__init__(
                self,
                command_executor=ChromiumRemoteConnection(
                    remote_server_addr=self.service.service_url,
                    keep_alive=keep_alive),
                desired_capabilities=desired_capabilities)
        except Exception:
            self.quit()
            raise
            
        self._is_remote = False

ソースコードのservice.start()メソッドが何を実装しているかを見てみましょう: /usr/local/lib/python3.9/site-packages/selenium/webdriver/common/service.py

def start(self):
    """
    サービスを起動します。
    
    :Exceptions:
    - WebDriverException : サービスを起動できないか、サービスに接続できない場合に発生します
    """
    try:
        cmd = [self.path]
        cmd.extend(self.command_line_args())
        self.process = subprocess.Popen(cmd, env=self.env,
                                      close_fds=platform.system() != 'Windows',
                                      stdout=self.log_file,
                                      stderr=self.log_file,
                                      stdin=PIPE)
    except TypeError:
        raise
    except OSError as err:
        if err.errno == errno.ENOENT:
            raise WebDriverException(
                "'%s' 実行ファイルがPATHに必要です。%s" % (
                    os.path.basename(self.path), self.start_error_message)
            )
        elif err.errno == errno.EACCES:
            raise WebDriverException(
                "'%s' 実行ファイルの権限が正しくない可能性があります。%s" % (
                    os.path.basename(self.path), self.start_error_message)
            )
        else:
            raise
    except Exception as e:
        raise WebDriverException(
            "実行可能ファイル %s がパスで利用可能である必要があります。%s\n%s" %
            (os.path.basename(self.path), self.start_error_message, str(e)))
    
    count = 0
    while True:
        self.assert_process_still_running()
        if self.is_connectable():
            break
        count += 1
        time.sleep(1)
        if count == 30:
            raise WebDriverException("サービス %s に接続できません" % self.path)

9-16行目では、cmdコマンドが実行されていることがわかります。このコマンドの役割はchromedriver.exe(Chromeブラウザのドライバプログラム)を起動することです。

ここで注意すべき点は、ダウンロードしたブラウザドライバを環境変数に設定するか、Pythonのルートディレクトリに配置する必要があることです。これにより、プログラムがドライバを実行時に見つけやすくなります。

このプロセスは、手動でブラウザドライバを起動するのと同じ効果があります。以下のような結果になります。

ドライバプログラムを起動すると、ポート番号9515が割り当てられ、このサービスはローカルアクセスのみが許可されます。実際にタスクマネージャーを見ると、サービスプロセスプログラムが起動していることが確認できます。

最初のステップでは、テストスクリプトwebdriver.Chrome()が自動的にchromedriver.exeドライバプログラムを実行し、プロセスを起動することがわかりました。

ブラウザを開く方法

ソースコード/usr/local/lib/python3.9/site-packages/selenium/webdriver/chromium/webdriver.pyの51-57行目に戻り、親クラスのRemoteWebDriverの初期化メソッドが呼び出されています。このメソッドが何を行っているかを見てみましょう: /usr/local/lib/python3.9/site-packages/selenium/webdriver/remote/webdriver.py

class WebDriver(object):
    """
    リモートサーバーにコマンドを送信してブラウザを制御します。
    このサーバーはWebDriverワイヤープロトコルを実行していると期待されます。
    プロトコルの仕様は以下で定義されています:
    https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
    
    :Attributes:
    - session_id - このWebDriverによって開始され制御されるブラウザセッションの文字列ID。
    - capabilities - リモートサーバーによって返されるこのブラウザセッションの有効な機能の辞書。
      詳細は https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities を参照してください
    - command_executor - コマンドを実行するためのremote_connection.RemoteConnectionオブジェクト。
    - error_handler - エラーを処理するためのerrorhandler.ErrorHandlerオブジェクト。
    """
    
    _web_element_cls = WebElement
    
    def __init__(self, command_executor='http://127.0.0.1:4444/wd/hub',
                 desired_capabilities=None, browser_profile=None, proxy=None,
                 keep_alive=False, file_detector=None, options=None):
        """
        ワイヤープロトコルを使用してコマンドを発行する新しいドライバを作成します。
        
        :Args:
        - command_executor - リモートサーバーのURLを表す文字列、またはカスタムの
          remote_connection.RemoteConnectionオブジェクト。デフォルトは'http://127.0.0.1:4444/wd/hub'
        - desired_capabilities - ブラウザセッションを開始する際に要求する機能の辞書。
          必須パラメータです
        - browser_profile - selenium.webdriver.firefox.firefox_profile.FirefoxProfileオブジェクト。
          Firefoxが要求される場合のみ使用されます。オプションです
        - proxy - selenium.webdriver.common.proxy.Proxyオブジェクト。可能であれば、
          指定されたプロキシ設定でブラウザセッションが開始されます。オプションです
        - keep_alive - remote_connection.RemoteConnectionがHTTPキープアライブを使用するように
          構成するかどうか。デフォルトはFalseです
        - file_detector - インスタンス化中にカスタムファイル検出オブジェクトを渡します。
          Noneの場合、デフォルトのLocalFileDetector()が使用されます
        - options - driver options.Optionsクラスのインスタンス
        """
        capabilities = {}
        if options is not None:
            capabilities = options.to_capabilities()
        if desired_capabilities is not None:
            if not isinstance(desired_capabilities, dict):
                raise WebDriverException("Desired Capabilitiesは辞書である必要があります")
            else:
                capabilities.update(desired_capabilities)
        if proxy is not None:
            warnings.warn("プロキシの設定にはFirefoxOptionsを使用してください",
                         DeprecationWarning, stacklevel=2)
            proxy.add_to_capabilities(capabilities)
            
        self.command_executor = command_executor
        if type(self.command_executor) is bytes or isinstance(self.command_executor, str):
            self.command_executor = RemoteConnection(command_executor, keep_alive=keep_alive)
        self._is_remote = True
        self.session_id = None
        self.capabilities = {}
        self.error_handler = ErrorHandler()
        self.start_client()
        if browser_profile is not None:
            warnings.warn("ブラウザプロファイルの設定にはFirefoxOptionsを使用してください",
                         DeprecationWarning, stacklevel=2)
        self.start_session(capabilities, browser_profile)
        self._switch_to = SwitchTo(self)
        self._mobile = Mobile(self)
        self.file_detector = file_detector or LocalFileDetector()

ここで最も重要なコードは、62行目のself.start_session(capabilities, browser_profile)です。このメソッドのソースコードが何を行っているかを見てみましょう:

def start_session(self, capabilities, browser_profile=None):
    """
    所望の機能で新しいセッションを作成します。
    
    :Args:
    - browser_name - 要求するブラウザの名前
    - version - 要求するブラウザのバージョン
    - platform - 要求するブラウザのプラットフォーム
    - javascript_enabled - 新しいセッションがJavaScriptをサポートするかどうか
    - browser_profile - selenium.webdriver.firefox.firefox_profile.FirefoxProfileオブジェクト。
      Firefoxが要求される場合のみ使用されます
    """
    if not isinstance(capabilities, dict):
        raise InvalidArgumentException("Capabilitiesは辞書である必要があります")
    if browser_profile:
        if "moz:firefoxOptions" in capabilities:
            capabilities["moz:firefoxOptions"]["profile"] = browser_profile.encoded
        else:
            capabilities.update({'firefox_profile': browser_profile.encoded})
    
    w3c_caps = _make_w3c_caps(capabilities)
    parameters = {"capabilities": w3c_caps,
                 "desiredCapabilities": capabilities}
    response = self.execute(Command.NEW_SESSION, parameters)
    if 'sessionId' not in response:
        response = response['value']
    self.session_id = response['sessionId']
    self.capabilities = response.get('value')
    
    # capabilitiesがNoneの場合、おそらくW3Cエンドポイントと話している
    if self.capabilities is None:
        self.capabilities = response.get('capabilities')
    
    # W3C準拠のブラウザかどうかを再度確認
    self.w3c = response.get('status') is None
    self.command_executor.w3c = self.w3c

このソースコードの分析から、22行目でlocalhost:9515/sessionにPOSTリクエストが送信され、JSON形式のパラメータが返され、特定の応答情報がプログラムに返されていることがわかります(主に新しいsessionidが作成されます)。最終的にブラウザが開かれます。

以上で、ブラウザを開く操作は完了です。

ブラウザ操作の実行方法

/usr/local/lib/python3.9/site-packages/selenium/webdriver/chromium/webdriver.pyのソースコード(最初のソースコードの51-57行目)を見てみましょう:

try:
    RemoteWebDriver.__init__(
        self,
        command_executor=ChromiumRemoteConnection(
            remote_server_addr=self.service.service_url,
            keep_alive=keep_alive),
        desired_capabilities=desired_capabilities)

ChromeRemoteConnectionをクリックしてソースコードを見てみましょう:

from selenium.webdriver.remote.remote_connection import RemoteConnection

class ChromiumRemoteConnection(RemoteConnection):
    def __init__(self, remote_server_addr, keep_alive=True):
        RemoteConnection.__init__(self, remote_server_addr, keep_alive)
        self._commands["launchApp"] = ('POST', '/session/$sessionId/chromium/launch_app')
        self._commands["setNetworkConditions"] = ('POST', '/session/$sessionId/chromium/network_conditions')
        self._commands["getNetworkConditions"] = ('GET', '/session/$sessionId/chromium/network_conditions')
        self._commands['executeCdpCommand'] = ('POST', '/session/$sessionId/goog/cdp/execute')

7行目ではlocalhost:9515/sessionアドレスにアクセスしています。8-11行目では、使用しているブラウザ(Chrome)固有のインターフェースアドレスが定義されています。次に親クラスのRemoteConnection内のソースコードを見てみましょう:

/usr/local/lib/python3.9/site-packages/selenium/webdriver/remote/remote_connection.py:RemoteConnection

self._commands = {
    Command.STATUS: ('GET', '/status'),
    Command.NEW_SESSION: ('POST', '/session'),
    Command.GET_ALL_SESSIONS: ('GET', '/sessions'),
    Command.QUIT: ('DELETE', '/session/$sessionId'),
    Command.GET_CURRENT_WINDOW_HANDLE:
    ('GET', '/session/$sessionId/window_handle'),
    Command.W3C_GET_CURRENT_WINDOW_HANDLE:
    ('GET', '/session/$sessionId/window'),
    Command.GET_WINDOW_HANDLES:
    ('GET', '/session/$sessionId/window_handles'),
    Command.W3C_GET_WINDOW_HANDLES:
    ('GET', '/session/$sessionId/window/handles'),
    Command.GET: ('POST', '/session/$sessionId/url'),
    Command.GO_FORWARD: ('POST', '/session/$sessionId/forward'),
    Command.GO_BACK: ('POST', '/session/$sessionId/back'),
    Command.REFRESH: ('POST', '/session/$sessionId/refresh'),
    Command.EXECUTE_SCRIPT: ('POST', '/session/$sessionId/execute'),
    Command.W3C_EXECUTE_SCRIPT:
    ('POST', '/session/$sessionId/execute/sync'),
    Command.W3C_EXECUTE_SCRIPT_ASYNC:
    ('POST', '/session/$sessionId/execute/async'),
    Command.GET_CURRENT_URL: ('GET', '/session/$sessionId/url'),
    Command.GET_TITLE: ('GET', '/session/$sessionId/title'),
    Command.GET_PAGE_SOURCE: ('GET', '/session/$sessionId/source'),
    Command.SCREENSHOT: ('GET', '/session/$sessionId/screenshot'),
    Command.ELEMENT_SCREENSHOT: ('GET', '/session/$sessionId/element/$id/screenshot'),
    Command.FIND_ELEMENT: ('POST', '/session/$sessionId/element'),
    Command.FIND_ELEMENTS: ('POST', '/session/$sessionId/elements'),
    Command.W3C_GET_ACTIVE_ELEMENT: ('GET', '/session/$sessionId/element/active'),
    Command.GET_ACTIVE_ELEMENT:
    ('POST', '/session/$sessionId/element/active'),
    Command.FIND_CHILD_ELEMENT:
    ('POST', '/session/$sessionId/element/$id/element'),
    Command.FIND_CHILD_ELEMENTS:
    ('POST', '/session/$sessionId/element/$id/elements'),
    Command.CLICK_ELEMENT: ('POST', '/session/$sessionId/element/$id/click'),
    Command.CLEAR_ELEMENT: ('POST', '/session/$sessionId/element/$id/clear'),
    Command.SUBMIT_ELEMENT: ('POST', '/session/$sessionId/element/$id/submit'),
    Command.GET_ELEMENT_TEXT: ('GET', '/session/$sessionId/element/$id/text'),
    Command.SEND_KEYS_TO_ELEMENT:
    ('POST', '/session/$sessionId/element/$id/value'),
    Command.SEND_KEYS_TO_ACTIVE_ELEMENT:
    ('POST', '/session/$sessionId/keys'),
    Command.UPLOAD_FILE: ('POST', "/session/$sessionId/file"),
    Command.GET_ELEMENT_VALUE:
    ('GET', '/session/$sessionId/element/$id/value'),
    Command.GET_ELEMENT_TAG_NAME:
    ('GET', '/session/$sessionId/element/$id/name'),
    Command.IS_ELEMENT_SELECTED:
    ('GET', '/session/$sessionId/element/$id/selected'),
    Command.SET_ELEMENT_SELECTED:
    ('POST', '/session/$sessionId/element/$id/selected'),
    Command.IS_ELEMENT_ENABLED:
    ('GET', '/session/$sessionId/element/$id/enabled'),
    Command.IS_ELEMENT_DISPLAYED:
    ('GET', '/session/$sessionId/element/$id/displayed'),
    Command.GET_ELEMENT_LOCATION:
    ('GET', '/session/$sessionId/element/$id/location'),
    Command.GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW:
    ('GET', '/session/$sessionId/element/$id/location_in_view'),
    Command.GET_ELEMENT_SIZE:
    ('GET', '/session/$sessionId/element/$id/size'),
    Command.GET_ELEMENT_RECT:
    ('GET', '/session/$sessionId/element/$id/rect'),
    Command.GET_ELEMENT_ATTRIBUTE:
    ('GET', '/session/$sessionId/element/$id/attribute/$name'),
    Command.GET_ELEMENT_PROPERTY:
    ('GET', '/session/$sessionId/element/$id/property/$name'),
    Command.GET_ALL_COOKIES: ('GET', '/session/$sessionId/cookie'),
    Command.ADD_COOKIE: ('POST', '/session/$sessionId/cookie'),
    Command.GET_COOKIE: ('GET', '/session/$sessionId/cookie/$name'),
    Command.DELETE_ALL_COOKIES:
    ('DELETE', '/session/$sessionId/cookie'),
    Command.DELETE_COOKIE:
    ('DELETE', '/session/$sessionId/cookie/$name'),
    Command.SWITCH_TO_FRAME: ('POST', '/session/$sessionId/frame'),
    Command.SWITCH_TO_PARENT_FRAME: ('POST', '/session/$sessionId/frame/parent'),
    Command.SWITCH_TO_WINDOW: ('POST', '/session/$sessionId/window'),
    Command.CLOSE: ('DELETE', '/session/$sessionId/window'),
    Command.GET_ELEMENT_VALUE_OF_CSS_PROPERTY:
    ('GET', '/session/$sessionId/element/$id/css/$propertyName'),
    Command.IMPLICIT_WAIT:
    ('POST', '/session/$sessionId/timeouts/implicit_wait'),
    Command.EXECUTE_ASYNC_SCRIPT: ('POST', '/session/$sessionId/execute_async'),
    Command.SET_SCRIPT_TIMEOUT:
    ('POST', '/session/$sessionId/timeouts/async_script'),
    Command.SET_TIMEOUTS:
    ('POST', '/session/$sessionId/timeouts'),
    Command.DISMISS_ALERT:
    ('POST', '/session/$sessionId/dismiss_alert'),
    Command.W3C_DISMISS_ALERT:
    ('POST', '/session/$sessionId/alert/dismiss'),
    Command.ACCEPT_ALERT:
    ('POST', '/session/$sessionId/accept_alert'),
    Command.W3C_ACCEPT_ALERT:
    ('POST', '/session/$sessionId/alert/accept'),
    Command.SET_ALERT_VALUE:
    ('POST', '/session/$sessionId/alert_text'),
    Command.W3C_SET_ALERT_VALUE:
    ('POST', '/session/$sessionId/alert/text'),
    Command.GET_ALERT_TEXT:
    ('GET', '/session/$sessionId/alert_text'),
    Command.W3C_GET_ALERT_TEXT:
    ('GET', '/session/$sessionId/alert/text'),
    Command.SET_ALERT_CREDENTIALS:
    ('POST', '/session/$sessionId/alert/credentials'),
    Command.CLICK:
    ('POST', '/session/$sessionId/click'),
    Command.W3C_ACTIONS:
    ('POST', '/session/$sessionId/actions'),
    Command.W3C_CLEAR_ACTIONS:
    ('DELETE', '/session/$sessionId/actions'),
    Command.DOUBLE_CLICK:
    ('POST', '/session/$sessionId/doubleclick'),
    Command.MOUSE_DOWN:
    ('POST', '/session/$sessionId/buttondown'),
    Command.MOUSE_UP:
    ('POST', '/session/$sessionId/buttonup'),
    Command.MOVE_TO:
    ('POST', '/session/$sessionId/moveto'),
    Command.GET_WINDOW_SIZE:
    ('GET', '/session/$sessionId/window/$windowHandle/size'),
    Command.SET_WINDOW_SIZE:
    ('POST', '/session/$sessionId/window/$windowHandle/size'),
    Command.GET_WINDOW_POSITION:
    ('GET', '/session/$sessionId/window/$windowHandle/position'),
    Command.SET_WINDOW_POSITION:
    ('POST', '/session/$sessionId/window/$windowHandle/position'),
    Command.SET_WINDOW_RECT:
    ('POST', '/session/$sessionId/window/rect'),
    Command.GET_WINDOW_RECT:
    ('GET', '/session/$sessionId/window/rect'),
    Command.MAXIMIZE_WINDOW:
    ('POST', '/session/$sessionId/window/$windowHandle/maximize'),
    Command.W3C_MAXIMIZE_WINDOW:
    ('POST', '/session/$sessionId/window/maximize'),
    Command.SET_SCREEN_ORIENTATION:
    ('POST', '/session/$sessionId/orientation'),
    Command.GET_SCREEN_ORIENTATION:
    ('GET', '/session/$sessionId/orientation'),
    Command.SINGLE_TAP:
    ('POST', '/session/$sessionId/touch/click'),
    Command.TOUCH_DOWN:
    ('POST', '/session/$sessionId/touch/down'),
    Command.TOUCH_UP:
    ('POST', '/session/$sessionId/touch/up'),
    Command.TOUCH_MOVE:
    ('POST', '/session/$sessionId/touch/move'),
    Command.TOUCH_SCROLL:
    ('POST', '/session/$sessionId/touch/scroll'),
    Command.DOUBLE_TAP:
    ('POST', '/session/$sessionId/touch/doubleclick'),
    Command.LONG_PRESS:
    ('POST', '/session/$sessionId/touch/longclick'),
    Command.FLICK:
    ('POST', '/session/$sessionId/touch/flick'),
    Command.EXECUTE_SQL:
    ('POST', '/session/$sessionId/execute_sql'),
    Command.GET_LOCATION:
    ('GET', '/session/$sessionId/location'),
    Command.SET_LOCATION:
    ('POST', '/session/$sessionId/location'),
    Command.GET_APP_CACHE:
    ('GET', '/session/$sessionId/application_cache'),
    Command.GET_APP_CACHE_STATUS:
    ('GET', '/session/$sessionId/application_cache/status'),
    Command.CLEAR_APP_CACHE:
    ('DELETE', '/session/$sessionId/application_cache/clear'),
    Command.GET_NETWORK_CONNECTION:
    ('GET', '/session/$sessionId/network_connection'),
    Command.SET_NETWORK_CONNECTION:
    ('POST', '/session/$sessionId/network_connection'),
    Command.GET_LOCAL_STORAGE_ITEM:
    ('GET', '/session/$sessionId/local_storage/key/$key'),
    Command.REMOVE_LOCAL_STORAGE_ITEM:
    ('DELETE', '/session/$sessionId/local_storage/key/$key'),
    Command.GET_LOCAL_STORAGE_KEYS:
    ('GET', '/session/$sessionId/local_storage'),
    Command.SET_LOCAL_STORAGE_ITEM:
    ('POST', '/session/$sessionId/local_storage'),
    Command.CLEAR_LOCAL_STORAGE:
    ('DELETE', '/session/$sessionId/local_storage'),
    Command.GET_LOCAL_STORAGE_SIZE:
    ('GET', '/session/$sessionId/local_storage/size'),
    Command.GET_SESSION_STORAGE_ITEM:
    ('GET', '/session/$sessionId/session_storage/key/$key'),
    Command.REMOVE_SESSION_STORAGE_ITEM:
    ('DELETE', '/session/$sessionId/session_storage/key/$key'),
    Command.GET_SESSION_STORAGE_KEYS:
    ('GET', '/session/$sessionId/session_storage'),
    Command.SET_SESSION_STORAGE_ITEM:
    ('POST', '/session/$sessionId/session_storage'),
    Command.CLEAR_SESSION_STORAGE:
    ('DELETE', '/session/$sessionId/session_storage'),
    Command.GET_SESSION_STORAGE_SIZE:
    ('GET', '/session/$sessionId/session_storage/size'),
    Command.GET_LOG:
    ('POST', '/session/$sessionId/log'),
    Command.GET_AVAILABLE_LOG_TYPES:
    ('GET', '/session/$sessionId/log/types'),
    Command.CURRENT_CONTEXT_HANDLE:
    ('GET', '/session/$sessionId/context'),
    Command.CONTEXT_HANDLES:
    ('GET', '/session/$sessionId/contexts'),
    Command.SWITCH_TO_CONTEXT:
    ('POST', '/session/$sessionId/context'),
    Command.FULLSCREEN_WINDOW:
    ('POST', '/session/$sessionId/window/fullscreen'),
    Command.MINIMIZE_WINDOW:
    ('POST', '/session/$sessionId/window/minimize')
}

このクラスには、Selenium操作に必要なすべてのインターフェースアドレスが定義されています(これらのインターフェースアドレスはすべてブラウザドライバプログラムにカプセル化されています)。したがって、すべてのブラウザ操作はこれらのインターフェースにアクセスすることによって実現されます。

その中で、Command.GET: ('POST', '/session/$sessionId/url') このアドレスはWebサイトのURLにアクセスするためのものです。後で使用するために記録しておきましょう。

以上で、すべての操作に対応するインターフェースアドレスがわかりました。では、これらのインターフェースを実行してブラウザで様々な操作を実現するにはどうすればよいでしょうか?インターフェースアドレス定義のすぐ下のソースコードを見てみましょう:

def execute(self, command, params):
    """
    リモートサーバーにコマンドを送信します。
    
    コマンドにマッピングされたURLに必要なパスの置換は、
    コマンドパラメータに含める必要があります。
    
    :Args:
    - command - 実行するコマンドを指定する文字列
    - params - コマンドのJSONペイロードとして送信する名前付きパラメータの辞書
    """
    command_info = self._commands[command]
    assert command_info is not None, '認識できないコマンド %s' % command
    path = string.Template(command_info[1]).substitute(params)
    if hasattr(self, 'w3c') and self.w3c and isinstance(params, dict) and 'sessionId' in params:
        del params['sessionId']
    data = utils.dump_json(params)
    url = '%s%s' % (self._url, path)
    return self._request(command_info[0], url, body=data)

def _request(self, method, url, body=None):
    """
    リモートサーバーにHTTPリクエストを送信します。
    
    :Args:
    - method - リクエストを送信するためのHTTPメソッドを指定する文字列
    - url - 送信先のURLを指定する文字列
    - body - リクエストボディの文字列。メソッドがPOSTまたはPUTの場合のみ無視されます
    
    :Returns:
    サーバーの解析済みJSONレスポンスを含む辞書
    """
    LOGGER.debug('%s %s %s' % (method, url, body))
    
    parsed_url = parse.urlparse(url)
    headers = self.get_remote_connection_headers(parsed_url, self.keep_alive)
    resp = None
    if body and method != 'POST' and method != 'PUT':
        body = None
    
    if self.keep_alive:
        resp = self._conn.request(method, url, body=body, headers=headers)
    else:
        http = urllib3.PoolManager(timeout=self._timeout)
        resp = http.request(method, url, body=body, headers=headers)
    
    statuscode = resp.status
    if not hasattr(resp, 'getheader'):
        if hasattr(resp.headers, 'getheader'):
            resp.getheader = lambda x: resp.headers.getheader(x)
        elif hasattr(resp.headers, 'get'):
            resp.getheader = lambda x: resp.headers.get(x)
    
    data = resp.data.decode('UTF-8')
    try:
        if 300 <= statuscode < 304:
            return self._request('GET', resp.getheader('location'))
        if 399 < statuscode <= 500:
            return {'status': statuscode, 'value': data}
        content_type = []
        if resp.getheader('Content-Type') is not None:
            content_type = resp.getheader('Content-Type').split(';')
        if not any([x.startswith('image/png') for x in content_type]):
            try:
                data = utils.load_json(data.strip())
            except ValueError:
                if 199 < statuscode < 300:
                    status = ErrorCode.SUCCESS
                else:
                    status = ErrorCode.UNKNOWN_ERROR
                return {'status': status, 'value': data.strip()}
        
        # 一部のドライバは、nullを返すべき場合に 'value' フィールドなしで
        # レスポンスを不正に返すことがあります
        if 'value' not in data:
            data['value'] = None
        return data
    else:
        data = {'status': 0, 'value': data}
        return data
    finally:
        LOGGER.debug("Request Finished")
        resp.close()

主にexecuteメソッドが_requestメソッドを呼び出し、urllib3標準ライブラリを使用してサーバーに対応する操作リクエストアドレスを送信することで、ブラウザの様々な操作を実現していることがわかります。

ブラウザを開くとブラウザで様々な動作を実行する操作はどのように関連付けられているのでしょうか?

実際、ブラウザを開くこともリクエストの送信であり、リクエストはsessionidを返します。その後の操作の様々なインターフェースアドレスにも、変数$sessionidが存在することがわかります。そうすると、ブラウザを開く操作とブラウザ操作はsessionidを介して関連付けられていると推測でき、同じブラウザ内で操作を行うことができます。

2つ目のブラウザで様々な操作を実現する原理も完了しました。

Seleniumのシミュレーション

以下のコードを使用して、ブラウザを開いてブログのホームページにアクセスするリクエストパラメータがどのようなものかを見てみましょう。

"""
------------------------------------
@Time : 2023/10/15 11:00
@Auth : Test Developer
@File : selenium_demo.py
@IDE : VS Code
@Motto: 品質を追求せよ!
------------------------------------
"""

from selenium import webdriver
import logging

logging.basicConfig(level=logging.DEBUG) # ソースコードのログを表示
browser = webdriver.Chrome() # ブラウザを開く
browser.get("https://techblog.example.com/") # ブログのホームページにアクセス

出力ログ情報:

DEBUG:selenium.webdriver.remote.remote_connection:POST http://127.0.0.1:55695/session
{"capabilities": {"firstMatch": [{}], "alwaysMatch": {"browserName": "chrome", "platformName": "any", "goog:chromeOptions":
{"extensions": [], "args": []}}}, "desiredCapabilities": {"browserName": "chrome", "version": "", "platform": "ANY",
"goog:chromeOptions": {"extensions": [], "args": []}}}
DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 127.0.0.1
DEBUG:urllib3.connectionpool:http://127.0.0.1:55695 "POST /session HTTP/1.1" 200 830
DEBUG:selenium.webdriver.remote.remote_connection:Finished Request
DEBUG:selenium.webdriver.remote.remote_connection:POST http://127.0.0.1:51006/session/09d52393b7dfcb45b8bb9101885ce206/url
{"url": "https://techblog.example.com/", "sessionId": "09d52393b7dfcb45b8bb9101885ce206"}
DEBUG:urllib3.connectionpool:http://127.0.0.1:51006 "POST /session/09d52393b7dfcb45b8bb9101885ce206/url HTTP/1.1" 200 72
DEBUG:selenium.webdriver.remote.remote_connection:Finished Request
Process finished with exit code 0

実行結果から、Seleniumの実行プロセスが明確に理解できます。プログラムはRemoteWebDriverにブラウザを開くように指示します(POSTリクエストを送信し、リクエストパラメータを指定)、次にリモートサーバーにブラウザ操作のリクエストを送信します。

Seleniumが自動化テストを実装するプロセスをより深く理解するために、自分でプログラムを作成してブラウザを開き、ブログのアドレスにアクセスする操作をシミュレートしてみましょう。

まず、ブラウザのドライバプログラムが開いている状態を維持する必要があります。次に、以下のコードを作成して実行します。

"""
------------------------------------
@Time : 2023/10/14 15:30
@Auth : Test Developer
@File : selenium_simulate.py
@IDE : VS Code
@Motto: 理解ある実践が最良の学習!
------------------------------------
"""

import requests
import json

# リクエストアドレス(ブラウザを開く)
driver_url = 'http://localhost:9515/session'
# ブラウザを開くためのリクエストパラメータ
driver_value = {
    "capabilities": {
        "firstMatch": [{}],
        "alwaysMatch": {
            "browserName": "chrome",
            "platformName": "any",
            "goog:chromeOptions": {
                "extensions": [],
                "args": []
            }
        }
    },
    "desiredCapabilities": {
        "browserName": "chrome",
        "version": "",
        "platform": "ANY",
        "goog:chromeOptions": {
            "extensions": [],
            "args": []
        }
    }
}

# リクエストを送信
response_session = requests.post(driver_url, json=driver_value)
print("セッション開始応答:", response_session.json())

# ブログにアクセスするためのリクエストアドレス(このアドレスは上記で記録したものです)
session_id = response_session.json()['sessionId']
url = f'http://localhost:9515/session/{session_id}/url'

# ブログにアクセスするためのリクエストパラメータ
value = {"url": "https://techblog.example.com/", "sessionId": session_id}
response_blog = requests.post(url=url, json=value)
print("ブログアクセス応答:", response_blog.json())

実行結果:

セッション開始応答: {'sessionId': '25144efef880dcce53e4e6f60c342e9d', 'status': 0, 'value':
{'acceptInsecureCerts': False, 'acceptSslCerts': False, 'applicationCacheEnabled': False,
'browserConnectionEnabled': False, 'browserName': 'chrome', 'chrome':
{'chromedriverVersion': '2.39.562718 (9a2698cba08cf5a471a29d30c8b3e12becabb0e9)',
'userDataDir': '/tmp/scoped_dir9944_25238'}, 'cssSelectorsEnabled': True, 'databaseEnabled': False,
'handlesAlerts': True, 'hasTouchScreen': False, 'javascriptEnabled': True, 'locationContextEnabled': True,
'mobileEmulationEnabled': False, 'nativeEvents': True, 'networkConnectionEnabled': False,
'pageLoadStrategy': 'normal', 'platform': 'Linux', 'rotatable': False, 'setWindowRect': True,
'takesHeapSnapshot': True, 'takesScreenshot': True, 'unexpectedAlertBehaviour': '', 'version': '115.0.5790.102',
'webStorageEnabled': True}}
ブログアクセス応答: {'sessionId': '25144efef880dcce53e4e6f60c342e9d', 'status': 0, 'value': None}

上記の応答情報で最も重要な情報は'sessionId': '25144efef880dcce53e4e6f60c342e9d'です。コードからもわかるように、ブログのアドレスにアクセスするURLはこのパラメータを使用して構築されています。ブラウザを開いた後、後のすべての操作はこのsessionidに基づいています。

また、Chromeブラウザが開き、ブログのアドレスhttps://techblog.example.com/が開かれていることも確認できます。これがSeleniumの原理のプロセスです。

まとめ

前述のコードを理解できなくても問題ありません。Seleniumの動作プロセスを再度説明します。

  1. Seleniumクライアント(Pythonなどの言語で作成された自動化テストスクリプト)がサービスを初期化し、WebDriverを使用してブラウザドライバプログラムchromedriver.exeを起動します。
  2. RemoteWebDriverを使用してブラウザドライバプログラムにHTTPリクエストを送信し、ブラウザドライバプログラムがリクエストを解析してブラウザを開き、sessionidを取得します。再度ブラウザを操作する場合は、このidを携帯する必要があります。
  3. ブラウザを開き、特定のポートを割り当て、起動したブラウザをWebDriverのリモートサーバーとして使用します。
  4. ブラウザを開いた後、すべてのSelenium操作(アドレスへのアクセス、要素の検索など)はRemoteConnectionを介してリモートサーバーに接続され、その後executeメソッドを使用して_requestメソッドを呼び出し、urlib3を使用してリモートサーバーにリクエストを送信します。
  5. ブラウザはリクエストの内容に基づいて対応するアクションを実行し、実行結果をブラウザドライバプログラムを介してテストスクリプトに返します。

行動に移しましょう。ただ見ているだけよりも道を進んでいる方が良いでしょう。未来のあなたは、今この努力をしている自分に感謝するはずです!

タグ: Selenium 自動テスト Webテスト WebDriver

6月17日 22:27 投稿