Playwrightのページオブジェクトモデル(POM)とは、画面やボタンなどの部品をひとまとめにして管理する仕組みのことです。POMを使うことでテストコードがシンプルになり、またUI変更時にも修正がPOMファイルのみで済むといったメリットがあります。

そしてPOMは、ページ単位だけでなくコンポーネント単位でも導入可能です。この記事では、テストで繰り返し登場する入力項目をPOM化して、再利用できるようにしてゆきます。

POM化対象の要素

以下は食事管理アプリの一部の画面です。「食べたものを入力する画面」と「食材をDBに登録する画面」があり、赤枠内にあるカロリー、タンパク質などの入力項目複数が共通しています。実際には編集画面なども存在するため、この入力欄の使用頻度は高いです。

テストコードでも当然これらの入力欄が繰り返し登場するため、POM化するメリットは大きいです。

POM導入前のテストコード

以下は、POM導入前のテストコードです。「食べたものを登録する画面」と「食材をDBに登録する画面」それぞれで登録動作が正常に行えることをテストしています。

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

test.describe('POM使用前のテスト', () => {

  test('食べたものを登録', async ({ page }) => {
    await page.goto('/meals/create');

    // 今日の日付を設定
    const today = new Date().toISOString().split('T')[0];
    await page.getByLabel('日付').fill(today);

    await page.getByRole('radio', { name: '朝食' }).check();

    await page.getByLabel('料理名').fill('テスト朝食');
    await page.getByLabel('カロリー (kcal)').fill('500');
    await page.getByLabel('タンパク質 (g)').fill('20');
    await page.getByLabel('脂質 (g)').fill('15');
    await page.getByLabel('炭水化物 (g)').fill('60');
    await page.getByLabel('糖質 (g)').fill('55');

    await page.getByLabel('メモ').fill('テストメモ');

    await page.getByRole('button', { name: '保存して戻る' }).click();

    // ダッシュボードにリダイレクトされることを確認
    await expect(page).toHaveURL('/');
    // 成功メッセージが表示されることを確認
    await expect(page.getByText('食事を記録しました')).toBeVisible();
  });

  test('食材をDBに登録', async ({ page }) => {
    await page.goto('/food-compositions/create');

    await page.getByLabel('食材名').fill('じゃがいも');
    await page.getByLabel('カロリー (kcal)').fill('150');
    await page.getByLabel('タンパク質 (g)').fill('35');
    await page.getByLabel('脂質 (g)').fill('10');
    await page.getByLabel('炭水化物 (g)').fill('75');
    await page.getByLabel('糖質 (g)').fill('55');

    await page.getByRole('button', { name: '登録する' }).click();

    // ダッシュボードにリダイレクトされることを確認
    await expect(page).toHaveURL('/food-compositions');
    // 成功メッセージが表示されることを確認
    await expect(page.getByText('食材を登録しました。')).toBeVisible();
  });
});

前述の通りそれぞれのテストに同じ入力欄が存在しているため、getByLabel()による要素取得がずらずらと記述されています。

対象要素をPOM化する

早速ですが、以下が作成したPOMコンポーネントになります。POMファイルの置き場所に特にルールはありません。今回は、componentsというディレクトリを作成し、その中にファイルを配置しました。

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

/**
 * 栄養素入力フィールドの共通コンポーネント
 */
export class NutritionInputs {
  // すべてのロケーターをプロパティとして定義
  readonly nameInput: Locator;
  readonly caloriesInput: Locator;
  readonly proteinInput: Locator;
  readonly fatInput: Locator;
  readonly carbohydratesInput: Locator;
  readonly sugarInput: Locator;
  readonly saltInput: Locator;

  constructor(private page: Page) {
    this.nameInput = page.getByLabel(/(料理|食材)名/);
    this.caloriesInput = page.getByLabel('カロリー (kcal)');
    this.proteinInput = page.getByLabel('タンパク質 (g)');
    this.fatInput = page.getByLabel('脂質 (g)');
    this.carbohydratesInput = page.getByLabel('炭水化物 (g)');
    this.sugarInput = page.getByLabel('糖質 (g)');
    this.saltInput = page.getByLabel('塩分 (g)');
  }

  /**
   * すべての栄養素を一括入力
   */
  async fillAll(nutrition: {
    name: string;
    calories: string;
    protein?: string;
    fat?: string;
    carbohydrates?: string;
    sugar?: string;
    salt?: string;
  }) {
    // 各ロケーターを再利用して入力
    await this.nameInput.fill(nutrition.name);
    await this.caloriesInput.fill(nutrition.calories);
    if (nutrition.protein) await this.proteinInput.fill(nutrition.protein);
    if (nutrition.fat) await this.fatInput.fill(nutrition.fat);
    if (nutrition.carbohydrates) await this.carbohydratesInput.fill(nutrition.carbohydrates);
    if (nutrition.sugar) await this.sugarInput.fill(nutrition.sugar);
    if (nutrition.salt) await this.saltInput.fill(nutrition.salt);
  }
}

constructor()fillAll()の2つの要素で構成されています。

