DEV Community

K@zuki.
K@zuki.

Posted on

How I Built E2E Tests for Chrome Extensions Using Playwright and CDP

End-to-End Testing for Chrome Extensions with Playwright

The rainy days continue, but it's the perfect season for indoor coding, isn't it?
I'm K@zuki..

How do you test your Chrome extensions?

To be honest, when it comes to E2E testing for Chrome extensions, you might think: "Is it even possible?", "Is it necessary?", "Sounds complicated..."
I thought the same at first.

But when I actually tried it, it turned out to be surprisingly doable.
Moreover, by combining Playwright with Chrome DevTools Protocol (CDP), I found that you can write quite practical tests.

Today, I'd like to share the E2E testing approach I implemented for my Chrome extension called Snack Time.

Snack Time

The repository for chrome extension to remind you to take a break and have a snack.

Installation

Install this extension from the Chrome Web Store.

Development

Prerequisites

  • asdf or compatible .tool-versions file

Setup

  1. Install Node.js

    asdf install
    Enter fullscreen mode Exit fullscreen mode
  2. Install pnpm

    npm install -g pnpm
    Enter fullscreen mode Exit fullscreen mode

Debugging

This extension is developed using CRXJS. So, if you run pnpm dev and load the output directory as an extension, the file will be updated in real time. OUTPUT_DIR is the directory where the output will be placed.

When modifying Content.tsx (Timer component), hot reload may not work properly. In this case, you need to restart the extension:

  1. Go to chrome://extensions
  2. Find "Snack Time" extension
  3. Click the reload button (↻) or toggle the extension off and on

This is a known limitation of Chrome Extension's content scripts.

  1. Install dependencies

    pnpm install
    Enter fullscreen mode Exit fullscreen mode
  2. Run the development server

    OUTPUT_DIR=~/Documents/snack-time pnpm dev
    Enter fullscreen mode Exit fullscreen mode
  3. Load the extension…

Summary

  • You can write E2E tests for Chrome extensions using Playwright
  • Everything, including popups, can be accessed as web pages
  • Integration tests between popups and content scripts are achievable with CDP
  • Page Object Pattern makes test code maintainable
  • bringToFront() enables smooth switching between multiple windows

Why E2E Testing for Chrome Extensions is Challenging

First, let's organize the unique challenges of Chrome extensions.
Compared to regular web applications, Chrome extensions have several special circumstances.

Multiple Execution Contexts

Chrome extensions actually run in multiple "worlds":

  • Popup - The screen that appears when you click the extension icon
  • Content Scripts - Scripts injected into each web page
  • Options Page - Settings screen
  • Background - Scripts running in the background (Service Worker)
  • Other custom pages

Since these work together, it seems difficult to test with simple E2E tests.

The Peculiarity of Popups

One of the challenges when wanting to perform E2E testing for Chrome extensions is "not knowing how to interact with popups."
Many Chrome extensions require users to click the extension button in the toolbar and then click elements within the popup to trigger events.
However, Playwright and similar tools cannot normally access this toolbar, which makes it seem difficult.

Extension-Specific URLs

Chrome extension pages have special URLs like chrome-extension://[extension-id]/popup.html.
This extension ID changes depending on the environment, so you can't hardcode it.

Solving with Playwright

Now, let's get to the main topic.
Actually, Playwright can solve these problems quite elegantly.

💡 Any tool using the same driver should be capable of this.

Basic Setup

First, let's look at how to load a Chrome extension with 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, // Extensions don't work in headless mode
      args: [
        `--disable-extensions-except=${pathToExtension}`,
        `--load-extension=${pathToExtension}`
      ],
    });
    await use(context);
    await context.close();
  },
  extensionId: async ({ context }, use) => {
    // Dynamically get the extension 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);
  },
});
Enter fullscreen mode Exit fullscreen mode

By preparing this fixture and reusing it in each test, we can handle the issue of dynamically changing IDs.

Key points:

  • Use launchPersistentContext to load the extension
  • Dynamically retrieve the extension ID from chrome://extensions/
  • Define as a fixture so it can be reused across all tests

Opening Popups as Separate Pages

Here's the crucial point: by opening the popup as a regular page, we can avoid the freeze issue.

