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
-
Install Node.js
asdf install
-
Install pnpm
npm install -g pnpm
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:
- Go to
chrome://extensions
- Find "Snack Time" extension
- Click the reload button (↻) or toggle the extension off and on
This is a known limitation of Chrome Extension's content scripts.
-
Install dependencies
pnpm install
-
Run the development server
OUTPUT_DIR=~/Documents/snack-time pnpm dev
-
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);
},
});
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")');
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");
}
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();
});
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
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...
}
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();
});
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();
}
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");
}
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)