Design Pattern for Playwright End-to-End Testing

に公開

この記事では、Playwrightを使用したend-to-endテストのデザインパターンを紹介します。このパターンはPage Object Modelを拡張したもので、テストコードの可読性の向上と、テストシナリオやテストデータのバリエーションの増加に対するテストコードの増加を抑止することを目的としています。このパターンはSVQKで採用されています。また、このパーンの動作可能な実装例と実行結果を以下のリポジトリで公開しています。

テストの構成

まず、Playwrightを使用したend-to-endテストの構成を以下に示します。

end-to-end Test Runtime

  • TestRunner
    Playwrightのテスト実行エンジンです。テストコード(TestCode)を呼び出し、テストシナリオの開始、進行、完了を管理します。 実装上はPlaywrightが提供する test 関数です。
  • TestCode
    テストシナリオやテストケースの実装本体です。ユーザー操作や期待する挙動を記述し、ブラウザ制御をBrowserControllerに指示します。
  • BrowserController
    Playwrightのブラウザ操作APIを利用してブラウザ(Browser)を直接制御します。ページ遷移、ボタン操作、要素検証などを担当します。 実装上はPlaywrightが提供する page インスタンスです。
  • Browser
    Playwrightによって制御されるブラウザインスタンスです。ユーザーインターフェースを持ち、実際にテスト対象アプリケーションへアクセスします。
  • TestSubject
    テスト対象となるWebアプリケーション本体です。ブラウザ経由で外部から操作され、期待される挙動を検証されます。

コンポーネント構成

次に、前述のTestCodeのコンポーネント構成を以下の様に定義します。

end-to-end test component structure

  • Spec
    Playwrightのテストのエントリーポイントとなるコンポーネントです。Factoryを使用してModel(テストデータ)を生成し、Facade、PageObjectを使用してテストシナリオを実行します。
  • Facade
    PageObjectを使用して複数の画面に跨った一連の画面操作と期待値の検証を実行します。
  • PageObject
    PageElementを使用して1つの画面内の一連の画面操作と期待値の検証を実行します。 画面ごとに1つのクラスを、同一画面内の一連の操作ごとに1つのメソッドを定義します。
  • PageElement
    BasePageElementが提供するAPIを使用して画面の操作と期待値の検証を実行します。画面ごとに1つのクラスを、画面要素 + 操作 / 検証ごとに1つのメソッドを定義します。
  • Factroy
    テストで使用するModelの生成を行います。

以降では、それぞれのコンポーネントについて詳細に説明します。

Spec

Specは、テストシナリオの実行を担うコンポーネントです。 Specには、テストシナリオをPlaywrightのテストケースとして実装します。 テストケースには、テストシナリオに沿った画面操作と期待値の検証の処理をFacace・PageObjectを使用して実装します。ここでは例として、「タスクの登録」シナリオを実装したSpecを挙げます。シナリオで実行する画面操作の概要は以下の様なものです。

  1. ユーザー: adminを使用してログインする。
  2. プロジェクト: Demo project を選択する。
  3. タスクを登録する。

このSpecの実装は以下の様になります。

import { DryRun } from '@arch/DryRun';
import LoginFacade from '@facades/LoginFacade';
import ProjectFactory from '@factories/ProjectFactory';
import TaskFactroy from '@factories/TaskFactory';
import UserFactory from '@factories/UserFactory';
import TopPage from '@pages/top/TopPage';
import { test, expect } from '@playwright/test';

test('Register a task', async ({ page }) => {
  const dryRun = DryRun.build();
  // Generate objects used in the scenario
  const topPage = new TopPage({ page, dryRun });
  const loginFacade = new LoginFacade();

  // Generate models used in the scenario
  const admin = UserFactory.createAdmin();
  const demoProject = ProjectFactory.createDemoProject();

  // Log in as an admin user and select the demo project
  const projectPage = await loginFacade.loginToProject(topPage, admin, demoProject);

  const taskInputPage = await projectPage.gotoTaskInputPage();

  const task = TaskFactroy.createTask();
  await taskInputPage.input(task);
});

上記の様に、画面要素単位の操作ではなくFactoryやFacade・PageObjectを介した操作を実装することで、シナリオで使用するテストデータ(Model)やシナリオ内の画面遷移が実装上で明確になります。

PageObject

PageObjectは、画面内の一連の画面操作と期待値の検証を担うコンポーネントです。 PageObjectには、これらを行うメソッドをPageElementを使用して実装します。ここでは例としてログイン画面のPageObjectを挙げます。

import ChangePasswordPage from '@pages/changePassword/ChangePasswordPage';
import LoginPageElement from './LoginPageElement';
import BasePageElement from '@arch/BasePageElement';
import TopPage from '@pages/top/TopPage';
import { UserModel } from '@models/UserModel';

export default class LoginPage {
  private readonly loginPageEl: LoginPageElement;

  constructor(page: BasePageElement) {
    this.loginPageEl = new LoginPageElement(page);
  }

