WebのテストをSeleniumで
Seleniumはスクレイピング用途でちょこちょこ使ったことはあったのですが、今回、テスト用途で使うこととなりました。
それが本来のSeleniumの用途ではあるのですが、、いざ実装してみると考えないといけないことが多数あり。せっかくなので、後日見返せるように整理しておこうと思います。
目次としてはこんな。
- テスト自動化を実装する前に考えておくこと
- 実装中に考えないほうがいいこと
- 今回実装してみたPOM実装のポイント
- 今後考えないといけないこと
以下では、POMベースで実装したWebページを操作するクラス/関数のことをパッケージと呼びます。
また、パッケージを呼び出して実際に試験を実施するスクリプトの方はテストスクリプトと呼びます。
ちなみに今回の環境は、Pythonで作っていて、テストスクリプトはpytestから呼び出していきます。まぁ、今回の記事上ではほとんど登場しませんが、一応。
1. テスト自動化を実装する前に考えておくこと
そもそもPOMやテスト自動化自体について、ぐぐってみると「手間がかかりすぎて開発自体の稼働を食いつぶしてしまう」と書かれている記事が多々。
自分で実装していても、ほんとにそうだなぁ、、と痛感しました。
実装しようと思えば際限なく実装できてしまいますが、試験自体が開発の目的ではないので、適切なところで割り切らないときりがないです。
そこら辺に関する考え方を整理してみます。
1-1. 何を試験するのか?試験のジャンルは?
端的に言うと、実施する試験のジャンルで実装すべきパッケージの作りが変わってくる、という話です。
試験のジャンル
試験のジャンルについては権威ある情報が見つけられていないのでなんともなのですが、、今回用にググった際はこちらのV字の図がわかりやすかったです。
とはいえ、、、なかなかこうキレイには行かず。
https://service.shiftinc.jp/column/3659/
また、この粒度で分けれたとしても、ユニットテストの中には正常系試験のようにエラーなどの割り込みが入らないことを前提としてしまっていい(いいというか、割り込み入ったら即fail判定しちゃえばいい)ものと、境界値分析のようにエラーが出ること自体を確認する試験が混ざってきます。
また結合試験も、Webアプリの中で見た結合試験(DBとの連携など)もあれば、システム全体としての結合試験もあります。
POM実装するパッケージに求められる機能も、試験のジャンルによってかなり変わってきます。
試験のジャンルとPOM実装
例えば結合試験を実装する場合は、データを登録する、登録したデータを他の機能から呼び出す、などの処理をそれぞれ1つずつのクラスや関数として実装して、それらを順番に呼び出していく実装になるかと思います。
一方、ユニットテストをする場合は、もっと細分化された関数群を実装しないといけません。データを登録するという粒度ではなく、値を入力欄に入れる、ラジオボックスをクリックする、Commitボタンをクリックする、などを個別で実施できるように細切れの関数として実装していくことになります。さらに境界値分析ベースの試験となると、異常データを入れたときにだけポップしてくるウィンドウの表示を回収する関数も必要になってきます。
細切れにする事自体はさして手間ではない(むしろコードがキレイになるので、望ましい)のですが、異常系を考慮しないといけなくなるというのがきつくて、実装の手間が一気に膨れ上がります。
試験ジャンルと実装のまとめ
試験項目で一番細かい操作が何になるかを確認して、それに合わせた粒度で作る。
異常系の処理は、試験項目に含まれない部分は割り切って実装しない、がポイントでしょうか。
1-2. Pageの定義
「Page」とは?
Page Object Modelを実装するのですから、「Page」の概念が重要になってきます。
なのですが、改めて「Pageって何?」と考えると、結構悩ましいです。
URLが振られている単位をPageとして捉えようとすると、シングルページデザインの場合に困ります。
また、データ入力ボタンを押すときにポップしてくる入力ウィンドウなどの場合は、それ自体が多数のElementを持っているので個別のPageとして扱いたくなってきますが、HTML的には元ページのElementの1つで入力時だけ非表示が解除される実装だったりすることもあるわけで、個別で扱うのはだめじゃないか、、?という気もしてきます。
で、どう定義する?
各Pageの実装は結構込み入ってくるので、積極的に細切れにしていくとコードが書きやすかったです。
今回のケースではこんな感じにしました。どうまとめるかというか、どう鋏を入れて別Pageとして区切るかの考え方ですね。
- 同時に表示できない画面は別ページ
- ポップウィンドウも、個別のPageとして扱う
- 元ページの上に表示されるが、アクセスできるのはウィンドウ側だけ→元ページと別ページとして扱う
- 他のPageとは実装すべき関数が結構違ってくるので、Pageクラスを継承するWindowという専用の抽象クラスを用意する
- OK/NG確認のようなシンプルなウィンドウは、元ページのElementとして扱う
- ポップウィンドウも、個別のPageとして扱う
- 同時に表示できる画面でも、どの画面でも共通で表示されるバーなどは1つの独立したPageとして扱う
- 各画面に遷移するボタンなどが配置される重要部分だったりする
- Elementとして定義して各Pageで呼び出してもいいが、そうすると「このページにジャンプしたい」というときにどのPageのElementを呼び出すか?と迷うことになる
あるいは、Webサイト自体のソースコードの作りと合わせるとキレイになる気はするのですが、今回はそちらには踏み込めてません。
1-3. POMパッケージとテストスクリプト実装時の割り切り
POMパッケージと、それを組み合わせて試験項目を実装していくテストスクリプトと分けて考える必要があるのですが、それぞれで大事だったかなと思う考え方をまとめます。
POMパッケージ
- 使わないページや機能のパッケージは実装しない
- 試験項目が増えた場合など、必要になったときに実装する
- 実装の考え方が整理できていれば、後付の追加はそれほど大変じゃないです
- でも、作ることにした機能はしっかり作る
- テンプレート化できるものはテンプレート化する(抽象クラス)
- Page,Element,Locatorの分類はきっちり守る
- 例外処理は手を抜く
- エラーがエラーとして表示されないと試験にならないケースもあるので、例外処理で隠蔽してしまうと逆に困る
- 例外処理は、正常なのかエラーなのかの判定と合わせて、テストスクリプト側に判断させる(PageやElementでは原則例外処理は実装しない)としてしまうくらいが楽
- PageとElement側で、自身の状態を確認する関数だけはざっくり作っておく(これをテストスクリプトで使用して成否判断をする)
- 汎用性は程々で諦める
- XPATH(Locator)のこと
- ある程度DIVの階層が変わっても大丈夫なように書くけど、厳密に考えるときりがない
- Webのソースコードに合わせてしまうとキレイにできるかもですが、今回はそこまで踏み込めていません
テストスクリプト
- パッケージの各機能は、正常に動作すると信じる
- 結構割り切って実装するので、パッケージを使うときに怖さを感じるケースは出てくるのですが、、
- パッケージを疑い始めると、なんの試験をしているのかわからなくなります
- パッケージが間違っていれば他の試験でFailが出て気がつけるはずと割り切ります
- 例外処理は手を抜く
- あるスクリプトで画面遷移やデータの入力・削除の歯車がずれると、続くテストの動作が想定外になることがありますが、、
- 都度例外処理を頑張るのではなく、一括で後始末すればいいやと割り切ります
- 具体的には、テスト群を小分けにしておいてTear DownとSet Upの処理をしっかり実装します
- 想定外の動作になればテスト群のなかでFailする項目が出て気がつけるはず、、
- Failが出たら、調整後にそのグループの試験だけ再実行すればいいだけ
- ただし、データを壊してしまうような致命的な暴走だけは例外処理で引っ掛けて、テスト自体をとめてしまう(pytestのrequest._pytest_stop = Trueとか)
- 結果は人間にも確認させる
- 「OK」が表示されていればassert Trueとするみたいな実装は当然しますが、、
- 変な場所に表示されているとか、別の場所の「OK」の文字列を掴んでしまっていて、みたいなことがあります
- スナップショットを細々撮っておいて、最後は人間の目で確認させればいいや、と割り切ります
- テストスクリプト自体のデバッグをしやすい方法を考える
- パッケージもテストスクリプトもそれなりに複雑になるので、テスト自動化側自体がバグを多数出します
- デバッグに結構な稼働がかかるので、処理を簡略化して再実行できる仕組み(Tear Downしないで次のSet Upを省略できるようにするなど)を用意しておかないと辛い
- 別の方法(Selenium使わない方法)でできることは、別の方法を使う
- 例えばSet Upの処理について、試験に使うデータを事前に登録する作業が必要になったりします
- POMパッケージで実施することもできますが、POMが呼び出すWebページにバグがある場合にデータ登録ができず、試験自体が始められなくなります
- データの書き込み画面を呼び出していくので、時間もかかる
- DB側のデータ差し替えなど、別の方法があるならそっちを使う方が効率的
2. 実装中に考えないほうがいいこと
実装中は「これ意味あるのか、、?」という自問自答に度々苛まれました。
結構な手間と時間がかかるので、そういう疑念がじわじわと累積していって、モチベーションをガンガン削っていきます。
結論としては「別途考える(実装中は考えない)」と割り切るしかないのですが、メモっておきます。
- これ安定して動くのか?
- 1.3と同じなのですが、例外処理を考えるときりがないです
- 「致命的になるケース以外はフォローしない」と割り切ります
- この試験は人間がやったほうが早くないか?
- 試験は同じ内容を膨大な回数繰り返すという前提はありますが、、
- それでも割に合わんだろ、と感じる箇所もでてきます
- とはいえ、どこで線を引くかを考え始めるときりがない
- 技術的に実装できる項目は実装する前提で考えます
- 全部の処理を自動化しきれなくても、一部の処理(スクリーンショットの取得とか)ができるだけでも効率化になると信じて、、
- 自作部分の試験をしているの?フレームワークの試験をしているの?
- HTMLを自分で書ききるということはあまりなく、フレームワークが生成しています
- Elementにちゃんと値が入るかどうかの試験をしていると、「フレームワークにバグがなければちゃんと入るだろ/そもそもこの確認はフレームワークの開発者がやるべきことだろ/てか実施済みだろ?」という考えが頭をよぎります
- とはいえ、ほんとにフレームワークにバグがあることもあるわけで、、
- 「ソースコードを加味した試験のあるべき姿」は改めて考えたいところですが、少なくとも実装中に考えることではないので、封印します
- 試験項目足りてるのか?
- スクリプトに落とし込んでいると見落としている観点に気がつくことがあります
- フィードバックはすべきなのですが、実装中に試験項目作り変えていると切りがないので、メモるだけメモって、後回しにします
3. 今回実装してみたPOM実装のポイント
実際のコードを上げるというわけにも行かないので、ポイントをまとめます。
改めて、Zabbixあたりの厄介な操作が結構入り組むのサイトを題材に実装してみるというのは時間ができたらやってみたいですが、時間できないだろうなぁ、、、
3-1. テンプレート(抽象クラス)の利用を徹底する
似たような作りのPageは少なからず出てきます。
Elementについては、フレームワークが自動生成するので、どのPageでも同じ様なElementが多数登場します。
Locatorは値自体はPageごとにバラバラになるのですが、Elementごとに何のLocatorを定義しないといけないかは共通だったりします。例えばメニューダウン(選択肢から選ばせる)の場合は1つのElementに見えますが、実際は▽ボタン、選択肢群、(実装している場合は)アラートポップアップなどの複数の子Elementの集合体になったりしています。これらの根っこになる親はメニューダウンごとに異なりますが、親の配下の子の相対XPATHは共通という場合は、親のXPATHを引数として子のXPATHを生成する関数を実装できます。
ということで、Page,Element,Locatorについて、3つとも抽象クラスを作っておいて、各ページでは抽象クラスを継承して実装する、とすると再利用が効いて楽でした。
尤も、どのページとどのページが似ているというのは事前に考えるのが難しく、実装を勧めながらつどつど整理してテンプレート化していった、というのが実情ですが。
3-2. モジュールの階層構造
かなり乱暴ですが、Page同士に階層の概念をもたせて配置しました。
こんな感じ。
- login(Directory)
- page.py
- element.py
- locator.py
- search(Directory)
- page.py
- element.py
- locator.py
- data_insert(Directory)
- page.py
- element.py
- locator.py
- add_window(Directory)
- page.py
- element.py
- locator.py
- side_bar(Directory)
- ...
そもそもページ同士の階層て何だろうという話から考えないといけなくなるのですが、今回の試験対象はなんとなく違和感ない自然な階層配置ができたので、それに従いました。
一般化すると、あるページからポップするウィンドウ(今回の指針では個別のPageとして扱うことにした)くらいは子ディレクトリに配置する、それ以外のページ同士はフラットに横並べにする、くらいが妥当かもしれません。
ちなみに全てのpage.py(の中身の、ページに対応するクラス)は__init__.pyで設定して、階層問わずmodule.<クラス名>で呼び出せるようにしています。
elementは、結合テストクラスの粒度だと呼び出す機会はないのですが、ユニットテストだと直接呼び出さないとやりづらくなってきます。とはいえ名前重複させたいクラスが多数出てくることもありますので、フルパス指定しないと呼び出せないようにしています。ここは<ディレクトリ名>.elementでインポートできるようにしてあげてもいいかな、、というところです。
locatorは、テストスクリプトを組む段階で呼び出す必要はないのですが、パッケージを作る段階ではデバッグで多々呼び出すことになります。ということで、これもelementに準じた扱いをします。
3-3. Page/Element/Locatorの境界線
境界線を明確にしておかないと、コードがぐちゃぐちゃになってきます。
POMモデルが提唱する考え方とはあってないところがあると思いつつですが、、今回はこんな感じ定義しました。
ElementとLocatorの境界
Elementでは絶対にXPATHを直書きしない。全部Locator側に書いて、ElementはLoactorのクラスインスタンスを呼び出して使う。
LocatorはWebDriverを使う関数は一切使わない。というか、Driverインスタンス自体をLocatorクラスは受け取らない。
ここ線引きは結構キレイにできます。
これが担保できていれば、Elementの再利用性が維持できます。
PageとElementの境界
Elementが実施する作業をPageに染み出させないルールは明瞭に定義できるのですが、逆が結構悩ましくなります。
WebDriverの関数(driver.find_elementなど)はElement側でしか呼び出さない。これは明瞭。
一方、ロジック(雑な表現ですが、、)をどっちで実装するかは、結構グレーになります。
例えば、「検索するために、検索文字欄のElementに〜の文字列を打ち込んで、検索開始ボタンのElementをクリックする」というような実装の場合、検索文字列と検索開始ボタンをそれぞれ別のElementとして実装し、Page側で一連の作業を実装するのがPOMの基本なのかなと思うのですが、、
場合によっては、検索欄と検索開始ボタンをセットにして、「検索Element」のような抽象的な1つのElementとして実装するほうがやりやすいこともあります。特に一連の動作で必ずセットで動作するElementは、変に細切れにせず、Element側でまとめて実装してしまうほうがコードも書きやすいですし、Page側もスッキリします。
キレイな言葉には落とせませんが、「Elementは几帳面に小分けにはしすぎず、用途ベースでグルーピングして、抽象的なElementとして実装する」という方針を取りました。言い換えると、ロジックはPageからElementに染み出してもいいやと割り切りました。
疑似エレメント
「ページをリロードする」というような操作をしたくなるときがあります。
例えば開いているWindowを閉じないと画面遷移できないが、処理実施段階でWindowが開いているか閉じているかわからない(前試験が想定通り動作していれば閉じているはずだが、Failしていると開きっぱになってるかも)というときに、どっちでもいいように画面をリロードしてしまってから画面遷移をかける、など。
処理としてはdriver.refresh()で実施できるのですが、上記の「Deriverの関数はElementからしか呼び出さない」の方針に乗っ取ると、呼び出し方をひと捻りしないといけません。
これを解決するために、画面には存在しない疑似エレメントを実装してしまって、疑似エレメントの関数としてrefresh()を実装しておく、とすると便利でした。
あるいは別Pageへのジャンプも疑似Elementに実装しておくと便利かもですが、今回の私の事例ではそれを使うケースはありませんでした。
3-4. Pageに共通的に実装する関数
3-1の通りテンプレートを多用するのですが、テンプレート(継承元の抽象クラス)で実装しておくと頭の整理がしやすくなる関数をまとめておきます。
- home
- そのページを開く関数です
- 単純にURLを叩いてアクセスできるページならシンプルなのですが、ページによっては「このページを開いたら下にあるこのボタンを押して、上がってくるポップで〜を選んで、、」のような長旅になる操作が必要になるものも出てきます
- 実装はページごとにバラバラになってきますが、いずれにしても試験上絶対に必須になる操作なので、抽象関数として枠を作っておきます
- 名前はgotoやvisitと呼称してもいいのですが、なんとなく趣味です
- 「どの画面でどの作業をしていても、homeを呼び出したら必ずそのpageに移動できる」という実装にする必要があります
- 試験中は画面が想定外の状態になることがありますが、次の試験項目では想定の状態にしてやらないといけません
- 例えば、前試験でポップが開きっぱなしになっているのでサイドバーが開けず、画面遷移ボタンが押せない、という状態は避けないといけません
- home操作の頭で画面のrefreshをかけるなど、そういう状態から抜け出せるひと工夫を可能な限り詰め込んでやる必要があります
- is_home
- 自分が今そのページにいるかどうかを確認する関数です
- homeが長旅になる場合や、複雑な試験をすると、操作の歯車がずれて見当違いの画面にいってしまうことがあります
- パッケージで実装している関数は、当然「想定している画面にいる」という前提が崩れたら動作しませんので、つどつど操作前にこの関数を呼び出して、歯車がずれてないかを確認する必要があります
- 試験毎に毎回トップページから旅してくると時間がかかるので、前試験ですでに想定の画面にいたならhomeはスキップする、という実装にしておくと非常に快適になる、というのもあります
- 1-3の「例外処理は割り切る」と若干矛盾しますが、このくらいは実装してやらないときついです
- 実装の仕方は、Elementで説明するBannerという疑似エレメントで説明します
- reset
- 今回は実装していないのですが、この記事を書いていて必要性を感じたので追記です
- homeで記載しましたが、次の試験で適切な画面に遷移できないといけません
- 開きっぱのポップがあたら消すなど、この画面から別のPageに遷移できる準備をする関数を用意しておいて、各試験の最後に呼び出してやると便利かもしれません
- search, add, etc,,,
- あとは試験対象にするサイトのグループごとに、更に子抽象クラスを作って実装していきます。
- クラス初期化に対する留意点
- 「一覧で表示されるデータの中の1つに対する変更ウィンドウ」のように、動的に作られる/変数ありきで一位特定されるPageというものもあります
- そういうページは、初期化の際に該当の変数を受け取るように実装します
- 受け取っておいた変数をhomeの中で呼び出して、適切なボタンを選ばせて、該当のPageを表示させます
3-5. Elementの実装単位
グルーピング
-
前述のとおり、毎回セットで使うElementは1つのエレメントとして束ねてしまいます
- inputなどで、想定外の値を入れるとアラートウィンドウがポップする、という実装の場合は、アラートウィンドウもセットに含めてしまいます
- アラートが出ているかどうかはis_alerted_XXXのような関数で呼び出せるようにしておきます
-
グループ内で、「現状の入力値」などの状態を保持させます
- ラジオボックスやメニューダウンのように、「今どの値が選ばれているか」の取得がやりにくいElementもあります
- やり難いというか、都度プルダウンを引き出すので時間がかかってしまうとか、余計な操作のせいで試験の状態が想定外になっちゃうとか
- 初期値や入力用の関数に渡された値をElementインスタンス内の変数として保持しておいて、getなどの関数で呼び出せるようにしておくと便利です
- 但し、本当に入力されている値とずれてくるケースもありますし、実際の値を確認しないと試験にならない項目も出てきますので、生の値を確認して返すget_rawなどの関数も用意しておく必要があります
疑似エレメント
- Banner
- そのPageに行ったら必ず表示される、他のPageでは絶対に表示されない、というElementです
- 画面上部のヘッダ部の文字列になることが多いかと思います
- Page側のis_homeは、Bannerの有無で判断します
- また、試験ではあるElementに対して、「データを入力したあとにフォーカスアウトする」という操作が必要になることがありますが、そういうときは、このBannerにfocusする、という方法で実装すると楽です
- 画面上にはこのElementに対応する実体が存在するのですが、Bannerとして使うということで専用の別名をつけてやる、というイメージです
- Browser
- refreshなどのdriverのその他関数を呼び出すためのElementです
- 画面上には該当するElementが存在しない、本当に疑似のElement
3-6. テストスクリプトからの呼び出し
結合試験相当はPageだけ呼び出す
- 大体Pageに実装する機能の組み合わせで試験シナリオを実装しきれるかと思います
- 逆に言うと、それくらいの機能まではpage側に実装しないといけません
ユニット試験相当は、Elementも呼び出す
- 境界値試験のように、特定のinputに想定外の値を入れた際にアラートが出るか見るなどをする場合、それに相当する機能をPageに作るのではなく、Elementを直接呼び出してしまう、という方法を取るほうが楽
- Pageのhome機能で所定の画面に遷移して、そこからはその画面固有のElementで詳細な試験動作を組み上げていきます
Pageだけで試験を組めるようとすると、Elementで実装している機能1つ1つを呼び出す機能をPage側に実装しないといけなくなるのですが、まだるっこしくなるだけでメリットは特に感じられなかったので、Elementを直接呼び出しちゃってもいいやと割り切りました。
4. 今後考えるべきこと
4-1. 別のモデル
POMをディスっているようなタイトルなのですが、、
実際はSeleniumとCypressの機能の差異の話のような感じで、モデルとしてPOMに変わるのものを提唱しているわけではない、、と理解しましたが、あってるかな。。
https://www.cypress.io/blog/2019/01/03/stop-using-page-objects-and-start-using-app-actions
CypressはPOMで作ることになる関数や、独自の関数を組み込める枠を予め用意しておくので、POM用の追加パッケージは作らず、用意している分でやりくりすればいいじゃん、って感じでしょうか。
「テストにおいては再利用性よりも可読性を優先しよう(変にテンプレート作らず、ベタ書きするようが扱いやすい)」など、同感できるアイデアも多いのですが、現状の私の経験値だとどっちがいいかは判断つけかねますね。
もうちょっと経験溜まってからまた考えてみようかと思います。
あるいは、Webブラウザを乗っ取るツールとしてのSelenium以外の選択肢という意味では、Seleniumでつかめないデータなど困るケースはいくつか出てきているので、他の方法の評価はしたいなとは思っていますが、少なくともこの記事についてはモデル自体を考え直さないといけないというわけではなさそうですね。
4-2. PageとElementの線引き
現状の考え方だと破綻するサイトがあるんじゃないかな、、という気がしています。
これも、経験値をためつつまた改めて考えようと思ってます。
4-3. 試験ありきのWeb開発
そもそもXPATH頑張らなくても指定のエレメントつかめるように名前を振っとくとか、ソースコードの構造に合わせてページを定義するとか、開発フェイズでひと手間かけていれば試験が大幅に楽になるよな、、という事項が多々あります。
開発サイドに踏み込んだ上で改めて思考を整理していきたいですが、今後の課題ですね。
まとめ
割り切りと手抜き大事。
以上。