E2E Testing Browser Extensions: Automation Patterns and Pitfalls

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 test

Accessing the Extension's Pages

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 test

Summary

E2E testing browser extensions with Playwright works well once you get the setup right. The key patterns:

  1. Load the built extension via --load-extension in a persistent context
  2. Discover the extension ID dynamically — don't hardcode it
  3. Use waitForSelector for extension-injected elements — don't assume instant injection
  4. Wake the service worker before testing background functionality
  5. 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.

Read more

Start now free