まずconstructor()では、このコンポーネントで操作対象とする全ての入力項目をプロパティとして定義しています。page.getByLabel(/(料理|食材)名/);だけは正規表現で要素を指定していますが、これは画面によってラベル名が「料理名」と「食材名」のように異なるからです。

そしてfillAll()、こちらはメソッド名の通り全ての項目に入力を実行します。その際、先ほどのconstructor()で定義したthis.nameInputthis.caloriesInputなどのプロパティを使って操作を行います。

また、必須項目以外はif (nutrition.protein) ・・・として、データがある場合にのみ入力するようにしています。

今回は入力欄のみを扱っていますが、ボタンやリスト要素などももちろんPOM化できます。メソッドも自由に定義ですますので、詳しくは公式ドキュメントをご参照ください。

作成したPOMをテストコードで使う

このように作成したコンポーネントPOMをテストコード側でどのように使うかというと、以下のようにします。

import { NutritionInputs } from './components/NutritionInputs';

  test('食べたものを登録', async ({ page }) => {
    // コンポーネントPOMを直接作成
    const nutrition = new NutritionInputs(page);

    // コンポーネントのメソッドを使用して入力
    await nutrition.fillAll({
      name: 'じゃがいも',
      calories: '200',
      protein: '10',
      fat: '5',
      carbohydrates: '30',
      sugar: '15',
      salt: '1.0',
    });
・・・・・

まずはNutritionInputsをインスタンス化します。その際引数にpageオブジェクトを渡してください。そして、インスタンスを使ってfillAll()を呼び出します。

getByLabel()がずらっと並んでいた部分が、必要な情報を渡すだけという形にスッキリしました。もしも入力欄のラベル名が変更になっても修正はPOMファイル1箇所で済みます。

FixtureでPOMを定義する

これでPOMが使えるようになりましたが、テスト内にnewの記述が入ってしまうことが少し気になります。テストとは直接関係がないのでできればテストの外でnewしたい。

こういう場合、Fixtureが便利です。Fixtureとは、テストで使う共通の準備処理をまとめて定義できる機能のことです。

以下は作成したfixtureです。ドキュメントの記述にならって以下のようにしました。

import { test as base } from '@playwright/test';
import { NutritionInputs } from './components/NutritionInputs';

type MyFixtures = {
  nutritionInputs: NutritionInputs;
};

export const test = base.extend<MyFixtures>({
  nutritionInputs: async ({ page }, use) => {
    const nutritionInputs = new NutritionInputs(page);
    await use(nutritionInputs);
  },
});

export { expect } from '@playwright/test';

まず、このファイルで定義するPOMを宣言します。今回はnutritionInputsという名前でNutritionInputsを使えるようにしています。

そのあとbase.extend()でPlaywrightのtest()を拡張しています。POMのインスタンスを作成し、await use(nutritionInputs)によって、テスト側でnutritionInputsを使えるように渡す、という流れになっています。

これにより、テスト側では以下のようにtest()の引数にnutritionInputsを受け取るだけでPOMを使えるようになります。

import { test, expect } from './fixtures';

  test('食べたものを登録', async ({ page, nutritionInputs }) => {

    await nutritionInputs.fillAll({
      name: 'テスト朝食',
      calories: '500',
      protein: '20',
      fat: '15',
      carbohydrates: '60',
      sugar: '55',
      salt: '2.0',
    });
・・・・・

POM導入後のテストコード

作成したPOM・Fixtureを使用した、最終的なテストコードは以下になります。

import { test, expect } from './fixtures';

test.describe('NutritionInputsを使用', () => {
  test('食べたものを登録', async ({ page, nutritionInputs }) => {
    await page.goto('/meals/create');

    // 今日の日付を設定
    const today = new Date().toISOString().split('T')[0];
    await page.getByLabel('日付').fill(today);

    await page.getByRole('radio', { name: '朝食' }).check();

    //POMのメソッドで栄養素を一括入力
    await nutritionInputs.fillAll({
      name: 'テスト朝食',
      calories: '500',
      protein: '20',
      fat: '15',
      carbohydrates: '60',
      sugar: '55',
      salt: '2.0',
    });

    await page.getByLabel('メモ').fill('テストメモ');
    await page.getByRole('button', { name: '保存して戻る' }).click();

    // 保存後のリダイレクト・成功メッセージ確認
    await expect(page).toHaveURL('/');
    await expect(page.getByText('食事を記録しました')).toBeVisible();
  });

  test('食材DBを登録', async ({ page, nutritionInputs }) => {
    await page.goto('/food-compositions/create');

    // POMのメソッドを使用して入力
    await nutritionInputs.fillAll({
      name: 'じゃがいも',
      calories: '200',
      protein: '10',
      fat: '5',
      carbohydrates: '30',
      sugar: '15',
      salt: '1.0',
    });

    // ページ固有のボタン(Submitなど)は直接pageオブジェクトで操作
    await page.getByRole('button', { name: '登録する' }).click();

    // 保存後のリダイレクト・成功メッセージ確認
    await expect(page).toHaveURL('/food-compositions');
    await expect(page.getByText('食材を登録しました。')).toBeVisible();
  });
});

このように小さな単位からでもPOMを導入できるので、ぜひ使ってみてください!

メルマガ購読の申し込みはこちらから。

By hmatsu