End-to-End Testing for Chrome Extensions: Complete Guide (2026)

AppBooster Team · · 4 min read
End-to-end testing code on a screen

Why End-to-End Testing for Extensions?

Unit tests verify individual functions work correctly. End-to-end tests verify your entire extension works as users experience it — popup rendering, content script injection, service worker behavior, and cross-component communication all tested together.

E2E tests catch the integration bugs that unit tests miss: a popup that renders blank because the service worker hasn’t initialized storage, a content script that fails on specific page structures, or a message handler that drops messages under load.


Choosing a Testing Library

LibraryExtension SupportHeadlessBest For
PuppeteerNative (--load-extension)Chrome onlyChrome-specific extensions
PlaywrightVia launch argsChromiumCross-browser testing
SeleniumVia ChromeOptionsVia ChromeDriverExisting Selenium infra
WebDriverIONative supportVia WebDriverComprehensive test suites

Puppeteer Setup

const puppeteer = require('puppeteer');
const path = require('path');

async function setupBrowser() {
  return puppeteer.launch({
    headless: false, // Required for extensions
    args: [
      `--disable-extensions-except=${path.resolve('./dist')}`,
      `--load-extension=${path.resolve('./dist')}`
    ]
  });
}

Playwright Setup

const { chromium } = require('playwright');
const path = require('path');

async function setupBrowser() {
  return chromium.launchPersistentContext('/tmp/test-profile', {
    headless: false,
    args: [
      `--disable-extensions-except=${path.resolve('./dist')}`,
      `--load-extension=${path.resolve('./dist')}`
    ]
  });
}

Selenium Setup

const { Builder } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const path = require('path');

async function setupDriver() {
  const options = new chrome.Options();
  options.addArguments(`--load-extension=${path.resolve('./dist')}`);

  return new Builder()
    .forBrowser('chrome')
    .setChromeOptions(options)
    .build();
}

Maintaining a Fixed Extension ID

A consistent ID simplifies testing — you can hardcode URLs and allowlist origins:

  1. Upload to the Chrome Developer Dashboard
  2. Copy the public key
  3. Add to manifest.json:
{
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
}

With a fixed ID, extension page URLs become predictable:

chrome-extension://abcdefghijklmnopqrstuvwx/popup.html
chrome-extension://abcdefghijklmnopqrstuvwx/options.html

Testing Extension Pages

test('options page saves settings', async () => {
  const page = await browser.newPage();
  await page.goto(`chrome-extension://${EXTENSION_ID}/options.html`);

  // Change a setting
  await page.click('#dark-mode-toggle');
  await page.click('#save-btn');

  // Verify toast notification
  await page.waitForSelector('.toast-success');
  const toast = await page.$eval('.toast-success', (el) => el.textContent);
  expect(toast).toContain('Settings saved');

  // Reload and verify persistence
  await page.reload();
  const isChecked = await page.$eval('#dark-mode-toggle', (el) => el.checked);
  expect(isChecked).toBe(true);
});

Testing Popups

Two approaches for popup testing:

Approach 1: Open Popup URL Directly

test('popup renders tab list', async () => {
  const page = await browser.newPage();
  await page.goto(`chrome-extension://${EXTENSION_ID}/popup.html`);

  await page.waitForSelector('.tab-item');
  const tabCount = await page.$$eval('.tab-item', (items) => items.length);
  expect(tabCount).toBeGreaterThan(0);
});

Approach 2: Use action.openPopup()

test('popup opens via action', async () => {
  const worker = await getServiceWorker(browser);
  await worker.evaluate(() => chrome.action.openPopup());

  const popupTarget = await browser.waitForTarget(
    (t) => t.type() === 'page' && t.url().endsWith('popup.html')
  );
  const popup = await popupTarget.page();

  const heading = await popup.$eval('h1', (el) => el.textContent);
  expect(heading).toBeDefined();
});

Simulating Tab Context in Popup

Popups often behave differently based on the active tab. Pass context via URL params:

// In your popup.js
const params = new URLSearchParams(window.location.search);
if (params.has('tab')) {
  activeTabId = parseInt(params.get('tab'));
}
// In tests
await page.goto(
  `chrome-extension://${EXTENSION_ID}/popup.html?tab=${testTabId}`
);

Inspecting Service Worker State

Puppeteer

async function getServiceWorker(browser) {
  const target = await browser.waitForTarget(
    (t) => t.type() === 'service_worker'
  );
  return target.worker();
}

test('storage is initialized on install', async () => {
  const worker = await getServiceWorker(browser);
  const settings = await worker.evaluate(async () => {
    return chrome.storage.local.get('settings');
  });

  expect(settings.settings).toBeDefined();
  expect(settings.settings.theme).toBe('light');
});

Selenium

test('service worker state via extension page', async () => {
  await driver.get(`chrome-extension://${EXTENSION_ID}/debug.html`);

  const result = await driver.executeAsyncScript(`
    const callback = arguments[arguments.length - 1];
    chrome.storage.local.get('settings', (data) => callback(data));
  `);

  expect(result.settings).toBeDefined();
});

CI/CD Integration

GitHub Actions (Linux)

- run: xvfb-run --auto-servernum npm test

Headless Mode (Chrome 112+)

const browser = await puppeteer.launch({
  headless: 'new', // New headless mode supports extensions
  args: [
    `--disable-extensions-except=${EXTENSION_PATH}`,
    `--load-extension=${EXTENSION_PATH}`
  ]
});

Test Organization

tests/
├── setup.js              # Browser launch and teardown
├── popup.test.js          # Popup UI tests
├── options.test.js        # Options page tests
├── content-script.test.js # Content script injection
├─�� service-worker.test.js # Background logic
└── integration.test.js    # Cross-component flows

What’s Next

E2E testing gives you the confidence to ship extensions knowing they work end-to-end. Start with popup and options page tests, add service worker validation, and run everything in CI.

Grow your well-tested extension with ExtensionBooster.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles