WijmoのコントロールをSeleniumでテストしてみよう
自動テストコードを書く前に、まずはWijmoが生成するリッチUIに対する自動テストコードの基本的な実装方式を考えてみましょう。
自動操作する要素を特定する「ロケーター(Locator)」
Seleniumなどを利用したE2Eテストの自動化では、自動操作・検証する画面の要素を何らかの方式により特定する必要があります。Seleniumではこの特定方式を「ロケーター」と呼んでいます。ロケーターにはさまざまな方式があり、次のようなものが昔からよくロケーターに利用されてきました。
DOM要素の属性による特定
- id
- class
- data-testid、test-id、data-automation-idなど、ロケーター専用に用意した属性
DOMツリーの構造による特定
- CSSセレクタ
- XPath
しかしこれらによるロケーターは、それぞれ単独で利用したときに次のような問題が生じがちでした。
- ライブラリにより自動的に生成されたリッチUI要素 およびその要素の属性に対して、詳細な指定がしづらい
- 後日、画面側の仕様変更にともなってテストコードの修正量も多くなりがち
どのようなロケーターが最も正しいかの議論はなかなか難しいのですが、今回はWijmoが対応しているアクセシビリティ標準 WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications)の属性を活用したロケーターを使って、Wijmoのいくつかの代表的なリッチUIに対するテストコードを書いてみます。
テストコードを書く前に
本記事の前提条件としまして、テストコードの言語はすべてPython 3.9とし、テストフレームワークにはpytest 6.2.5を使用します。Selenium 4からPython向けのAPIが大きく変わったので、その点にも注意していきましょう。
SeleniumからWebブラウザを自動操作するとき、WebDriver APIを提供するために間に立ってくれるブラウザドライバが、ブラウザごとに必要になります。例えば、Google Chromeを自動操作する場合にはChromeDriverが必要になり、Microsoft Edgeを自動操作する場合はEdgeDriverが必要になります。
ChromeDriverやEdgeDriverのバージョン管理ポリシーは厳しく、自動操作するブラウザとメジャーバージョンをきっちり合わせ続けなければなりません。このようにわずらわしいブラウザドライバ管理を簡単に済ませるために、適合するブラウザドライバを自動的にダウンロードしてくれる「WebDriverManager」を活用してみましょう。
WebDriverManagerはもともとJava向けに開発されたライブラリですが、便利で手軽なので別の作者がPython向けに「webdriver_manager」という名称でポーティングしました。今回はこのPython向けwebdriver_managerを使っていきましょう。
利用方法は非常にシンプルです。例えばChromeDriverを使いたい部分ではChromeDriverManager().install()
と実行するだけで、自動ダウンロードしてダウンロード先のファイルパスを返してくれます。このパスを、次の例のようにドライバの初期化の引数に渡せば、以後の管理がとても簡単になります(Selenium 4から、executable_path
はService
オブジェクト経由でドライバに渡さなければいけなくなった点に注意してください)。
from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager 自動ダウンロードしたchromedriverのファイルパス = ChromeDriverManager().install() chromedriver管理サービス = Service(executable_path=自動ダウンロードしたchromedriverのファイルパス) driver = webdriver.Chrome(service=chromedriver管理サービス)
このようなブラウザ操作前の初期化処理は、テストフレームワークがxUnitの場合はsetup()
やtearDown()
といった共通処理にまとめて書くことが多いですね。これに相当する部分を、今回使用するテストフレームワークのpytestではフィクスチャ(fixture)という機能で次の例のように一つの関数に簡潔に記述します。
import pytest from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager @pytest.fixture(scope='function', autouse=True) def chrome_driver(): 自動ダウンロードしたchromedriverのファイルパス = ChromeDriverManager().install() chromedriver管理サービス = Service(executable_path=自動ダウンロードしたchromedriverのファイルパス) driver = webdriver.Chrome(service=chromedriver管理サービス) # ここまでが setup() 相当です。yieldにより初期化済みのドライバのオブジェクトを返します。 yield driver # ここからが tearDown() 相当です。ドライバの終了処理を記述します。 driver.close() driver.quit()
入力 - ComboBox
Wijmoには、標準的なWeb UI部品を拡張したものが多く含まれています。最初の題材として、リスト項目がドロップダウンされるComboBoxでの自動操作を実装してみましょう。
次の図のように、おいしそうな和菓子が並んだドロップダウンが実装されたページがあります。おいしそうですね。このページに対して「ドロップダウンからどらやきをクリックしたら、上部の入力フィールド側でもどらやきが選択された状態になる」というページ仕様が実装されているかテストしてみましょう。
このドロップダウンは一見<select>
要素によるものにも見えますが、実際はWijmoが自動的に生成する次のようなDOMにより構成されています。
<div id="theComboBox"> <input role="combobox"> <button aria-label="トグル ドロップダウン"></button> </div> <div role="listbox" id="theComboBox_dropdown"> <div role="option">おまんじゅう</div> <div role="option">ようかん</div> <div role="option">どらやき</div> <div role="option">きんつば</div> </div>
とはいえ、E2Eテストの観点、平たく言えばユーザー目線では、ドロップダウンを操作するのには変わりありません。テストコードも、まずはユーザー目線ではどのような操作をしているのかをコード化して、具体的な自動操作コードについては各ページ操作用のオブジェクトの内部に後から肉付けするように考えてみましょう。
ユーザー目線での操作・テストは、例えば次のようにコード化できるでしょう。ドロップダウンのあるページを操作するためのオブジェクトをDropdownPage
クラスで定義します。
dropdown_page: DropdownPage = DropdownPage(driver=chrome_driver) dropdown_page.select('どらやき') # ドロップダウンから「どらやき」を選択する assert dropdown_page.get_selected_option() == 'どらやき' # 操作後、「どらやき」が選択されていればOK
ドロップダウンから「どらやき」を選択する操作は、詳細な動きに分けると次のようにコード化できます(このあたりからもう、DropdownPageの責務になってきます)。
toggle_dropdown() # ドロップダウンを一度クリックして展開する。 select_option_from_dropdown('どらやき') # 展開したドロップダウンから「どらやき」を選ぶ。
では、ドロップダウンを一度クリックして展開する部分の詳細実装を考えてみましょう。 ここで便利なのは、Wijmoが自動的に出力するWAI-ARIA属性です。 これはアクセシビリティに関する属性ということもあって、見た目のデザインが細かく修正されても影響を受けず変わりにくい属性なので、うまく取り入れることでテスト対象の変更に強い自動テストコードになることが期待できます。
ドロップダウンを展開するためのボタンには「トグル ドロップダウン」という値のaria-label
属性が自動的に設定されています。ロケーター文字列に取り入れてみましょう。
driver.find_element(By.CSS_SELECTOR, '#theComboBox button[aria-label="トグル ドロップダウン"]').click()
次に、展開したドロップダウンから「どらやき」を選ぶ詳細実装を考えてみましょう。ここでも、自動的に設定されるWAI-ARIA属性のひとつであるrole
をロケーター文字列に取り入れてみます。ユーザー目線では「ドロップダウンのn番目の項目を選択する」ではなく、「ドロップダウンの中の〇〇と表記されている項目を選択する」という操作になるので、これを意識したコードにするとよりE2Eテストらしくなるでしょう。
driver.find_element(By.XPATH, '//div[@role="option" and text()="どらやき"]').click()
最後に、操作後「どらやき」が選択されているか確認するための詳細実装を考えてみましょう。ここでも自動的に設定されるrole
属性を活用できます。
driver.find_element(By.CSS_SELECTOR, 'input[role="combobox"]').get_attribute('value')
Wijmoが自動生成するDOMは一見複雑ですが、WAI-ARIA属性に着目することで比較的シンプルで堅牢な対応ができるのではないでしょうか。
入力 - Calendar
次の題材はカレンダーです。リッチUIライブラリの中でも、とりわけよく使われるUI部品ですね。
カレンダーの実装は細かい要素の集まりなので複雑に見えますが、おおまかにとらえるとWijmoは次のような構造で自動的に生成しています。
<div id="theCalendar"> <div> <div role="button" aria-label="月ビュー">2021年12月</div> <div> <button aria-label="先月"></button> <button aria-label="今日"></button> <button aria-label="来月"></button> </div> </div> <table role="grid" aria-label="calendar"> <tr> <td aria-label="2021年11月28日">28</td> <td aria-label="2021年11月29日">29</td> <td aria-label="2021年11月30日">30</td> <td aria-label="2021年12月1日">1</td> <td aria-label="2021年12月2日">2</td> <td aria-label="2021年12月3日">3</td> <td aria-label="2021年12月4日">4</td> </tr> <tr> ... ... ... </tr> ... ... ... </table> </div>
では、「先ほど選んだどらやきを遠方の知人へおみやげに持っていく日にちをカレンダー上で決める」なんてシナリオで、自動操作コードの大枠を考えてみましょう。どらやき、喜んでもらえるとうれしいですね。
カレンダーのあるページを操作するためのオブジェクトをCalendarPage
クラスで定義します。選択する日にちは、2021年12月30日としましょう。
calendar_page: CalendarPage = CalendarPage(driver=chrome_driver) calendar_page.select_date(year=2021, month=12, date=30)
次に、日にちの具体的な選択方法を、CalendarPageの内部実装として考えていきます。カレンダーは<table>
要素を主体に自動生成されているのですが、なんと一つひとつのセルのaria-label
にわかりやすく年月日が入っています。これを活用すると非常にロケーター記述がシンプルになりますね。
driver.find_element(By.CSS_SELECTOR, 'td[aria-label="2021年12月30日"]').click()
ナビゲーション - TabPanel
次の題材はタブパネルです。次の図のように、和菓子のこだわりの原材料が列挙されたタブを、ページ切り替えなしに軽快に読めます。なぜどらやきがおいしいのか、原材料のうんちくを思う存分語りましょう。
タブパネルは、おおまかにとらえると次のような構造でWijmoが自動的に生成しています。
<div id="theTabPanel"> <div role="tablist"> <a role="tab">おまんじゅう</a> <a role="tab">ようかん</a> <a role="tab">どらやき</a> <a role="tab">きんつば</a> </div> <div> <div role="tabpanel">... ... ...</div> <div role="tabpanel">... ... ...</div> <div role="tabpanel">... ... ...</div> <div role="tabpanel">... ... ...</div> </div> </div>
では、「どらやきのおいしさの秘訣として、生地にはちみつが入っている」と記述されていることを検証するテストの自動操作コードを考えてみましょう。はちみつが入ってるとフワッフワになるんですよね(たまらん)。
タブパネルのあるページを操作するためのオブジェクトをTabPanelPage
クラスで定義します。
tabpanel_page: TabPanelPage = TabPanelPage(driver=chrome_driver) tabpanel_page.select('どらやき') assert tabpanel_page.is_included_in_selected_item(ingredient='はちみつ')
タブパネルの内容確認の操作は具体的には、タブを選択して、コンテンツが表示されているパネルの内容を見る、というものになります。role
属性だけでなく、表示されていることを示す属性値も組み合わせて、コンテンツが表示されているパネルの要素がどれなのか絞り込みましょう。
driver.find_element(By.XPATH, '//a[@role="tab" and text()="どらやき"]').click() selected_tabpanel = driver.find_element(By.XPATH, '//div[@role="tabpanel" and contains(@class, "wj-state-active")]') included_ingredients = selected_tabpanel.find_elements(By.XPATH, '//li[text()="はちみつ"]') assert len(included_ingredients) > 0 # 原料の一覧に「はちみつ」があったらOK
FlexGrid
さて、本記事 最後の題材となる、FlexGridです。非常にリッチで、自作での開発が困難そうなUI部品です。Wijmoを利用する醍醐味の一つではないでしょうか。
今回は、どらやき以外のお菓子もまとめて買ったらおみやげ代が総額でいくらになるか、単価と数量の入力から小計と合計金額を自動的に計算してくれる計算表をFlexGridで作ってみました。この計算表が想定通りに機能しているかの自動テストを、Selenium 4の新機能のひとつ「Relative Locators」と組み合わせて実装してみましょう。
FlexGridは非常に大きなDOM構造を持っていますが、おおまかにとらえると自動生成部分は次のようになっています。WAI-ARIA属性に絞って見渡すと、かなりシンプルな構造です。
<div id="theGrid"> <div role="grid"> <div role="row"> <div role="columnheader">品目</div> <div role="columnheader">単価</div> <div role="columnheader">数量</div> <div role="columnheader">小計</div> </div> <div role="row"> <div role="gridcell">おまんじゅう</div> <div role="gridcell">150</div> <div role="gridcell" aria-selected="true"> <!-- 入力中の要素 --> <input type="text"> </div> <div role="gridcell">8,100</div> </div> ... ... ... </div> </div>
この画面の自動E2Eテストとして、「数量を上のセルから下に向けて順に入力する」「小計セルの金額が正しいか確認する」「合計金額の表示値が正しいか確認する」の3点を実装していきます。
まず、数量を上のセルから順に下に向けて入力する実装を作ってみましょう。
flexgrid_page: FlexGridPage = FlexGridPage(driver=chrome_driver) # 数量を上のセルから下に向けて順に入力する flexgrid_page.input_quantity(list_of_quantity=[54, 36, 81, 72]) # 小計セルの金額が正しいか確認する subtotal_cell_values: list = flexgrid_page.get_subtotal_cell_values() assert subtotal_cell_values[0] == '8,100' assert subtotal_cell_values[1] == '6,480' assert subtotal_cell_values[2] == '17,820' assert subtotal_cell_values[3] == '12,240' # 合計セルの金額が正しいか確認する total_cell_value: int = flexgrid_page.get_total_cell_value() assert total_cell_value == '44640'
次に上記のコードの詳細実装として、Selenium 4の新機能「Relative Locators」を使って、入力しているセルの直下のセルを選択するという計算表にありがちな操作について、相対的なロケーターでの実装を試してみます。locate_with()
により相対的ロケーターのオブジェクトRelativeBy
が得られるので、さらに次のフィルターメソッドを実行してどちら方向の要素を取得するか絞り込みましょう。
-
above()
:上方向に近い要素に限定 -
below()
:下方向に近い要素に限定 -
to_left_of()
:左方向に近い要素に限定 -
to_right_of()
:右方向に近い要素に限定 -
near()
:近い要素(方向性を限定しない)
なお、Relative Locatorsには次の特徴があります。想定通りに動作しない場合は、テスト対象の画面の実装を確認してみてください。
-
near()
の「近い要素」の閾値は、既定では50ピクセルです - 領域が重なっている要素は「近い要素」として検出できないようです
今回は下方向に順に入力するので、below()
のみを使いました。
target_element = driver.find_element(By.CSS_SELECTOR, 'div[role="grid"] div[role="row"]:nth-child(2) div[role="gridcell"]:nth-child(3)') for quantity in list_of_quantity: # セルに入力可能な状態にするためダブルクリックする ActionChains(driver).double_click(target_element).perform() inputtable_cell = target_element.find_element(By.CSS_SELECTOR, 'input') inputtable_cell.send_keys(quantity) relative_by = locate_with(By.CSS_SELECTOR, 'div[role="gridcell"]').below(inputtable_cell) # 下方向に要素が存在するときのみ処理対象とする target_elements = driver.find_elements(relative_by) if len(target_elements) > 0: target_element = target_elements[0]
小計セルの値の確認も、同様にRelative Locatorsで順に値を取得してから検証してみましょう。
subtotal_cell_values = [] target_element = driver.find_element(By.CSS_SELECTOR, 'div[role="grid"] div[role="row"]:nth-child(2) div[role="gridcell"]:nth-child(4)') for index in range(4): subtotal_cell_values.append(target_element.text) # セルに入力可能な状態にするためダブルクリックする ActionChains(driver).double_click(target_element).perform() inputtable_cell = target_element.find_element(By.CSS_SELECTOR, 'input') relative_by = locate_with(By.CSS_SELECTOR, 'div[role="gridcell"]').below(inputtable_cell) # 下方向に要素が存在するときのみ処理対象とする target_elements = driver.find_elements(relative_by) if len(target_elements) > 0: target_element = target_elements[0] assert subtotal_cell_values[0] == '8,100' assert subtotal_cell_values[1] == '6,480’ assert subtotal_cell_values[2] == ‘17,820' assert subtotal_cell_values[3] == '12,240'
最後、合計金額の表示値はFlexGridの外のただの <span> 要素なので簡単に済ませます。
target_element = driver.find_element(By.CSS_SELECTOR, '#theTotal') assert target_element.text == '44640'
これでFlexGridの自動E2Eテストもなんとかできました。ところで、おみやげとはいえ、どらやき81個はさすがに買いすぎじゃないですか?
まとめ
- リッチUIを生成するJavaScriptライブラリは、自動E2Eテストが可能なものを選ぶのをおすすめします。
- WAI-ARIA属性も自動生成するJavaScriptライブラリは、自動E2Eテストの実装を容易にするケースが多いでしょう。
- これらの点は、昨今流行しつつある自動E2EテストSaaSでも同様に有利にはたらくはずなので、JavaScriptライブラリのテスタビリティには引き続き気を払っていきましょう。
- いくらおいしいおやつでも食べ過ぎには気をつけましょうね。