🔥

PlaywrightとCDPを活用したChrome拡張E2Eテスト実践

に公開

雨の日が続いてますが、室内でコーディングするには最高の季節ですね。
K@zuki.です。

Chrome拡張のテスト、皆さんはどうしていますか?

正直なところ、Chrome拡張のE2Eテストって「できるの?」「必要?」「めんどくさそう...」って思っちゃいますよね。
私も最初はそう思ってました。

でも、実際にやってみたら意外とできました。
しかも、PlaywrightとChrome DevTools Protocol(CDP)を組み合わせることで、かなり実用的なテストが書けることがわかりました。

今回は、私が開発しているSnack TimeというChrome拡張で実装したE2Eテストの手法を共有したいと思います。

https://github.com/corrupt952/snacktime

要約

  • Chrome拡張でもPlaywrightを使えばE2Eテストが書ける
  • ポップアップも含め全てWebページとしてアクセスが可能
  • ポップアップとコンテンツスクリプトの連携テストもCDPで実現可能
  • Page Object Patternで保守性の高いテストコードに
  • bringToFront()で複数ウィンドウの切り替えもスムーズ

なぜChrome拡張のE2Eテストが難しいのか

まず、Chrome拡張特有の難しさを整理してみましょう。
通常のWebアプリケーションと比べて、Chrome拡張にはいくつか特殊な事情があるわけです。

複数の実行コンテキスト

Chrome拡張って、実は複数の「世界」で動いているんですよね。

  • ポップアップ ... 拡張アイコンをクリックして出てくる画面
  • コンテンツスクリプト ... 各Webページに注入されるスクリプト
  • オプションページ ... 設定画面
  • バックグラウンド ... 裏で動いているスクリプト(Service Worker)
  • その他の独自ページ

これらが協調して動くので、単純なE2Eテストでは難しそうに見えます。

ポップアップの特殊性

Chrome拡張のE2Eテストを行いたい場合に「どう指示すればいいのか分からない」という点の1つが、ポップアップの存在です。
一部のChrome拡張では、ツールバーにある拡張ボタンをクリックしてもらい、ポップアップの中の要素をクリックしてイベントを発火することが多いです。
ただし、このツールバーへはPlaywirghtなどでは通常アクセスできず、難しく感じます。

拡張機能特有のURL

Chrome拡張のページは chrome-extension://[拡張ID]/popup.html みたいな特殊なURLになります。
この拡張IDは環境によって変わるので、ハードコーディングもできません。

Playwrightで解決する

さて、ここからが本題です。
実はPlaywrightなら、これらの問題をきれいに解決できるんです。

基本的なセットアップ

まず、Chrome拡張をPlaywrightで読み込む方法から見ていきましょう。

e2e/fixtures/extension.ts
export const test = base.extend<{
  context: BrowserContext;
  extensionId: string;
}>({
  context: async ({}, use) => {
    const pathToExtension = path.join(__dirname, "../../dist");
    const context = await chromium.launchPersistentContext("", {
      headless: false, // 拡張はヘッドレスモードでは動かない
      args: [
        `--disable-extensions-except=${pathToExtension}`,
        `--load-extension=${pathToExtension}`
      ],
    });
    await use(context);
    await context.close();
  },
  extensionId: async ({ context }, use) => {
    // 拡張IDを動的に取得
    const page = await context.newPage();
    await page.goto("chrome://extensions/");
    await page.click("cr-toggle#devMode");
    
    const extensionCard = await page.locator("extensions-item").first();
    const extensionId = await extensionCard.getAttribute("id");
    
    await page.close();
    await use(extensionId);
  },
});

このFixutreを用意しておき、これを書くテストで流用することで、IDが動的に変更される問題に対処することが可能です。

ポイントは以下の通りです。

  • launchPersistentContextを使って拡張を読み込む
  • 拡張IDはchrome://extensions/から動的に取得
  • fixtureとして定義することで、全テストで使い回せる

ポップアップを別ページとして開く

ここが最大のポイントなんですが、ポップアップを通常のページとして開けば、フリーズ問題を回避できるわけです。

