Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use Playwright for the most direct Python workflow: launch Playwright’s bundled Chromium with a persistent context, load the unpacked extension with Chromium flags, then test the page, popup, and Manifest V3 service worker as separate targets. Selenium can also install an extension, but Chrome’s documented Selenium path has important service-worker inspection and lifecycle limitations.

Choose the target you are actually testing

A browser extension can affect several different surfaces. Decide which one your test must exercise before writing selectors or waiting logic.

Target What to verify Best access pattern
Ordinary web page Injected content scripts, modified DOM, blocked requests, or user-visible changes Open a normal URL and assert the resulting page behavior
Extension popup Controls shown when the toolbar icon is opened Use the library’s popup-opening capability when available, or navigate to the popup document
Manifest V3 service worker Background events, message handling, alarms, and lifecycle-sensitive logic Obtain the worker target and inspect it only when the test requires internals
Options or other extension page Settings and internal UI Navigate to its chrome-extension:// URL after discovering the extension ID

Chrome for Developers describes the goal of an automation tool as communicating with the browser to test the same flows a user would go through. In practice, make user-visible assertions your default; internal worker checks should be reserved for behavior that cannot be observed through the page or extension UI.

Recommended setup: Playwright with Python

Install Playwright and its Chromium build

  1. Create and activate a virtual environment.
  2. Install the Python package: pip install playwright.
  3. Download the browser binaries: playwright install chromium.

Playwright’s official extension recipe requires a persistent context. Use the Chromium bundled with Playwright rather than assuming that system Google Chrome or Microsoft Edge can load the extension: those browsers removed the command-line flags needed for this side-loading workflow. The documented headless-capable route uses channel="chromium"; run headed when you need to watch the browser during debugging.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Minimal end-to-end test of an unpacked extension

The example below assumes an unpacked extension directory at ./my-extension. Its manifest should already be valid, and the directory must contain the files referenced by that manifest.

from pathlib import Path
from playwright.sync_api import sync_playwright

EXTENSION_DIR = Path(__file__).parent.joinpath("my-extension").resolve()
PROFILE_DIR = Path(__file__).parent.joinpath(".pw-profile").resolve()

with sync_playwright() as p:
    context = p.chromium.launch_persistent_context(
        user_data_dir=str(PROFILE_DIR),
        channel="chromium",
        headless=True,
        args=[
            f"--disable-extensions-except={EXTENSION_DIR}",
            f"--load-extension={EXTENSION_DIR}",
        ],
    )
    page = context.pages[0] if context.pages else context.new_page()
    page.goto("https://example.com", wait_until="domcontentloaded")

    # Replace this with an assertion about your extension's visible effect.
    page.locator("body").wait_for()
    assert page.title() == "Example Domain"

    context.close()

For visual debugging, change headless=True to headless=False. Keep the profile directory dedicated to the test. Reusing a profile that is open in another Chromium process can fail or leak state between tests.

Use a worker event for Manifest V3 background logic

Manifest V3 background code runs in a service worker instead of a persistent background page. Wait for the worker event before deriving the extension ID:

from pathlib import Path
from playwright.sync_api import sync_playwright

extension_dir = Path("my-extension").resolve()
profile_dir = Path(".pw-profile-worker").resolve()

with sync_playwright() as p:
    context = p.chromium.launch_persistent_context(
        str(profile_dir),
        channel="chromium",
        headless=True,
        args=[
            f"--disable-extensions-except={extension_dir}",
            f"--load-extension={extension_dir}",
        ],
    )

    worker = context.service_workers[0] if context.service_workers else None
    if worker is None:
        with context.expect_event("serviceworker") as worker_info:
            # Opening a page gives Chromium a chance to start the extension.
            page = context.pages[0] if context.pages else context.new_page()
            page.goto("https://example.com", wait_until="domcontentloaded")
        worker = worker_info.value

    extension_id = worker.url.split("/")[2]
    assert worker.url.startswith(f"chrome-extension://{extension_id}/")
    print("Extension ID:", extension_id)

    context.close()

The exact startup trigger depends on the extension. If no worker appears, verify that the manifest is Manifest V3, the service-worker file exists, and the extension actually has background work that causes Chromium to start it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Test the popup and extension-owned pages

Navigate directly to the popup document

Once you have the ID, open the popup as a tab. This is deterministic and works when the popup is a normal HTML document:

popup = context.new_page()
popup.goto(
    f"chrome-extension://{extension_id}/popup.html",
    wait_until="domcontentloaded",
)
popup.get_by_role("button", name="Enable").click()
assert popup.get_by_text("Enabled").is_visible()

Replace popup.html and the locators with the paths and accessible labels in your manifest and UI. A popup opened as a tab does not always have the same active-tab context as a toolbar popup. If your code reads the active tab, pass an explicit tab override or arrange the test so the intended page is active before opening the popup.

Prefer the popup-opening API when your library provides one

Chrome’s extension testing guidance recommends invoking the automation library’s popup-opening capability when available. That more closely represents a user opening the toolbar UI. If your installed Playwright version or test harness does not expose such a method, direct navigation to the popup URL is the documented fallback.

Assert outcomes before implementation details

  • For a content script, assert the page element, text, attribute, or behavior a user can see.
  • For a blocked request, assert the resulting page state rather than a private background variable.
  • For popup actions, assert the setting reflected in the popup or on the active page.
  • Inspect service-worker messages, storage, or functions only when no stable user-facing assertion can prove the requirement.

Headless, headed, and CI choices

Headless execution

Playwright’s extension guide points to the chromium channel for headless extension runs. Chrome’s testing documentation specifies the modern --headless=new mode for Chrome-based automation. Browser flags and channel support change, so verify the behavior against the versions pinned by your project.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Headed debugging

