Holmes開発者ブログ

契約マネジメントシステム「ホームズクラウド」の開発者ブログです

TestCafeによるPage Object Patternの実装

こんにちは。Holmesでエンジニアをしている山本です。

以前TestCafeについて調べた際、TestCafeでもPage Object Patternが利用できることが分かりました。

E2Eテストとしての導入を検討しているため、簡単にではありますが、TestCafeによるPage Object Patternの実装を試してみました。

実行環境

名前 バージョン
OS Windows 10 Pro 64bit バージョン2004
Node.js 14.16.1
Yarn 1.22.5
TestCafe 1.14.0
TypeScript 3.9.7

Page Object Patternの概要

テスト対象となるページへのインターフェイスとなるオブジェクトまたはクラスを作成し、テストケースのコードと分離する手法です。

UIをページオブジェクトで隠蔽し、操作をメソッドとしてテストケースに提供することで、UIに変更があってもページオブジェクトの変更のみで対応できるようになります。

Seleniumを用いたテスト自動化において、コードの重複を減らしてメンテナビリティを高めるためのデザインパターンとして広まりました。

JavaSeleniumラッパーであるSelenideで推奨されており、GroovyのSeleniumラッパーであるGebでもファーストクラスとしてサポートされています

近年人気のCypressのブログでは、Page Object Patternの問題点を指摘されていますが、少なくともテストを都度スクリプトとして記述するよりも、メンテナンス性は高くなると思います。

TestCafeにおけるPage Object Patternの実装

ガイドページに、Page Modelとして実装例が記載されています。

今回は、弊社プロダクトであるホームズクラウドの、2021年4月末時点でのログイン画面を対象として、ページオブジェクトおよびテストケースを実装していきます。

TestCafeではJavaScriptおよびTypeScriptでテストを実装できますが、今回は型の指定が可能なTypeScriptを利用します。

実装対象画面

ログイン画面の初期表示は、ログインID入力欄とソーシャルログイン用のリンクが表示されます。

f:id:h-yamamoto_holmescloud:20210506193738p:plain
ログイン画面の初期表示

ログインIDを入力して「次へ」ボタンをクリックすると、パスワード入力欄が表示されます。

f:id:h-yamamoto_holmescloud:20210506195835p:plain
ログイン画面のパスワード入力欄

パスワードを入力し、「ログイン」ボタンをクリックすると、ログインIDとパスワードが正しければ、ダッシュボード画面に遷移します。

また、ログインの導線以外に、パスワードリセット画面へのリンクが表示されます。

ページオブジェクトの実装

まず、 pages/login.ts として、以下のファイルを作成します。なお、今回はソーシャルログインに関する要素を除外しております。

import { Selector, t } from 'testcafe';

export default class LoginPage {
  private url = 'http://localhost:8080/login';

  private loginIdInput = Selector('#username');
  private nextButton = Selector('.footer button[type=button]');

  private passwordInput = Selector('#password');
  private loginButton = Selector('.footer button[type=submit]');

  private alert = Selector('.alert');
  private passwordResetLink = Selector('a[href="/reset"]');

  async open(): Promise<void> {
    await t.navigateTo(this.url);
  }

  async toPasswordReset(): Promise<void> {
    await t.click(this.passwordResetLink.filterVisible());
  }

  async typeLoginId(loginId: string): Promise<void> {
    await t.typeText(this.loginIdInput, loginId).click(this.nextButton);
  }

  async doLogin(loginId: string, password: string): Promise<void> {
    await this.typeLoginId(loginId);
    await t.typeText(this.passwordInput, password).click(this.loginButton);
  }

  async hasError(): Promise<boolean> {
    return await this.alert.exists;
  }
}

実装例ではコンストラクタ内でSelectorを設定していましたが、TestCafeのSelectorは遅延評価されるため、privateなフィールドとして初期化しています。

UIの各要素を隠蔽し、操作をメソッドとして提供しています。また、初期ページのためURLが必要ですが、それもページオブジェクトに持たせています。