// Open the popup as a new page
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup/index.html`);

// Now you can test it like a normal page!
await popupPage.click('button:has-text("5:00")');
Enter fullscreen mode Exit fullscreen mode

Well, it's different from actual user interaction, but it's sufficient for functional testing.

Controlling Multi-Window with CDP

Next, let's talk about switching between popups and content pages.

In Snack Time, the timer specified in the popup is displayed within the content page.
Even if you try to test such a mechanism, you can't test it by operating in the tab where the popup is open because the active tab is not the content page.

This is where Chrome DevTools Protocol (CDP) comes in handy for finer browser control.
Page.bringToFront is particularly useful as it brings a specific page to the front, allowing you to switch the active tab.

// content-timer.page.ts - bringToFront implementation
async bringToFront(): Promise<void> {
  const client = await this.page.context().newCDPSession(this.page);
  await client.send("Page.bringToFront");
}
Enter fullscreen mode Exit fullscreen mode

With this mechanism, you can write tests like this:

test("Set timer from popup", async ({ extensionId, context, page }) => {
  // 1. Open the test target page
  await page.goto("https://example.com");
  const contentPage = new ContentTimerPage(page);

  // 2. Open the popup
  const popupPageHandle = await context.newPage();
  const popupPage = new PopupPage(popupPageHandle, extensionId);
  await popupPage.open();

  // 3. Bring content page to front (using CDP)
  await contentPage.bringToFront();

  // 4. Set timer in popup
  await popupPage.clickPresetButton("5");

  // 5. Verify timer is displayed on content page
  await contentPage.waitForTimer();
  await contentPage.verifyTimerVisible();
});
Enter fullscreen mode Exit fullscreen mode

The great thing is that we can test in a way that's close to actual user operations.

sequenceDiagram
    participant Test as Test Code
    participant Popup as Popup Page<br/>(New Tab)
    participant CDP as Chrome DevTools Protocol
    participant Content as Content Page<br/>(example.com)
    participant Timer as Timer Element<br/>(#snack-time-root)

    Test->>Content: 1. Open example.com
    Test->>Popup: 2. Open popup in new tab<br/>chrome-extension://id/popup.html
    Test->>CDP: 3. bringToFront()<br/>(Content Page)
    CDP->>Content: 4. Make content page active
    Test->>Popup: 5. Click preset button (5 min)
    Popup->>Timer: 6. Inject timer
    Test->>Timer: 7. waitForSelector("#snack-time-root")
    Test->>Timer: 8. Verify timer display
Enter fullscreen mode Exit fullscreen mode

Organizing with Page Object Pattern

As test code grows, maintenance becomes challenging.
This is where the Page Object Pattern comes in.

// popup.page.ts - Page Object implementation example
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();
  }

  // Define other actions similarly...
}
Enter fullscreen mode Exit fullscreen mode

This way, test cases can be kept simple.

Practical Test Scenarios

Let me introduce some actual tests I've written.

Independence Test Across Multiple Tabs

Chrome extensions need to work independently for each tab. We can test this too:

test("Independent timers work in different tabs", async ({ extensionId, context }) => {
  // Set timer in tab 1
  const page1 = await context.newPage();
  await page1.goto("https://example.com");
  // ... set timer ...

  // Set different timer in tab 2
  const page2 = await context.newPage();
  await page2.goto("https://www.google.com");
  // ... set timer ...

  // Verify both timers are working independently
  await page1.bringToFront();
  await contentPage1.verifyTimerVisible();

  await page2.bringToFront();
  await contentPage2.verifyTimerVisible();
});
Enter fullscreen mode Exit fullscreen mode

Drag & Drop Test

You can also test user interactions:

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();
}
Enter fullscreen mode Exit fullscreen mode

Pitfalls and Solutions

During implementation, I encountered several pitfalls.
Honestly, these are unavoidable challenges.

Waiting for Asynchronous Operations

Chrome extensions have many asynchronous operations, so you need to wait appropriately.
This is fundamental in E2E testing, not just for Chrome extensions, but it's especially important here.

// Wait for timer to appear
async waitForTimer(): Promise<void> {
  await this.page.waitForSelector("#snack-time-root");
}
Enter fullscreen mode Exit fullscreen mode

Can't Use Headless Mode

Chrome extensions don't work in headless mode. You need to set headless: false even in CI.
In CI services like GitHub Actions, you can handle this by using Xvfb.

Pros and Cons of Implementation

Here's what I've learned from actual maintenance:

Item Content Rating
Test Feasibility Popup and content script integration ◎ Achievable
User Operation Reproduction Some differences from actual operations △ Compromises needed
Maintainability Organized with Page Object Pattern ◎ Good
CI/CD Integration No headless, Xvfb required △ Additional setup needed
Learning Curve Need to understand CDP △ Moderate

Overall, while not perfect, it's sufficiently practical.

Especially with Snack Time using Closed Shadow DOM, operations are difficult.
I'm exploring alternative approaches for this, and I'll write about it if successful.

Conclusion

E2E testing for Chrome extensions is more practical than you might think, isn't it?
Sure, it's not perfect, and there are differences from actual popup behavior.
But being able to write tests that cover major functionality is significant.

Especially being able to test complex behaviors involving multiple contexts and user operation flows provides peace of mind.

When creating Chrome extensions, I encourage you to try E2E testing.
It might feel tedious at first, but once you set it up, it's not that different from regular web apps.

The Snack Time code is available on GitHub, so feel free to reference it if you're interested.

Happy Testing! 🎉

Top comments (0)