Testing Browser Extensions: Unit Tests, Integration Tests, and E2E Automation

Browser extension testing is harder than web application testing because extensions consist of multiple isolated execution contexts — a background service worker, one or more content scripts, popup and options pages — that communicate via message passing. Each context has different APIs available to it, different lifecycle behavior, and different access to browser internals. Standard web testing tools assume a single document context, which makes most of what developers know about frontend testing require adaptation.

The Three Contexts and What They Mean for Testing

Understanding what needs to be tested requires understanding the contexts:

Background service worker (MV3) or background page (MV2): The persistent logic center. No DOM. Has access to the full chrome.* API set (with declared permissions). Manages state, responds to browser events, coordinates across tabs. Testing this in isolation requires mocking the chrome API.

Content scripts: Injected into web pages. Restricted chrome.* API access (only runtime.sendMessage, storage, etc. — no tabs or webRequest). Can read and modify the page DOM. Testing content scripts requires either a real browser document or a DOM simulation library.

Popup and options pages: Standard web pages with access to chrome extension APIs. These are the easiest to test because they’re closest to a normal web application.

The message-passing boundary between contexts is where integration bugs most commonly live. A content script sends a message expecting a certain response; the background worker sends back what it thinks is correct; the disconnect reveals a schema mismatch or timing assumption.

Unit Testing Background Logic

Background service worker logic is primarily business logic: filtering, state management, rule evaluation, storage reads/writes. These can be tested with a standard JavaScript test runner (Jest, Vitest, Mocha) if you mock the chrome.* API.

The jest-chrome library provides TypeScript-typed mocks of the Chrome extension API that work with Jest. Alternatively, write your own stub:

// Simple chrome.storage.local mock
global.chrome = {
  storage: {
    local: {
      get: jest.fn().mockResolvedValue({}),
      set: jest.fn().mockResolvedValue(),
      remove: jest.fn().mockResolvedValue()
    }
  },
  runtime: {
    sendMessage: jest.fn(),
    onMessage: { addListener: jest.fn() }
  }
};

Keep background logic in pure functions that don’t directly call chrome.* wherever possible. A function that takes a URL and returns a filtering decision is easy to unit test. A function that calls chrome.storage.local.get in the middle of its logic requires more setup. The architectural distinction pays off in testability.

Unit Testing Content Scripts

Content scripts interact with the DOM. For pure DOM logic, jsdom (the DOM simulation library used by Jest’s default environment) handles most use cases. The main gap is that jsdom doesn’t implement all browser APIs — things like MutationObserver, IntersectionObserver, and canvas operations may require polyfills or mocks.

For content scripts that use chrome.runtime.sendMessage, mock the chrome.runtime API the same way as for background tests. The content script’s behavior in response to DOM events and messages can then be unit tested without a real browser.

Integration Testing with a Real Browser

Unit tests catch logic errors but miss browser-specific behavior: API differences between Chrome and Firefox, permission boundary violations, actual rendering behavior, and service worker lifecycle quirks. Integration tests run the extension in a real browser.

Playwright supports loading unpacked extensions in Chromium-based browsers via the --load-extension and --disable-extensions-except flags. A Playwright test can launch Chrome with your extension loaded, navigate to a page, and assert that the extension’s content script has modified the DOM as expected. The Playwright documentation has the setup for extension testing.

// Playwright: loading an extension
const context = await chromium.launchPersistentContext('', {
  headless: false, // extension testing requires headed mode
  args: [
    `--disable-extensions-except=${pathToExtension}`,
    `--load-extension=${pathToExtension}`
  ]
});

Headless mode with extension loading is not supported in Chrome as of 2025 — extension testing with Playwright requires headed mode or the headless “new” experimental headless variant, and behavior may differ. This is a known limitation.

Puppeteer also supports extension loading with similar arguments. Choose based on which tool your team already uses.

Accessing Extension Service Worker from Tests

Testing that the background service worker responded correctly to a browser event requires access to the service worker’s context from the test. In Playwright, you can access the background service worker via the extension’s service worker URL:

const [backgroundPage] = await context.serviceWorkers();
const result = await backgroundPage.evaluate(() => {
  return chrome.storage.local.get('myKey');
});

This gives direct access to evaluate code in the service worker context, which is useful for setting up state before a test or asserting state after user actions.

Testing Message Passing

Message passing tests are the most important integration tests for multi-context extensions. A test should:

  1. Set up the background worker state
  2. Simulate a content script sending a message
  3. Assert the background worker’s response
  4. Assert any state changes the message triggered

This requires either Playwright’s service worker evaluation API or a separate test harness that loads both contexts. A pragmatic alternative: test the message handler functions directly in unit tests by mocking chrome.runtime.onMessage.addListener and calling the listener callback directly.

End-to-End Tests

E2E tests for extensions simulate full user workflows: user opens the extension popup, interacts with settings, navigates to a page, extension content script modifies the page in the expected way.

Structure E2E tests around user stories rather than implementation details. An E2E test should not need to know whether a particular setting is stored in chrome.storage.sync or chrome.storage.local — it should only verify that changing the setting in the popup changes extension behavior on pages.

For CI, E2E tests in headed mode require a virtual display on Linux. The common pattern is running them in Docker with Xvfb or using GitHub Actions with a pre-configured browser environment. puppeteer/puppeteer#1052 has useful CI configuration patterns from the community.

Testing Across Browsers

If your extension targets both Chrome and Firefox, test on both. The extension APIs are largely compatible but there are behavioral differences:

  • Firefox’s MV3 implementation preserves webRequestBlocking, which Chrome removed. Tests written against Chrome’s blocking behavior may pass but be testing the wrong thing.
  • Firefox extension APIs are under the browser.* namespace with Promises; Chrome uses chrome.* with callbacks (though Chrome now also supports chrome.* with Promises for most APIs). Browser-polyfill libraries like webextension-polyfill normalize this.
  • Storage API behavior, message passing timing, and permission prompts can differ.

Run the same integration and E2E test suite against both browsers. Playwright supports Firefox as a test target.

FAQ

Can I test a Chrome extension in Jest without a real browser? Yes for unit tests. Jest with jsdom and mocked chrome.* APIs handles logic-level testing. You cannot test browser-specific behavior (actual tabs API, real network requests, rendering) without a real browser.

How do I test the extension popup? Playwright and Puppeteer can both open the extension popup’s URL directly (chrome-extension://<id>/popup.html) and interact with it as a normal page. Get the extension ID from the extension object returned by the context after loading.

What should I prioritize when adding tests to an existing extension? Message passing logic between content scripts and the background worker — that’s where integration bugs concentrate. After that: storage state management, and content script DOM modifications on the page types your extension targets.

How do I keep the extension ID stable across test runs? Chrome generates extension IDs from the public key in the manifest. Include a key field in your development manifest with a consistent key. The ID will be stable. Remove the key field before publishing — the store assigns a permanent ID on first submission.