🎭

E2EのPOMを関数ベースで書いてみた

に公開

はじめに

私が担当しているプロジェクトでは、開発規模が大きいにもかかわらず E2E テストがほとんど整備されておらず、結果としてバグやデグレが頻繁に発生する状態にあります。これを改善するために E2E テストの導入を進めることにしました。

E2E テストの設計パターンとしては POM(Page Object Model)がよく採用されますが、多くの解説や実装例はクラスベースで書かれています。

しかし、フロントエンドエンジニアの中にはクラス構文に不慣れな人も多く、新たな学習コストが発生してしまうという課題がありました。

目的

そこで本記事では、これから E2E テストを導入したいフロントエンドエンジニアに向けて、クラスを使わずに POM を実現する方法を提案します。

関数ベースのPOM を採用すると、クラスを学ぶ必要がなくなり、より簡潔で扱いやすい実装が可能になります。

3行要約

  • POM は必ずしもクラスで書く必要はなく、関数・オブジェクトベースでも実用的に運用できる
  • JavaScript/TypeScript では、クラスを使わない方がシンプルで開発スタイルとも相性が良い
  • 大切なのは「自分たちが保守しやすいテスト基盤」を作ることであり、形式に縛られすぎないことが重要

POMとは何か

  • POM は、ページの操作に関する知識を 1 つの場所に集約するデザインパターンです。
  • テストケースに直接 CSS セレクタや操作ロジックを書かず、“ページを表すオブジェクト(Page Object)” を用意し、そのメソッドを呼び出すだけでページ操作を行えるようにします。

POMの個人的な課題

POMはクラスベースで書かれることが基本ですが、JS/TSにおいてはクラスベースによりいくつかの問題があると感じています。
1. クラス構文によってテストコードが冗長になる
2. thisバインディングの煩雑さ
3. クラスの学習コスト

調べてみると、POMはSeleniumで使用が推奨されており(Selenium発祥?)、PythonやJavaでも使えるため、その性質上クラスが使われていたのだと思います。

しかし、フロントエンドにおいてクラスの記述は、その複雑さと冗長さからなるべく使用は避けたいと個人的に思います。

関数ベースでPOMを書くという発想

POMのルールだけ守れば別にクラスで書かなくても良いという結論になりました。
「ページ」は「状態を持つオブジェクト」ではなく、「操作を提供する関数の集まり」と捉えることができ、単なるエクスポート関数で実装が可能になります。
また関数ベースで書くことでJavaScript/TypeScriptとの親和性が高くなり、保守性も向上するはずです。

定義側の実装比較

  • これはログイン画面のページオブジェクトをクラスと関数で比較したものです。

クラスベースでの記述

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

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(private page: Page) {
    this.emailInput = page.locator('input[type="email"]');
    this.passwordInput = page.locator('input[type="password"]');
    this.submitButton = page.locator('button[type="submit"]');
  }

  async goto() {
    await this.page.goto('https://example.com/login');
  }

  async fillEmail(email: string) {
    await this.emailInput.fill(email);
  }

  async fillPassword(password: string) {
    await this.passwordInput.fill(password);
  }

  async submit() {
    await this.submitButton.click();
  }
}

関数ベースでの記述

import { Page, Locator } from '@playwright/test';
import { authSelectors } from '../selectors/authSelectors.ts'

export const loginPage = (page: Page) => {
  const goto = () => page.goto('/login');

  const emailInput = (email: string) =>
    page.locator(authSelectors.emailInput).fill(email);

  const passwordInput = (password: string) =>
    page.locator(authSelectors.passwordInput).fill(password);

  const submit = () => page.locator(authSelectors.submit).click();

  return {
    goto,
    emailInput,
    passwordInput,
    submit,
  };
};
  • 直感的に見ても、関数の方がシンプルで理解しやすいと思います。
  • セレクターは別ファイルに分けて管理するようにしました。

テスト側の実装比較

クラスベースでの記述

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

test.describe('ログイン画面 ', () => {
  test('正常にログインできること', async ({ page }) => {
    const loginPage = new LoginPage(page);

    await loginPage.goto();
    await loginPage.fillEmail('user@example.com');
    await loginPage.fillPassword('password123');
    
    await loginPage.submit(); 

    await expect(page).toHaveURL('/dashboard');
  });
});

関数ベースでの記述

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

test.describe('ログイン画面 (Function Base)', () => {
  test('正常にログインできること', async ({ page }) => {
    const { goto, emailInput, passwordInput, submit } = loginPage(page);

    await goto();
    await emailInput('user@example.com');
    await passwordInput('password123');
    
    await expect(page).toHaveURL('/dashboard');
  });
});
  • 関数ベースは分割代入ができるため、少しスッキリした記述になりました。

関数ベースのメリット

  • 冗長な書き方が少ない
  • 可読性が高い
  • テストとの接続が容易
  • クラス継承問題や this の管理が不要

まとめ

  • 「クラスを使わない POM」はフロントエンジニアにとってシンプルで扱いやすく、JavaScript/TypeScriptでE2Eを実装する場合においては特に有効です。
  • 関数ベースのPOM を採用することで、クラスを学ぶ必要がなくなり、普段の実装スタイルを維持したままPOMを書くことができるようになります。
  • 既存のテストパターンやルールを踏まえることも重要ですが、それ以上に自分たちが保守しやすい仕組みを構築することが、長期的にテストを継続していく上で最も大切だと思います。
株式会社ソニックムーブ

Discussion