TestCafeのテストにはTestControllerが不可欠なため、当初はSelenium系のページオブジェクトを作る場合と同様、コンストラクタ引数でTestControllerを引数として受けてやる必要があるかと思ったのですが、TestCafeの場合、TestControllerをimportするだけで、適切なコンテキストが解決されるため、非常にシンプルになりました。

テストケースの実装

作成したページオブジェクトを用いて、実際のテストケースを記述していきます。TestCafe実行時に指定するファイルはこちらになります。

今回は、 features/login.ts として以下のファイルを記述します。

import { t } from 'testcafe';
import LoginPage from '../pages/login';

fixture `ログイン画面`;

// 実際は、IDやパスワードは外部ファイルから読み込みます
const loginId = 'test@example.com';
const password = 'test-password';

test('ログインするとダッシュボードが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.doLogin(loginId, password);

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/ws/dashboard');
});

test('ログインID入力前にパスワードを忘れた場合のリンクをクリックすると、パスワードリセットが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.toPasswordReset();

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/reset');
});

test('ログインID入力後にパスワードを忘れた場合のリンクをクリックすると、パスワードリセットが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.typeLoginId(loginId);
  await loginPage.toPasswordReset();

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/reset');
});

test('存在しないログインIDを入力すると、エラーメッセージが表示される', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.typeLoginId('notfound-loginId@example.com');

  await t.expect(await loginPage.hasError()).ok();
});

test('パスワードを間違えていると、エラーメッセージが表示される', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.doLogin(loginId, 'invalid-password');

  await t.expect(await loginPage.hasError()).ok();
});

実際のテストケースの流れを記述していきます。入力する値などもこちらで制御し、ページオブジェクトに渡すようにします。

今後の実装予定

今回はログイン画面のテストの例に対象を絞ったため、パスワードリセットやログイン後のページ遷移先ではURLを確認しただけですが、ページ遷移を伴うメソッドでは、遷移先のページオブジェクトを生成し、それを返すように実装していきます。

今回の例でいうと、 pages/reset.tsPasswordResetPagepages/ws/dashboard.tsDashboardPage を追加し、 LoginPage#toPasswordResetLoginPage#doLogin の戻り値としてそれぞれ返すイメージです。該当部分だけ抽出すると、以下のようになります。

export default class LoginPage {

  async toPasswordReset(): Promise<PasswordResetPage> {
    await t.click(this.passwordResetLink.filterVisible());
    return new PasswordResetPage();
  }

  async doLogin(loginId: string, password: string): Promise<DashboardPage> {
    await this.typeLoginId(loginId);
    await t.typeText(this.passwordInput, password).click(this.loginButton);
    return new DashboardPage();
  }

}

また、ログイン後の画面には、ヘッダやフレームといった共通コンポーネントが存在します。

それらもページオブジェクトとして実装し、継承やCompositeパターンを用いることで、各ページオブジェクトでのUIや操作を共通化できます。

class Header {
  ...
}

class DashboardPage extends Header {
  ...
}

感想

Page Object Pattern自体は歴史もあり、枯れたパターンであるため、特に違和感なく実装できました。

ただ、TestCafeの場合、どうしても処理の実行に asyncawait が必要になるため、以下の2点が気になりました。

  1. ページオブジェクトを生成したタイミングでのURL遷移が難しい
    • コンストラクタに async を付与できないため、今回はページオブジェクト側でURL遷移を行わせるメソッドを用意しました
    • static なファクトリーメソッドを作成し、それに async を付与することは可能ですが、やや煩雑になります
  2. Promise で値を返す必要があるため、メソッドチェーンが書きにくく、1行ずつ await して実行していく記述になりがち
    • TestController のように、 this | Promise<any> を返すようにすればできるかもしれませんが、テスト記述としては実装難度が高そうです

とはいえ、 async/await の頻出はJavaScriptベースのE2Eテストツールでは仕方のないことかと思います。また、E2Eテストはメンテナンスが難しいため、書きやすさよりも読みやすさを重視すべきですが、どちらのケースでも行単位で処理を実行していくことになるため、結果的にテストケースの可読性が上がることにもつながるかと思いました。

参考ページ

最後に

Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com