E2E Testing Browser Extensions: Automation Patterns and Pitfalls
End-to-end testing browser extensions is significantly more complex than E2E testing a web app. Extensions have multiple entry points (popup, options page, content scripts, service worker), interact with browser APIs that automation tools don't fully support, and have permission models that affect behavior.
This guide covers the patterns that work and the pitfalls to avoid.
Setting Up Playwright for Extension Testing
Playwright supports Chrome extensions via persistent contexts. The setup is specific and unforgiving about options:
// playwright.config.js
import { defineConfig } from '@playwright/test';
import path from 'path';
const EXTENSION_PATH = path.join(__dirname, 'dist'); // Built extension directory
export default defineConfig({
projects: [
{
name: 'chrome-extension',
use: {
// Extensions require a persistent context
launchOptions: {
args: [
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
// Extensions don't work in headless mode in older versions
headless: false,
},
},
},
],
});Critical: Extensions must be loaded from a directory containing a manifest.json. You must build your extension before running E2E tests. Add a build step to your CI pipeline before the test step.
# .github/workflows/test.yml
- name: Build extension
run: npm run build
- name: Run E2E tests
run: npx playwright testAccessing the Extension's Pages
Popup Page
The popup is the most commonly tested extension page. Accessing it in Playwright requires navigating to the extension's URL:
// tests/e2e/popup.spec.js
import { test, expect, chromium } from '@playwright/test';
async function getExtensionId(context) {
// Navigate to extensions page to find the extension ID
const page = await context.newPage();
await page.goto('chrome://extensions/');
// Enable developer mode and find the extension ID
const extensionId = await page.evaluate(() => {
const extensions = document.querySelector('extensions-manager')
?.shadowRoot?.querySelector('extensions-item-list')
?.shadowRoot?.querySelectorAll('extensions-item');
// Parse the ID from the first extension
return Array.from(extensions || [])[0]?.getAttribute('id');
});
await page.close();
return extensionId;
}
test.describe('popup', () => {
let browser;
let extensionId;
test.beforeAll(async () => {
browser = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
});
extensionId = await getExtensionId(browser);
});
test.afterAll(() => browser.close());
test('popup renders correctly', async () => {
const popupPage = await browser.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(popupPage.locator('[data-testid="popup-header"]')).toBeVisible();
await expect(popupPage.locator('[data-testid="enable-toggle"]')).toBeVisible();
});
test('enable toggle changes extension state', async () => {
const popupPage = await browser.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
const toggle = popupPage.locator('[data-testid="enable-toggle"]');
// Should start enabled
await expect(toggle).toBeChecked();
// Disable
await toggle.click();
await expect(toggle).not.toBeChecked();
// Reload popup — state should persist
await popupPage.reload();
await expect(toggle).not.toBeChecked();
});
});Options Page
test('options page saves settings', async () => {
const optionsPage = await browser.newPage();
await optionsPage.goto(`chrome-extension://${extensionId}/options.html`);
// Change a setting
await optionsPage.selectOption('[data-testid="theme-select"]', 'dark');
await optionsPage.click('[data-testid="save-button"]');
// Verify success feedback
await expect(optionsPage.locator('[data-testid="saved-indicator"]')).toBeVisible();
// Reload and verify persistence
await optionsPage.reload();
await expect(optionsPage.locator('[data-testid="theme-select"]')).toHaveValue('dark');
});Testing Content Script Interactions
Testing that content scripts work on real pages is where E2E tests provide the most value:
test('extension annotates product prices on shopping sites', async () => {
const page = await browser.newPage();
await page.goto('https://example-shop.com/product/123');
// Wait for content script to run
await page.waitForSelector('[data-ext-annotated]', { timeout: 5000 });
// Verify the extension added its annotation
const annotation = page.locator('.ext-price-annotation').first();
await expect(annotation).toBeVisible();
await expect(annotation).not.toBeEmpty();
});
test('content script handles page navigation without errors', async () => {
const page = await browser.newPage();
const errors = [];
// Collect any JavaScript errors
page.on('pageerror', err => errors.push(err.message));
await page.goto('https://example.com/page-1');
await page.click('a[href="/page-2"]');
await page.waitForLoadState('networkidle');
// No errors from content script after navigation
const extensionErrors = errors.filter(e => e.includes('ext-'));
expect(extensionErrors).toHaveLength(0);
});Testing the Background ↔ Content Script Communication
One of the trickiest things to E2E test is the messaging between content scripts and the background service worker:
test('content script communicates with background to save data', async () => {
const page = await browser.newPage();
await page.goto('https://example.com/article');
// Trigger the save action (e.g., clicking the extension's save button)
await page.keyboard.press('Control+Shift+S'); // Extension shortcut
// Verify the background processed it (via the popup showing updated count)
const popupPage = await browser.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
const savedCount = popupPage.locator('[data-testid="saved-count"]');
await expect(savedCount).toHaveText('1 saved');
});Common E2E Testing Pitfalls
Pitfall 1: Race Conditions with Content Script Injection
Content scripts don't inject instantly. If you navigate to a page and immediately try to interact with extension-injected elements, they might not exist yet:
// WRONG - content script might not have run yet
await page.goto('https://example.com');
await page.click('.ext-button'); // Fails intermittently
// CORRECT - wait for the injected element
await page.goto('https://example.com');
await page.waitForSelector('.ext-button', { timeout: 5000 });
await page.click('.ext-button');Pitfall 2: Extension ID Changes Between Runs
When you load an unpacked extension with --load-extension, Chrome assigns a random ID. This ID changes between browser launches, so you can't hardcode it:
// WRONG - ID is not stable
const popupUrl = 'chrome-extension://abcdefghijklmnop/popup.html';
// CORRECT - discover ID dynamically
const extensionId = await getExtensionId(browser);
const popupUrl = `chrome-extension://${extensionId}/popup.html`;To get a stable ID during development, you can pin it in the manifest using a key field — but this is optional for testing purposes.
Pitfall 3: Service Worker Not Ready
The background service worker may not be active when your test starts, especially on first run:
// CORRECT - trigger service worker activation before testing
async function ensureServiceWorkerReady(extensionId, browser) {
// Opening the extension's popup URL wakes the service worker
const page = await browser.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await page.waitForLoadState('networkidle');
await page.close();
// Give the service worker a moment to initialize
await new Promise(resolve => setTimeout(resolve, 500));
}Pitfall 4: Chrome Extensions and headless Mode
Chrome's --headless=new mode supports extensions from Chrome 112+, but older Playwright versions or CI images may not support this. Always verify:
// playwright.config.js
const SUPPORTS_HEADLESS_EXTENSIONS = process.env.CI && process.env.CHROME_VERSION >= 112;
export default defineConfig({
projects: [{
use: {
launchOptions: {
headless: SUPPORTS_HEADLESS_EXTENSIONS,
args: SUPPORTS_HEADLESS_EXTENSIONS
? ['--headless=new', `--load-extension=${EXTENSION_PATH}`]
: [`--load-extension=${EXTENSION_PATH}`],
},
},
}],
});Pitfall 5: Permissions Dialogs
Some extension permissions trigger browser dialogs that block automation. Permissions like "tabs", "storage", and "scripting" are granted automatically for installed extensions. But optional permissions prompt the user:
// If your extension requests optional permissions at runtime, handle the dialog
page.on('dialog', async dialog => {
await dialog.accept(); // Or dismiss, depending on your test
});For testing purposes, prefer declaring all permissions in manifest.json rather than requesting them optionally — it simplifies test setup significantly.
Testing Across Multiple Pages/Tabs
Extensions often interact across multiple tabs:
test('extension syncs state across tabs', async () => {
const page1 = await browser.newPage();
const page2 = await browser.newPage();
await page1.goto('https://example.com');
await page2.goto('https://example.com');
// Save from tab 1
await page1.click('.ext-save-button');
// Verify tab 2 shows updated state
// (content script should receive the storage change event)
await expect(page2.locator('.ext-saved-indicator')).toBeVisible({ timeout: 3000 });
});CI Configuration
# .github/workflows/extension-e2e.yml
name: Extension E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Build extension
run: npm run build
- name: Install Playwright
run: npx playwright install chromium
- name: Run E2E tests
run: npx playwright test
env:
# Needed for virtual display on Linux
DISPLAY: ':99'
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/On Linux CI, you need a virtual display. Install xvfb:
- name: Install virtual display
run: sudo apt-get install -y xvfb
- name: Run tests with virtual display
run: xvfb-run --auto-servernum npx playwright testSummary
E2E testing browser extensions with Playwright works well once you get the setup right. The key patterns:
- Load the built extension via
--load-extensionin a persistent context - Discover the extension ID dynamically — don't hardcode it
- Use
waitForSelectorfor extension-injected elements — don't assume instant injection - Wake the service worker before testing background functionality
- Use virtual display (xvfb) on Linux CI
The most valuable E2E tests for extensions are the ones that verify the content script works on real pages and that popup/background communication functions correctly — things unit tests can't easily cover.