Run with headless=False when diagnosing permissions, popup rendering, or timing. Add screenshots and traces around the failing step, but keep the production assertion independent of pixels unless visual output is the requirement.

Reproducible CI browsers

For a Selenium or ChromeDriver pipeline, Chrome recommends Chrome for Testing with a matching ChromeDriver. Pin both versions in CI instead of depending on whatever browser happens to be installed on the runner. On machines without a graphical display, use headless mode. Apply the same principle to Playwright: install its browser version during the build and use a fresh, isolated profile per job.

Selenium as an alternative

Selenium can load an extension through Chrome options or its WebExtension installation interface. The exact API depends on the Selenium and Chrome versions in use, so confirm the current Selenium and Chrome documentation before copying a command into a production suite. A basic Chrome-options shape is:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-extensions-except=/absolute/path/to/my-extension")
options.add_argument("--load-extension=/absolute/path/to/my-extension")

driver = webdriver.Chrome(options=options)
try:
    driver.get("https://example.com")
    assert driver.title == "Example Domain"
finally:
    driver.quit()

Chrome’s documented Selenium guidance says Selenium does not directly access the service worker through that approach. Chrome also notes that ChromeDriver attaches a debugger to service workers, preventing their normal automatic termination during Selenium tests. Consequently, Selenium is suitable for page and UI assertions, but lifecycle-sensitive Manifest V3 tests can behave differently from real worker shutdown and restart.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Selenium’s site also demonstrates WebExtension installation with remote debugging and an enable-unsafe-extension-debugging switch. Treat that as version-sensitive configuration: use the installation route documented for the precise Selenium, Chrome, and ChromeDriver versions you pin.

Playwright and Selenium compared

Concern Playwright Python Selenium
Loading an unpacked extension Persistent context plus --disable-extensions-except and --load-extension; official recipe uses bundled Chromium Chrome options or WebExtension installation interface; verify version-specific API behavior
Headless mode Use the chromium channel documented for extension testing Chrome documents --headless=new
Service-worker access Documented service-worker target and event support Not directly available through Chrome’s documented Selenium method
Worker lifecycle tests Can observe the worker target, subject to extension startup behavior ChromeDriver’s debugger attachment prevents normal automatic worker termination
Popup testing Use popup API where available, otherwise navigate to the extension URL Use the corresponding Selenium window or tab navigation strategy
CI reproducibility Install and pin Playwright’s Chromium build Pin matching Chrome for Testing and ChromeDriver versions

Common failures and fixes

“The extension is not loaded”

  • Use an absolute path, resolved with Path.resolve().
  • Pass the unpacked directory, not a ZIP file.
  • Check that manifest.json is at the directory root and valid.
  • Ensure no other extension flags contradict the two loading flags.
  • Use Playwright’s bundled Chromium; system Chrome and Edge may not support the required side-loading flags.

No service-worker event arrives

  • Confirm the manifest uses Manifest V3 and names the correct background.service_worker.
  • Trigger an extension action or open a page that causes the worker to start.
  • Wait for the event before reading the worker URL; do not assume startup is instantaneous.
  • For Selenium, do not expect the same direct worker access or termination behavior documented for Playwright.

The popup behaves differently in a tab

  • Check whether the popup reads the active tab, selected tab, or window state.
  • Open the intended page first and explicitly provide the tab context if the test framework supports it.
  • Use the library’s popup-opening capability for interaction semantics closer to a toolbar click.

Tests pass locally but fail in CI

  • Pin browser and driver versions.
  • Use a clean profile directory for every test job.
  • Run headless only when the runner has no display, and capture logs, screenshots, or traces on failure.
  • Replace fixed sleeps with waits for a selector, URL, network condition, or service-worker event.

Selectors are flaky

Prefer roles, labels, and stable data attributes over generated class names. Wait for the state the user needs to see, not an arbitrary delay. Keep extension-internal assertions narrow so a harmless implementation refactor does not invalidate a user-flow test.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your goal is a clean screenshot of a page after extension-like browser automation, ScreenshotNeo provides a single HTTP request rather than a local Chromium harness. It is a website screenshot API and MCP server; it does not replace tests that must install and exercise your extension’s popup or service worker.

For a page capture, see the parameter reference in the ScreenshotNeo documentation and call the API:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot.
  • Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed as clean shots; response headers identify the page verdict and billing result.
  • An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
  • The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Sign up for the free ScreenshotNeo plan to try it without a card.

Operational checklist

  • Identify whether the requirement concerns a normal page, popup, options page, or Manifest V3 worker.
  • For Playwright, use a persistent context, an isolated profile, and the bundled Chromium channel.
  • Load the unpacked directory with both extension flags.
  • Wait for service-worker startup before deriving the extension ID.
  • Prefer visible behavior assertions; use internals only when necessary.
  • Pin browser versions and replace sleeps with state-based waits.
  • Document Selenium’s worker-access and lifecycle limitations if your team chooses it.

Frequently Asked Questions

Can I load a packed CRX file with the Playwright recipe?

The documented recipe loads an unpacked extension directory. Unpack the extension and pass the directory containing its manifest and files.

Why does the extension ID change between test runs?

An ID derived from a worker URL should be treated as runtime data. Discover it after the worker starts instead of hard-coding an ID.

Should every test inspect the service worker?

No. Inspect it only for background behavior that cannot be established through a page or extension UI; user-visible assertions are generally less brittle.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Is ScreenshotNeo a replacement for extension interaction tests?

No. It captures web pages and PDFs through an API and MCP tools. Use Playwright or Selenium when the test must install and interact with your extension itself.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.