iTranslated by AI
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.
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