2025/12/04

テクノロジー

Playwright MCP × POM + Fixture パターン:AIとの協業で実現する保守性の高いE2Eテスト

この記事の目次

    本記事は【Advent Calendar】の4日目の記事です。


    こんにちは、マイナビに業務委託で参画しているY.Hです。
    現在携わっているプロジェクトでは、Playwright MCP を活用して E2E テストを実施しています。
    しかし、MCP 単体だけでテストを書き続けると、どうしても「つらみ」が出てきます。

    Playwright MCP だけで書き続けると起きる「つらみ」

    Playwright MCPはテストコードを爆速で書ける一方で、"プロジェクト全体としてのテスト設計"までは面倒を見てくれません。
    要素の取得ロジックが各テストケースに散らばったり、テストデータの準備が毎回バラバラになったりして、気づくとメンテナンスが大変な状態に陥ります。

    // tests/todo-create.spec.ts
    import { test, expect } from '@playwright/test';
    
    test('ユーザーがToDoを1件作成できる', async ({ page }) => {
      // ログイン処理(テストごとにコピペされがち)
      await page.goto('https://example.com/login');
      await page.fill('input[type="email"]', 'user+e2e@example.com');
      await page.fill('input[type="password"]', 'P@ssw0rd!');
      await page.click('button:has-text("ログイン")');
    
      // 画面遷移待ち(毎回ちょっとずつ書き方が違う)
      await page.waitForURL('**/dashboard');
    
      // 要素のセレクタがテストケースごとにバラバラ
      await page.click('text=TODO一覧');
      await page.click('button.add-todo'); // クラスセレクタ直書き
    
      // テストデータもその場でベタ書き
      const title = '牛乳を買う';
      const description = '明日の朝までにスーパーで買う';
    
      await page.fill('input[placeholder="タイトル"]', title);
      await page.fill('textarea[name="description"]', description);
      await page.click('button:has-text("保存")');
    
      await expect(page.getByText(title)).toBeVisible();
    });

    この問題を解決するために、POM(Page Object Model)と Fixture を組み合わせた構成を導入しました。

    POM(Page Object Model)とは?

    POM(Page Object Model)とは、ページごとの操作を1つのクラスにまとめ、テストコードからDOM操作を"隠蔽(カプセル化)"する設計パターンです。

    UIテストはDOM構造やセレクタの変更に影響を受けやすく、page.click(...) や page.fill(...) がテスト内に散乱すると、UI変更のたびに大量のテストが壊れてしまいます。

    POMを使うことで、UI変更の影響をページクラス1か所に閉じ込められ、テストコード自体は安定して保てるようになります。

    POM を使わないコード

    import { test, expect } from '@playwright/test';
    
    test('ログインできること', async ({ page }) => {
      await page.goto('https://example.com/login');
    
      <em>// セレクタがテスト内にベタ書き</em>
      await page.fill('input[name="email"]', 'test@example.com');
      await page.fill('input[name="password"]', 'password123');
    
      <em>// ボタン文言も直書き</em>
      await page.click('button:has-text("ログイン")');
    
      <em>// UI 変更(文言/属性)があると即壊れる</em>
      await expect(page.getByText('ダッシュボード')).toBeVisible();
    });

    POM を使ったコード(良い例)

    まずは LoginPage クラスを作り、DOM操作をすべてそこへ隠蔽します。

    import { Page } from '@playwright/test';
    
    export class LoginPage {
      constructor(private page: Page) {}
    
      async goto() {
        await this.page.goto('https://example.com/login');
      }
    
      async fillEmail(email: string) {
        await this.page.fill('input[name="email"]', email);
      }
    
      async fillPassword(password: string) {
        await this.page.fill('input[name="password"]', password);
      }
    
      async submit() {
        await this.page.click('button:has-text("ログイン")');
      }
    
      async login(email: string, password: string) {
        await this.goto();
        await this.fillEmail(email);
        await this.fillPassword(password);
        await this.submit();
      }
    }

    上記で作成したPOMを使ってテストを作成します。

    import { test, expect } from '@playwright/test';
    import { LoginPage } from '../src/pages/LoginPage';
    
    test('ログインできること', async ({ page }) => {
      const loginPage = new LoginPage(page);
    
      <em>// DOMの詳細を触らずに「意図」だけを書く</em>
      await loginPage.login('test@example.com', 'password123');
    
      <em>// テスト側は UI 変更の影響を受けにくい</em>
      await expect(page.getByText('ダッシュボード')).toBeVisible();
    });
    • テストコードから page.fill(...) や page.click(...) が消える
    • DOM変更(セレクタ・文言)があっても LoginPage.ts の中だけ直せば良い

    Fixture とは?

    Fixture(フィクスチャ)とは、 テスト実行前に必要な「前準備」を共通化し、再利用しやすくするPlaywright の仕組みです。

    E2E テストでは、ログイン・初期データ作成・画面遷移など「どのテストでも毎回必要な前処理」が発生しがちです。
    これらをテストごとに書くと、以下のような問題が発生します:

    • ログイン処理がテスト間でコピペされてバラバラになる
    • データ準備の仕方がテストファイルごとで微妙に異なる
    • UIやAPIが変更されたとき、修正箇所が全テストに波及して壊滅する
    • MCP にテスト生成させるほど、重複コードが爆増しやすい

    Fixture を使うと、これらの前処理を 1ヶ所にまとめて隠蔽し、テスト側では "準備済みの状態" をそのまま使える というメリットがあります。

    Fixture を使わない例(前処理が毎回バラバラになる)

    import { test, expect } from '@playwright/test';
    
    test('プロフィールを更新できる', async ({ page }) => {
      await page.goto('/login');
      await page.fill('input[name=email]', 'user@example.com'); <em>// 毎回書く</em>
      await page.fill('input[name=password]', 'password123');
      await page.click('button:has-text("ログイン")');
    
      await page.waitForURL('/dashboard');
    
      await page.goto('/profile');
      await page.fill('#name', '新しい名前');
      await page.click('button:has-text("保存")');
    
      await expect(page.getByText('更新完了')).toBeVisible();
    });
    • どのテストにもログインが散らばる → UI変更があれば全滅
    • ページ遷移や待機処理も統一されない → バグが出た時に追いにくい
    • MCPに生成させるとバリエーション違いのログインコードが量産される → 地獄

    Fixture を導入するとどうなる?

    Fixture を使うと、テスト側では次のように一瞬でログイン済み状態を利用できます:

    <em>// fixtures/auth.ts</em>
    import { test as base } from '@playwright/test';
    
    export const test = base.extend({
      loggedInPage: async ({ page }, use) => {
        await page.goto('/login');
        await page.fill('input[name="email"]', 'test@example.com');
        await page.fill('input[name="password"]', 'password123');
        await page.click('button:has-text("ログイン")');
        await page.waitForURL('/dashboard');
    
        <em>// ログイン済みの page をテストに注入</em>
        await use(page);
      },
    });
    
    test('プロフィールを更新できる', async ({ loggedInPage }) => {
      await loggedInPage.goto('/profile');
      await loggedInPage.fill('#name', '新しい名前');
      await loggedInPage.click('button:has-text("保存")');
      await expect(loggedInPage.getByText('更新完了')).toBeVisible();
    });

    ログイン処理は fixtures/auth.ts に隔離されており、UI変更やログインフロー変更の影響はその1ファイルに閉じ込められます。

    MCP に「loggedInPage を使ってテストを書いて」と指示すると、生成されるテストの質も一気に安定します。

    POM 作成における「人間」と「AI」の役割分担

    POM を導入するときにいきなり「POMもテストも全部 MCP にお任せ!」としてしまうと、短期的には便利でも、中長期的にはカオスになります。

    このプロジェクトでは、

    • POM の設計(どのページをどう抽象化するか)は人間が決める
    • 決めた設計に沿ってコードを書く作業は AI(MCP)にやってもらう

    という役割分担にしています。

    まずは人間が POM の設計・責務を決める

    • どの画面を 1 POM とするか
    • どの操作をメソッドに切り出すか
    • どの UI 要素に data-test-id​ を振るべきか
    • 命名規則をどうするか(例:<page>-<role>-<name>​)

    この「抽象化」と「テスト観点の整理」は、プロダクトの設計意図を理解していないとできないため人間の仕事です。

    例えば「ログイン画面」なら:

    • email 入力欄:login-email-input​
    • password 入力欄:login-password-input
    • ログインボタン:login-submit-button

    のように どの要素をテストの観点で識別するか を人間が確定させます。

    その上で、data-test-id は以下の専用のファイルにまとめて一元管理しています。

    <em>// testIds.ts</em>
    export const testIds = {
      login: {
        emailInput: 'login-email-input',
        passwordInput: 'login-password-input',
        submitButton: 'login-submit-button',
      },
    } as const;

    設計が決まったら、実装(POM 化 & data-test-id の埋め込み)は AI に任せる

    UI のどの要素に data-test-id​ を付けるか、
    POM でどのメソッドを持つか、
    まで決まればそこからは AI(MCP)に実装してもらう方が速く、正確です。

    MCP にはこんな指示を与えます:

    • この testIds に従って UI コンポーネントに data-test-id​ を追加してください
    • 同じ命名規則で POM クラスを作成してください
    • POM には「login(email, password)」操作を実装してください
    • ログインが成功したら Dashboard に遷移する前提です

    すると AI は以下のような実装を自動生成できます:

    UI コンポーネント側(AI生成例)

    <input
      data-test-id="login-email-input"
      type="email"
      ...
    />
    
    <button
      data-test-id="login-submit-button"
    >
      ログイン
    </button>

    POM(AI生成例)

    import { Page } from '@playwright/test';
    import { testIds } from '../testIds';
    export class LoginPage {
      constructor(private page: Page) {}
    
      async login(email: string, password: string) {
        await this.page.getByTestId(testIds.login.emailInput).fill(email);
        await this.page.getByTestId(testIds.login.passwordInput).fill(password);
        await this.page.getByTestId(testIds.login.submitButton).click();
      }
    }

    この方式のメリット

    • data-test-id の命名が統一される
      → 人が設計し、AIが実装するのでブレない
    • POM の責務が明確になる
      → どこまで抽象化するかの境界を人間が決める
    • 実装作業(単純作業)は AI が自動化
      → 開発者は設計・レビューに集中できる
    • テスト資産が中長期で保守しやすくなる
      → UI の変更に強い

    最終的に Playwright MCP へどう指示すればいいのか?

    ここまでで、

    • POM の設計/data-test-id の設計は人間が行う
    • 実装作業(POM 化・UI への data-test-id 埋め込み)は AI が担当する
    • テストケースは POM と Fixture を前提に書く

    という役割分担を確立しました。

    では、実際に Playwright MCP にどう指示すれば"質の高いテスト"を生成してくれるのか?
    ここが一番重要なポイントになります。

    MCP に依頼するときの指示テンプレート

    以下の 4 点をセットで渡すのが効果的です:

    1. POM の定義(クラス名・メソッド・責務)
    2. data-test-id のルール & testIds.ts の構造
    3. 利用する Fixture(例:loggedInPage)
    4. 欲しいテストケースの「意図」だけを書く(UI操作は書かない)

    これを守ると、MCP は "テストの意図" を読み取った上で、POM と Fixture を前提にした理想的なテストコード を返してくれます。

    MCP へ渡す具体的なプロンプト例

    以下のルールに従って Playwright のテストコードを生成してください
    
    1. 使用する POM
    - LoginPage: login(email, password) を提供
    - ProfilePage: updateName(name), goto() を提供
    - POM 内の DOM 操作は testIds.ts  data-test-id のみを使用すること
    - POM 以外で page.locator  page.fill を直接使わない
    
    2. data-test-id の命名規則
    - <page>-<role>-<name> の形式で統一
    -  test-id  testIds.ts に定義済み
    - テストコードからは getByTestId(testIds.xx.yy) を必ず使うこと
    
    3. Fixture の利用
    - ログイン済みの状態が必要な場合はloggedInPageを利用すること
    - ページの初期化は const profilePage = new ProfilePage(loggedInPage) のように行う
    
    4. 作成してほしいテストの意図
    -プロフィール画面で名前を変更できること
    - 名前欄に新しい名前を入力し保存後に更新完了のトーストが表示されることをテストする
    
    これらを踏まえて適切なテストコードを生成してください

    MCP が返す理想的なアウトプット(例)

    上記の指示に対して、MCP は以下のようなテストを生成できます

    import { test, expect } from '../fixtures/auth';
    import { ProfilePage } from '../pages/ProfilePage';
    
    test('プロフィールの名前を更新できる', async ({ loggedInPage }) => {
      const profilePage = new ProfilePage(loggedInPage);
    
      await profilePage.goto();
      await profilePage.updateName('新しい名前');
    
      await expect(
        loggedInPage.getByText('更新完了')
      ).toBeVisible();
    });
    • page.fill() や page.click() は一切ない
    • POM のメソッドだけを使っている
    • Fixture により "ログイン状態" が保証されている
    • 変更に強いテストコードになっている

    最終的に Playwright MCP にはどう指示を出せばいいのか

    ここまで書いたとおり、Playwright MCP にそのままテストを書かせると、

    • その場しのぎのセレクタを勝手に推測する
    • ログインや前処理を毎回ベタ書きする
    • ページ遷移や待機の書き方がテストごとにバラバラになる

    という形で、短期的にはテストが増えるけれど、中長期では破綻しがちです。

    そこでこのプロジェクトでは:

    • UI 操作は必ず POM 経由
    • 前処理は Fixture 経由
    • 要素の取得は data-test-id 経由

    という「テスト設計上のルール」をまず人間が決め、そのうえで MCP にはそのルールを"絶対に守らせる"形で指示を出す ようにしています。

    MCP に期待しているのは「設計済みのレールの上で、ひたすらコードを書いてくれるジュニアエンジニア」に近い役割です。

    まとめ:MCP × POM + Fixture は「AI任せにしないための設計」

    Playwright MCP はとても強力ですが、そのまま自由に書かせると、テストが必ず崩壊します。

    理由はシンプルで、MCP は「プロジェクト横断の設計意図」を理解していないからです。

    • セレクタの使い方や命名に一貫性を持たせる
    • 前処理(ログインなど)を共通化する
    • UI 変更の影響を最小化する

    といった"テスト設計として当たり前の基盤"は、AI 任せにすると必ず壊れます。

    だからこそ、POM(抽象化・隠蔽の境界)と Fixture(前処理の共通化)を人間が設計し、実装を AI に任せる という役割分担が最も安定します。

    この協業パターンにすると:

    • テストコードは「意図を書くだけ」で済む
    • data-test-id / POM / Fixture の運用がブレなくなる
    • UI 変更にも強い "腐らない E2E テスト" が作れる
    • AI は「ジュニアエンジニア的なコーディング作業」を高速にこなす

    という状態が実現できます。力の両方を最大化した、壊れにくいテスト基盤が手に入ります。

    イベント告知

    12月23日にイベントを開催します!申し込みはこちらから▼

    https://mynaviit.connpass.com/event/376769

    ※本記事は2025年12月時点の情報です。

    著者:マイナビエンジニアブログ編集部