  async login(user: UserModel) {
    await this.loginPageEl.inputUserName(user.username);
    await this.loginPageEl.inputPassword(user.password);
    await this.loginPageEl.clickLoginButton();
    return new TopPage(this.loginPageEl);
  }

  async firstLogin(user: UserModel) {
    await this.login(user);
    return new ChangePasswordPage(this.loginPageEl);
  }
}

PageObjectのメソッドには、1つの画面内の一連の操作をPageElementを使用して実装します。PageElementを使用することで、PlaywrightのAPIも画面要素のselector等の物理情報も意識せずに実装することができます。また、メソッドの実行語に画面遷移を伴う場合は、遷移先のPageObjectをreturnします。これにより、PageObjectを使うSpec、Facade側の実装上で画面遷移が明確になります。

PageElement

PageElementは、画面要素に対する操作と期待値の検証を担うコンポーネントです。 PageElemntには、これらを行うメソッドをBasePageElementを使用して実装します。1つの画面内の画面要素に対する操作を1メソッドとして実装します。ここでは例としてログイン画面のPageElementを挙げます。

import BasePageElement from '@arch/BasePageElement';

export default class LoginPagePageElement extends BasePageElement {
  get pageNameKey() {
    return 'login';
  }

  async inputUserName(userName: string) {
    await this.inputText('#username', userName);
  }

  async inputPassword(password: string) {
    await this.inputText('#password', password);
  }

  async clickLoginButton() {
    await this.click('#login-form input[name="login"]');
  }
}

BasePageElementを使用することで、開発者はPlaywrightのAPIを意識せず、CSS Selector等の画面固有の情報のみを使って実装することができます。

BasePageElement

BasePageElementはPageElementの基底クラスです。サブクラスであるPageElementに対し、Testing FrameworkのAPIを使用して画面要素を操作するメソッドを提供します。メソッドの引数は、画面要素の特定するための情報や入力値などです。BasePageElementを定義する目的は以下の通りです。

  • 開発者がPlaywrightのAPIを意識せずにPageElementを実装可能とする。
  • 画面要素の特定方法(CSS Selectorを使用する、など)をテストコード全体で標準化する。
  • 画面で標準のHTMLでは提供されない複雑な入力コントロールを使用している場合、
    その入力コントロールに対する一連のAPI操作を共通化・再利用する。

BasePageElementの実装例は以下の通りです。

import { expect, type Page } from '@playwright/test';

export default abstract class BasePageElement {
  page: Page;

  constructor(page: BasePageElement | { page: Page }) {
    this.page = page.page;
  }

  protected async open(path: string) {
    await this.page.goto(path);
  }

  protected async inputText(selector: string, value: any) {
    await this.page.locator(selector).fill(value.toString());
  }

  protected async select(selector: string, value: any) {
    const select = this.page.locator(selector);
    await select.click();
    const ariaControls = await select
      .locator('div.ng-input > input')
      .getAttribute('aria-controls');
    await this.page.locator(`#${ariaControls}`).getByText(value.toString()).click();
  }
}

select メソッドがこのような実装になるのは、OpenProject内で使用されるセレクトボックスが以下の様なHTML標準ではないUIコンポーネントで実装されているためです。

  • 閉じた状態のセレクトボックス
    Select Close
  • 開いた状態のセレクトボックス
    Select Open

Facade

Facadeは、複数の画面に跨った一連の画面操作と期待値の検証を担うコンポーネントです。 Facadeには、これらを行うメソッドをPageObjectを使用して実装します。 メソッドは、開始時点の画面のPageObjectが引数に、終了時点の画面のPageObjectが戻り値となるように実装します。

import ProjectModel from '@models/ProjectModel';
import { UserModel } from '@models/UserModel';
import TopPage from '@pages/top/TopPage';

export default class LoginFacade {
  async loginToProject(topPage: TopPage, user: UserModel, project: ProjectModel) {
    const loginPage = await topPage.open();
    await loginPage.login(user);

    const projectPage = await topPage.selectProject(project);
    projectPage.gotoWorkPackages();

    return projectPage;
  }
}

Factroy

Factoryは、テストデータ(Model)の生成を担うコンポーネントです。Factoryを使用することで、複数のシナリオでのテストデータの再利用が可能になります。ここでは例としてユーザー情報を生成するFactroyを挙げます。

import { UserModel } from '@models/UserModel';

export default class UserFactory {
  static createAdmin() {
    return {
      username: 'admin',
      password: 'admin'
    } as UserModel;
  }
}

おわりに

この記事では、Page Object Modelを拡張したデザインパターンを紹介しました。このパターンはPage Object Modelに比べコンポーネントの構成は複雑になりますが、テストシナリオ・データパターンを増やす際には可読性を保ったまま少ない実装量で対応できます。SVQKではこのパターンの参照実装とこれらのコンポーネントを自動生成するツールも合わせて提供しています。クイックスタートでこれらの動作を試すことができるので、興味があればぜひ試してみだください。

Discussion