freeeの開発情報ポータルサイト

ページオブジェクトモデルを採用しているE2Eテスト基盤の実装Tips

こんにちは。SEQ (Software Engineering in Quality)のnakamuです。

freee QA Advent Calendar2024 11日目です。

これまで、freeeのE2EテストツールはSelenium+RSpec+Capybara+SitePrismをベースにした独自基盤を利用してきました。 この基盤は、2016年からページオブジェクトモデルを採用し、今日に至るまでE2Eテストの保守および運用を支えてきました。 今年度からは、Playwrightを基盤にした新しいE2Eテスト環境への移行を推進しており、新基盤でも引き続きページオブジェクトモデルを使用しています。 そこで今回はページオブジェクトモデルに関連する実装の工夫点について紹介します。

注意点

コード例はPlaywright + TypeScriptを想定しています。

ページオブジェクトモデルとは

ページオブジェクトモデルとはテスト対象の各画面に対応するオブジェクトを作成し、そこに画面の要素を定義するデザインパターンです。 これはDOMの変更による要素の修正範囲が対象のオブジェクトファイルだけになるのが特徴です。

取引先作成ページのページクラス(AccountingPartnerNew)と取引先詳細ページのページクラス(AccountingPartnerDetail)の2つを例にします。

// ページクラス
export class AccountingPartnerNew {
  readonly page: Page;
  readonly nameField: Locator;
  readonly submitButton: Locator;
  
  constructor(page: Page) {
    this.page = page;
    this.nameField = page.getByLabel("名前");
    this.submitButton = page.getByRole('button', { hasText: '作成' })
  }
  
  async createPartner(name: string): Promise<void> {
    await nameField.fill(name);
    await submitButton.click();
  }
}

export class AccountingPartnerDetail {
  readonly page: Page;
  readonly partnerName: Locator;
  
  constructor(page: Page) {
    this.page = page;
    this.partnerName = page.locator('h1');
  }
}

参考: https://playwright.dev/docs/pom

定義してページクラスをテストコード側で利用する例は以下の通りです。 以下のコードはテストコード内でページクラスを初期化しています。しかし、利用するページクラス毎に初期化する必要があるため可読性が下がるデメリットがあります。

// テストコード
test('取引先を登録できること', async ({ page }) => {
  const partnerNewPage = new AccountingPartnerNew(page);
  await partnerNewPage.createPartner("取引先A");
  const partnerDetailPage = new AccountingPartnerDetail(page);
  await expect(partnerDetailPage.partnerName).toHaveText("取引先A")
});

このような課題について工夫した3つの点をそれぞれ紹介します。

3つの工夫したこと

ページクラスを集約したクラスからページオブジェクトを呼び出す

ページクラスのインスタンスを格納するクラスを用意し、そのクラスから呼び出すようにしています。 以下がそのソースコードの例になります。

// ページクラスのインスタンスを格納する App クラス
export class App {
  accounting = {
    partners: {
      new: new AccountingPartnerNew(),
      detail: new AccountingPartnerDetail()
    }
  }
}
// App クラスのインスタンスを格納するオブジェクト
export const store = {
  app: null
};

// store.app 呼び出し用の関数
export const app = () => store.app;
// Automatic fixture で、store.app を用意
import { test as base } from "@playwright/test";

export const test = base.extend<
  {
    setup: undefined
  }
>({
  setup: [
    async ({}, use) => {
      store.app = new App();
      await use(undefined);
    },
    { auto: true },
  ]
});

上記の構成した際のテストコードは以下の通りになります。テストコード側でページクラスの初期化が不要になりました。

// テストコード
test('取引先を登録できること', async () => {
  // ページクラス初期化文がなくなってスッキリになった
  await app().accounting.partners.new.createPartner("取引先A");
  await expect(app().accounting.partners.detail.partnerName()).toHaveText("取引先A");
});

必要な情報だけが残るのでテストコードの可読性が上がります。 また、Appクラスを用意することでテストコードの表現がシンプルになり、担当外のプロダクトのコードを読むのが容易になります。

URLに沿った構成にする

ページクラスのディレクトリ構成はプロダクトのURLに沿っています。

例えば、対象となるプロダクトが会計でパスが /partners/new の場合、ページオブジェクトのファイルは lib/pages/accounting/partners/new.ts となります。

この構成にすることで実装時に対象となる画面のページクラスのファイルに辿りつきやすくなります。

また、先ほど登場したページクラスのインスタンスを格納するAppクラスの構成もURLに沿っています。

// App クラス
export class App {
  accounting = {
    partner: {
      new: new AccountingPartnerNew(), // /partners/new
      detail: new AccountingPartnerDetail() // /partners/${partnerId}
    }
  }
}

操作対象のPageインスタンスをタブの切替に合わせて変更し、テスト内で共有する

これはPlaywright特有の問題ですが、ページクラスの初期化時にPageインスタンスを渡す方法を採用すると、ブラウザの各タブにPageインスタンスが存在します。そのため、新しいタブでページオブジェクトを使用しようとする場合、再度ページクラスを初期化する必要が出てきます。

これを解決するために、操作対象のPageインスタンスをページクラス・テストコード内で共有し、新しいタブに対して操作したい場合は対象のPageインスタンスをそれに切り替えるようにする仕組みを実装しました。

これによって、利用者はPageインスタンスの扱いを意識することなくテストコードを実装することができます。

// 操作対象の Page インスタンスを格納するオブジェクト
export const store = {
  app: null,
  page: null
};

// 呼び出し用の関数
export const page = () => store.page;
// Automatic fixture で、store.page を用意
import { test as base } from "@playwright/test";

export const test = base.extend<
  {
    setup: undefined
  }
>({
  setup: [
    async ({ page }, use) => {
      store.page = page;
      store.app = new App();
      await use(undefined);
    },
    { auto: true },
  ]
});
// 操作対象の Page インスタンスを切り替える関数
export const switchWindow = async (index: number) => {
  await page().context().pages()[index].bringToFront();
  store.page = page().context().pages()[index];
};
// ページクラス
export class AccountingDealDetail {
  partnerLink = () => page().locator('a', { hasText: '取引先詳細' })
}

export class AccountingPartnerDetail {
  partnerName = () => page().locator('h1');
}

テストコードで新しいタブに切り替える例は以下の通りです。

// テストコード
await app().pages.accounting.deals.detail.partnerLink().click();
// 新しいタブに切り替える(indexは1)
await switchWindow(1);
await expect(app().pages.accounting.partners.detail.partnerName()).toHaveText("取引先A");

おわりに

E2Eテスト + ページオブジェクトモデルは実装・保守コストが高いので、地道ですがそれを少しでも下げられるような工夫を紹介しました。よかったら参考にしてみてください。

明日は、従業員ポータルのQAエンジニアのaireen-sanが「Why Accessibility Matters」について記事を書いてくれます。

それでは、よい品質を〜