// ポップアップを新しいページとして開く
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup/index.html`);

// これで普通のページと同じようにテストできる!
await popupPage.click('button:has-text("5:00")');

まあ、実際のユーザー操作とは違うんですが、機能テストとしては十分だと思います。

CDPでマルチウィンドウを制御する

次に、ポップアップとコンテンツページを行き来する場合の話です。

Snack Timeでは、コンテンツページの中にポップアップで指定した時間のタイマーを表示する仕組みになっています。
このような仕組みをテストしようとしても、ポップアップを開いているタブで操作しても、アクティブなタブがコンテンツページではないため、テストができません。

そこでChrome DevTools Protocol(CDP)を使うと、ブラウザをより細かく制御できます。
特に Page.bringToFront が便利で、特定のページを前面に持ってこれるため、アクティブなタブを切り替えることができるというわけです。

content-timer.page.ts - bringToFront実装
async bringToFront(): Promise<void> {
  const client = await this.page.context().newCDPSession(this.page);
  await client.send("Page.bringToFront");
}

そして、この仕組みを使うと、こんなテストが書けます。

test("ポップアップからタイマーを設定する", async ({ extensionId, context, page }) => {
  // 1. テスト対象のページを開く
  await page.goto("https://example.com");
  const contentPage = new ContentTimerPage(page);
  
  // 2. ポップアップを開く
  const popupPageHandle = await context.newPage();
  const popupPage = new PopupPage(popupPageHandle, extensionId);
  await popupPage.open();
  
  // 3. コンテンツページを前面に(CDPを使用)
  await contentPage.bringToFront();
  
  // 4. ポップアップでタイマーを設定
  await popupPage.clickPresetButton("5");
  
  // 5. コンテンツページでタイマーが表示されることを確認
  await contentPage.waitForTimer();
  await contentPage.verifyTimerVisible();
});

実際のユーザー操作に近い形でテストできるのがいいところです。

Page Object Patternで整理する

テストコードも増えてくると、メンテナンスが大変になります。
そこでPage Object Patternの出番というわけです。

popup.page.ts - Page Objectの実装例
export class PopupPage extends BasePage {
  private readonly presetButtonMap = {
    "5": "1:00",
    "10": "3:00", 
    "15": "5:00",
    "25": "10:00",
  };

  async open(): Promise<void> {
    await this.goto(`chrome-extension://${this.extensionId}/popup/index.html`);
  }

  async clickPresetButton(minutes: "5" | "10" | "15" | "25"): Promise<void> {
    const timeText = this.presetButtonMap[minutes];
    const button = this.page.locator(`button:has-text("${timeText}")`).first();
    await button.click();
  }

  // 他のアクションも同様に定義...
}

こうしておけば、テストケースはシンプルに保てます。

実践的なテストシナリオ

実際に書いているテストをいくつか紹介します。

複数タブでの独立性テスト

Chrome拡張って、タブごとに独立して動く必要があります。これもちゃんとテストできるんです:

test("異なるタブで独立したタイマーが動く", async ({ extensionId, context }) => {
  // タブ1でタイマーを設定
  const page1 = await context.newPage();
  await page1.goto("https://example.com");
  // ... タイマー設定 ...

  // タブ2で別のタイマーを設定
  const page2 = await context.newPage();
  await page2.goto("https://www.google.com");
  // ... タイマー設定 ...

  // 両方のタイマーが独立して動いていることを確認
  await page1.bringToFront();
  await contentPage1.verifyTimerVisible();
  
  await page2.bringToFront();
  await contentPage2.verifyTimerVisible();
});

ドラッグ&ドロップのテスト

ユーザーインタラクションもテストできます:

async dragTimer(deltaX: number, deltaY: number): Promise<void> {
  await this.timerRoot.hover();
  await this.page.mouse.down();
  await this.page.mouse.move(deltaX, deltaY);
  await this.page.mouse.up();
}

ハマりポイントと対策

実装していて、いくつかハマったポイントがありました。
正直、これらは避けて通れない道だと思います。

非同期処理の待機

Chrome拡張は非同期処理が多いので、適切に待機する必要があります。
これはChrome拡張に限らず、E2Eテストでは基本中の基本ですが、Chrome拡張でも重要です。

// タイマーが表示されるまで待つ
async waitForTimer(): Promise<void> {
  await this.page.waitForSelector("#snack-time-root");
}

ヘッドレスモードは使えない

Chrome拡張はヘッドレスモードでは動きません。CIでも headless: false にする必要があります。
GitHub ActionsなどのCIサービスではXvfbを使うことで対応できます。

実装上のメリット・デメリット

実際にメンテナンスしてみて感じたことを整理してみました。

項目 内容 評価
テストの実現性 ポップアップ、コンテンツスクリプトの連携 ◎ 実現可能
ユーザー操作の再現性 実際の操作とは異なる部分あり △ 妥協が必要
メンテナンス性 Page Object Patternで整理 ◎ 良好
CI/CDへの統合 ヘッドレス不可、Xvfb必要 △ 追加設定必要
学習コスト CDPの理解が必要 △ 中程度

総合的に見ると、完璧ではないものの、十分実用的だと思います。

特にSnack TimeではClosed Shadow DOMを使っていることもあり、操作が困難です。
ここの対策に関しては別の方法を検討中なので、成功したら何か書きます。

まとめ

Chrome拡張のE2Eテスト、思ったより実用的じゃないですか?
まあ、完璧ではないですし、実際のポップアップの挙動とは違う部分もあります。
でも、主要な機能をカバーできるテストが書けるのは大きいと思います。

特に、複数のコンテキストが絡む複雑な動作や、ユーザーの操作フローをテストできるのは安心感がありますよね。

皆さんもChrome拡張を作る際は、ぜひE2Eテストにチャレンジしてみてください。
最初は面倒に感じるかもしれませんが、一度セットアップしてしまえば、あとは普通のWebアプリとそんなに変わりません。

Snack TimeのコードはGitHubで公開しているので、興味があれば参考にしてみてください。

それでは、Happy Testing! 🎉

Discussion