After 2 Years with Selenium, Playwright’s Native Extension Loading Changed Everything

python dev.to

At 1:30 AM, the user group chat exploded: all the saved quick commands inside the extension had suddenly vanished, completely breaking the workflows of a dozen people. I crawled out of bed to dig through the logs and found that chrome.storage.sync, in an environment without a network connection, had wiped the local cache along with it—a “clear” logic we’d never tested. Why? Because our previous Selenium-based extension tests couldn’t touch the real storage API, let alone simulate a sync conflict. That night convinced me to tear down our entire extension testing setup and start over. The rebuild landed on Playwright. This article is the story of that from-scratch rewrite—and how I shrunk the regression time for our sync logic from 2 hours to 3 minutes.

Breaking Down the Problem: The Three Mountains of Extension Testing

Testing browser extensions is hard because an extension runs across three isolated worlds simultaneously: the content script (injected into pages), the popup/options pages, and the background service worker. Traditional UI automation tools can only interact with normal web pages and have no real access to the internals of an extension. Take Selenium, for example: the best you can do is pass a .crx file via ChromeOptions and then click around the browser window. You can’t reach the service worker’s console, much less read or write directly to chrome.storage.

I tried practically every workaround out there: splitting the extension logic into plain JS modules and testing them with Jest, but any test involving chrome.storage.sync quotas or conflict policies instantly fell apart. Or, using Puppeteer’s page.evaluate to inject a fake chrome object onto a normal page—this worked marginally for content scripts, but was useless for popups and service workers. The root cause was simple: none of these tools treat the extension itself as a first-class citizen. They see a browser window, not a complete extension runtime.

Solution Design: Why Playwright

What I needed was straightforward: load an unpacked extension directory, connect directly to the background service worker, and execute JavaScript there to verify storage state. After searching, three candidates emerged:

  • Puppeteer: supports --load-extension and can retrieve the worker target, but its API is low-level—you have to manage CDP sessions manually, which makes writing tests tedious.
  • Selenium 4 + DevTools: can also go through CDP, but the framework itself is heavy, documentation for extensions is scarce, and the cost of stumbling through edge cases was too high.
  • Playwright: browser_context accepts a loadExtension option that points to the unpacked extension folder. The returned context.backgroundPage() is the service worker, and you can evaluate on it just like a normal page. Plus, Playwright ships with its own test runner and fixtures, so writing test cases feels like writing unit tests.

The reasons for rejecting the first two were clear: I needed something that could run reliably in CI, not a pile of hacks that barely pass once. Playwright abstracts every extension page into a normal Page object—which means I can use the exact same API to interact with the popup, the options page, and even the service worker. That uniformity matters enormously.

Core Implementation: Three Pieces of the Puzzle

Below I’ll show three runnable pieces of code. Piece one: launch a browser context with the extension and grab the background service worker.

# This snippet solves: how to launch Playwright together with the extension we’re developing
import os
from playwright.sync_api import sync_playwright

EXTENSION_PATH = os.path.abspath("./my-extension")  # directory containing manifest.json

with sync_playwright() as p:
    browser = p.chromium.launch(
        headless=False,  # some storage APIs behave differently headless; start headed first
        args=["--disable-extensions-except=" + EXTENSION_PATH,
              "--load-extension=" + EXTENSION_PATH]
    )
    context = browser.contexts[0]  # default context is fine; the extension is already loaded

    # The key part: grab the background service worker
    # If the extension has a service worker, Playwright treats it as a background page
    background = context.background_page
    if background is None:
        # some manifest V2 extensions may use a persistent background script page
        background = context.service_workers[0] if context.service_workers else None
    # Now we can exercise chrome.storage APIs directly on `background`
Enter fullscreen mode Exit fullscreen mode

Piece two: simulate user actions on the popup page to trigger storage writes, then switch back to the background to verify the data was persisted correctly.

# This snippet solves: how to verify the final memory/local-storage state after a popup interaction
def open_popup(context) -> Page:
    """Click the toolbar icon to open the popup page"""
    # A popup is also a Page; you can open it by constructing its URL using the extension id
    # (in practice it’s better to simulate a click; for simplicity we use the URL directly)
    extension_id = context.background_page.url.split("/")[2]  # chrome-extension://xxx
    popup = context.new_page()
    popup.goto(f"chrome-extension://{extension_id}/popup.html")
    popup.wait_for_load_state("domcontentloaded")
    return popup

popup = open_popup(context)
# Simulate user input and save
popup.fill("#command-input", "say hello")
popup.click("#save-btn")

# Wait for the async write to complete (storage APIs are asynchronous)
popup.wait_for_timeout(500)  # in real tests, use more robust waiting

# Switch to background and verify
value = background.evaluate("() => chrome.storage.local.get(['command'])")
assert value["command"] == "say hello"
Enter fullscreen mode Exit fullscreen mode

Piece three: the real game changer—fully automating a chrome.storage.sync conflict scenario, something that was impossible with Selenium. With Playwright, you can open multiple contexts pointing to different Chrome profiles, load the same extension in both, and then force a sync conflict.

# This snippet solves: how to test sync conflicts deterministically
# We simulate two separate “browsers” with different local states
# and verify the conflict resolution logic of chrome.storage.sync

def setup_context(playwright, profile_dir: str):
    """Create a persistent context with its own storage and load the extension."""
    context = playwright.chromium.launch_persistent_context(
        user_data_dir=profile_dir,
        headless=False,
        args=["--disable-extensions-except=" + EXTENSION_PATH,
              "--load-extension=" + EXTENSION_PATH]
    )
    # The extension is loaded; collect the background page
    background = context.background_page or context.service_workers[0]
    return context, background

with sync_playwright() as p:
    # Context A: write "value A"
    ctx_a, bg_a = setup_context(p, "./profile-a")
    bg_a.evaluate("() => chrome.storage.sync.set({key: 'A'})")
    bg_a.wait_for_timeout(500)

    # Context B: write "value B" (different from A, triggers conflict)
    ctx_b, bg_b = setup_context(p, "./profile-b")
    bg_b.evaluate("() => chrome.storage.sync.set({key: 'B'})")
    bg_b.wait_for_timeout(500)

    # Now, depending on your conflict policy, the resolved value in each
    # context can be asserted. For example, if the extension uses "last-write-wins":
    final_value = bg_a.evaluate("() => chrome.storage.sync.get(['key']).key")
    assert final_value in ("A", "B")  # based on your extension logic
    # Clean up
    ctx_a.close()
    ctx_b.close()
Enter fullscreen mode Exit fullscreen mode

This approach turned a manual two-hour regression ritual into a three-minute test suite that runs in CI on every commit. The fact that Playwright treats extension pages as first-class Page objects—and allows you to work directly with chrome.storage APIs—is an absolute superpower for anyone serious about shipping reliable browser extensions.

Source: dev.to

arrow_back Back to Tutorials