SPAとページオブジェクトパターン
ページオブジェクトパターンとは、ブラウザテストの自動化でよく使われる手法の一つです。UI操作をオブジェクト指向におけるクラスで抽象化&共通化することによって、テストシナリオ側のコードが理解しやすくなったり、UI変更による影響範囲が一箇所にまとまってメンテナンス性の向上、コードの重複を削減することができます。Seleniumの公式サイトや本連載の2回目でも説明されているので、詳細はそちらをご覧ください。
SPAは、シングルページとはいってもブラウザによるページ遷移がないというだけで、画面上での動的な書き換えによるアプリケーション内でのページ遷移は存在します。ユーザー視点では、通常のWebサイトやアプリケーションと同様にページがあるということです。なので、ページオブジェクトパターンを使うメリットはSPAでも変わらず存在します。ページの単位をどうするかということが気になると思いますが、これまで通りユーザー視点でページと認識する単位で問題ないです。
SPAのUIの特徴としては、ページ間で共通のコンポーネントが多いということです。例えばヘッダーやサイドメニューを残したままでコンテンツ部分のみ書き換えるといったように、ページの部分的な書き換えが多いです。なので、ページオブジェクトのクラスを作成する上で、ページ間の共通部分をどうするかということが課題になります。
これに対処するために、ページをさらに分解して、コンポーネント単位でクラス設計をしていきます。「えっ、ページオブジェクトってページ単位でクラスにするものじゃないの? ページ以外をクラスにしてもいいの?」と思われるかもしれませんが、SPA向けに限らず、まったく問題ありません。ユーザー視点でUIを構造化することによって、理解しやすさとメンテナンス性を向上させることがページオブジェクトパターンの目的だからです。
例として、以下のようなページAとページBがあるとします。
ヘッダーとサイドメニューは同じものとします。このとき、ヘッダーとサイドメニューそれぞれをコンポーネントとして切り出してクラス化します。
public class Header {
private final WebDriver driver;
public Header(WebDriver driver) {
this.driver = driver;
}
// ヘッダー部分で提供されている機能を実装する
}
public class SideMenu {
private final WebDriver driver;
public SideMenu(WebDriver driver) {
this.driver = driver;
}
// サイドメニューで提供されている機能を実装する
}
そして、ページAとページBを表すクラスからは、作成したコンポーネントクラスを通じてヘッダーとサイドメニューにアクセスするようにします。
public class PageA {
private final WebDriver driver;
public PageA(WebDriver driver) {
this.driver = driver;
}
public Header getHeader() {
return new Header(driver);
}
public SideMenu getSideMenu() {
return new SideMenu(driver);
}
// ページAのみの機能を実装する
}
public class PageB {
private final WebDriver driver;
public PageA(WebDriver driver) {
this.driver = driver;
}
public Header getHeader() {
return new Header(driver);
}
public SideMenu getSideMenu() {
return new SideMenu(driver);
}
// ページBのみの機能を実装する
}
今回は書いていませんが、コンテンツ部分をクラス化して切り出すことももちろんできます。
今回はページクラスからコンポーネントクラスのオブジェクトを返すように実装しましたが、言語によってはtraitのような仕組みで委譲することもできます。Javaでも本連載2回目の「複数画面で共通している部分の対応」で書かれていたように、Java 8で導入されたinterfaceのdefault methodで同じことができます。こちらのほうがテストケース側のコードではコンポーネントを意識せずに綺麗に書けると感じるかもしれません。
一方で、親クラスを作って継承する方法を考える人もいるかもしれませんが、Javaの単一継承のような仕組みだとサイドメニューはページによって共通ではないようなケースで破綻するので、注意が必要です。
ページの読み込み完了判定
E2Eテストの自動化をしていると、ページの読み込み完了を待ちたいということがあります。不安定性への対処で書いたような要素の待ち処理があればある程度は安定するのですが、一方で、ページ遷移前の同じ要素を取得してしまう場合などがあるので、ページの読み込みが完了して操作してもいい状態になっているかどうかを判定したい場合があります。
完全に静的なページの場合、JavaScriptでdocument.readyStateがcompleteになるのを待てばページの読み込みが完了していると判定することができます(もちろん、その前のページがアンロードされていることが前提となります)。
public void waitPageLoaded() {
long timeOutInSeconds = 10;
WebDriverWait wait = new WebDriverWait(driver, timeOutInSeconds);
wait.until((Function<WebDriver, Object>) webDriver ->
((JavascriptExecutor) webDriver).executeScript("return document.readyState").equals("complete"));
}
しかし、SPAのようにフロントエンド側で非同期的にページを組み立てる場合はこのような方法はうまくいきません。
この対処として、URLの変更を待つという方法が考えられます。SPAの特徴で書いたようにSPAでもページ遷移時にURLは変化するからです。しかし、URLの変更はページの再描画のトリガーであって完了を意味するものではないことが多いので、この方法はうまくいきません。
おすすめするのは、Webアプリケーション側でユーザーに読み込み完了をフィードバックするための要素を用意して、それをテストでも利用することです。例として、筆者のケースだと、読み込み中を表す要素が表示されなくなるまで待つ処理を入れました。
public void waitPageLoaded() {
WebDriverWait wait = new WebDriverWait(driver, 10);
// ページ書き換え中は"loading"というクラス名を持つ要素でユーザーに読み込み中であることをフィードバックしている
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".loading")));
}
場合によってはテスト用に見えない要素をページ読み込み完了時にDOMに追加する(もしくは削除する)という手もあると思います。しかし、機械的に画面上の要素からページの読み込み完了を判定できない場合は、ユーザーにとってもわかりづらい状況になっている場合が多いです。なので、できればユーザーにページ読み込み完了のフィードバックを用意して、それを自動テストでも使用するほうが理想的だと思います。
まとめ
今回紹介した問題と対策は必ずしもSPA固有のものではありません。動的なページ書き換えの多いWebページであればSPAでなくても不安定なテストに対処していく必要がありますし、ページ間の共通部分が多いサイトであればページオブジェクトを階層化して共通化を行っていくとメンテしやすくなると思います。
今回の記事では、SPAと題しつつもできるだけ汎用的に役立てやすい知見を中心に書きました。少しでも読者のみなさまの役に立つ部分がありましたら幸いです!
