iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔥

Practical E2E Testing for Chrome Extensions with Playwright and CDP

に公開

The rainy days continue, but it's the perfect season for coding indoors.
K@zuki. here.

How do you all handle Chrome extension testing?

To be honest, E2E testing for Chrome extensions often makes you think, "Is it possible?", "Is it necessary?", or "It seems like a hassle...".
I felt the same way at first.

However, when I actually tried it, it worked surprisingly well.
Moreover, I found that by combining Playwright with the Chrome DevTools Protocol (CDP), you can write quite practical tests.

In this article, I'd like to share the E2E testing methods I implemented for a Chrome extension I'm developing called Snack Time.

https://github.com/corrupt952/snacktime

Summary

  • You can write E2E tests for Chrome extensions using Playwright.
  • Everything, including popups, can be accessed as web pages.
  • Interaction tests between popups and content scripts can be achieved with CDP.
  • High maintainability can be achieved using the Page Object Pattern.
  • Smooth switching between multiple windows using bringToFront().

Why is Chrome Extension E2E Testing Difficult?

First, let's organize the difficulties specific to Chrome extensions.
Compared to regular web applications, Chrome extensions have several special circumstances.

Multiple Execution Contexts

Chrome extensions actually operate in multiple "worlds".

  • Popup ... The screen that appears when you click the extension icon.
  • Content script ... Scripts injected into each web page.
  • Options page ... The settings screen.
  • Background ... Scripts running behind the scenes (Service Worker).
  • Other custom pages.

Since these work in coordination, simple E2E testing seems difficult.

Particularity of Popups

One point where people often don't know "how to give instructions" when trying to perform E2E tests on Chrome extensions is the existence of popups.
In some Chrome extensions, users are often expected to click the extension button in the toolbar and then click elements within the popup to trigger events.
However, this toolbar is usually inaccessible from tools like Playwright, which makes it feel challenging.

Extension-Specific URLs

Chrome extension pages have special URLs like chrome-extension://[Extension ID]/popup.html.
Since this Extension ID changes depending on the environment, it cannot be hardcoded.

Solving with Playwright

Now, let's get to the main topic.
In fact, Playwright can solve these issues elegantly.

Basic Setup

First, let's look at how to load a Chrome extension in 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 do not 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);
  },
});

By preparing this fixture and reusing it in your tests, you can address the issue of dynamically changing IDs.

The key points are as follows:

  • Load the extension using launchPersistentContext
  • Dynamically retrieve the extension ID from chrome://extensions/
  • Define it as a fixture to reuse it across all tests

Opening the Popup as a Separate Page

This is the most important point: by opening the popup as a regular page, you can avoid freezing issues.

// 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 just like a normal page!
await popupPage.click('button:has-text("5:00")');

While this differs from actual user interaction, it is sufficient for functional testing.

Controlling Multiple Windows with CDP

Next, let's discuss navigating between the popup and the content page.

In Snack Time, a timer is displayed within the content page for a duration specified in the popup. If you try to test this mechanism, simply performing operations in the tab where the popup is open won't work because the content page is not the active tab, making it impossible to test the interaction.

This is where the Chrome DevTools Protocol (CDP) comes in, allowing for finer control of the browser. Specifically, Page.bringToFront is very useful because it can bring a specific page to the foreground, 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");
}

By using this mechanism, you can write a test like this:

test("Setting a timer from the popup", async ({ extensionId, context, page }) => {
  // 1. Open the page to be tested
  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. Confirm that the timer is displayed on the content page
  await contentPage.waitForTimer();
  await contentPage.verifyTimerVisible();
});

The great thing about this approach is that you can test in a way that is close to actual user operations.

Organizing with the Page Object Pattern

As the test suite grows, maintenance can become a burden. This is where the Page Object Pattern shines.

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...
}

This keeps your test cases clean and simple.

Practical Test Scenarios

I'll introduce some of the tests I'm actually writing.

Independence Testing across Multiple Tabs

Chrome extensions need to operate independently for each tab. This can also be tested properly:

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

  // Set another timer in Tab 2
  const page2 = await context.newPage();
  await page2.goto("https://www.google.com");
  // ... Timer setup ...

  // Confirm both timers are running independently
  await page1.bringToFront();
  await contentPage1.verifyTimerVisible();
  
  await page2.bringToFront();
  await contentPage2.verifyTimerVisible();
});

Testing Drag & Drop

User interactions can also be tested:

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();
}

Common Pitfalls and Solutions

I encountered several pitfalls while implementing this. Honestly, I think these are inevitable challenges.

Waiting for Asynchronous Processing

Chrome extensions involve many asynchronous processes, so you need to wait for them appropriately. This is a fundamental principle of E2E testing in general, not just for Chrome extensions, but it remains crucial here.

// Wait until the timer is displayed
async waitForTimer(): Promise<void> {
  await this.page.waitForSelector("#snack-time-root");
}

Headless Mode Cannot Be Used

Chrome extensions do not work in headless mode. You must set headless: false even in CI environments. In CI services like GitHub Actions, you can handle this by using Xvfb.

Implementation Pros and Cons

Here is a summary of what I've learned after actually maintaining these tests.

Item Content Evaluation
Test Feasibility Coordination between popups and content scripts ◎ Feasible
User Action Reproducibility Some differences from actual operations △ Compromise required
Maintainability Organized with Page Object Pattern ◎ Good
CI/CD Integration Headless impossible, Xvfb required △ Additional setup required
Learning Cost CDP knowledge required △ Moderate

Overall, while not perfect, I believe it is practical enough.

In particular, Snack Time uses a Closed Shadow DOM, which makes operations difficult. I am currently considering a workaround for this and will write about it if I find a successful approach.

Conclusion

Doesn't E2E testing for Chrome extensions seem more practical than you might have thought?
Admittedly, it's not perfect, and some aspects differ from how actual popups behave. However, being able to write tests that cover core functionality is a major advantage.

The peace of mind that comes from testing complex interactions across multiple contexts and user flows is invaluable.

I encourage you to try E2E testing when you develop your own Chrome extensions. It may feel tedious at first, but once the setup is complete, it's not much different from testing a standard web application.

The code for Snack Time is public on GitHub, so feel free to use it as a reference if you're interested.

Happy Testing! 🎉

Discussion