Originally published on Extenify.
If you tried to test a Chrome extension with Playwright recently and nothing loaded, the browser is probably the culprit, not your code. Since Chrome 137, branded Google Chrome ignores the --load-extension flag (and --disable-extensions-except stopped working in Chrome 139). Below is the short version of a setup that works: which browser to launch, how to reach the popup and the service worker, and the four timing traps behind most flaky extension tests.
Why your old extension tests stopped loading
Playwright can only load extensions under two conditions:
- You launch a persistent context (
chromium.launchPersistentContext()). A normalbrowser.newContext()will never load an extension. - You run a Chromium build that still honours the side-loading flags. Google removed them from branded Chrome, but they keep working in plain Chromium and in Chrome for Testing.
So if a Selenium or Puppeteer suite quietly broke in mid-2025, this is the likely reason. The simplest fix is to set channel: 'chromium', which uses the Chromium bundled with Playwright. As a bonus, that channel is also what lets extensions run headless, so CI works without a virtual display.
Here is what that buys you, and what it doesn't:
| You can test | You can't test |
|---|---|
| Content scripts on any page, including mocked ones | Clicking the toolbar icon |
| Popup, options and side panel pages (opened as tabs) | The real popup window |
The service worker, chrome.storage, messages, alarms |
The Chrome Web Store install flow |
A minimal fixture that loads your build
The trick is to turn the browser context, the service worker and the extension ID into Playwright fixtures, so every test receives them ready to use. The ID is simply the host part of the worker's URL.
// tests/fixtures.ts
import { test as base, chromium, type BrowserContext, type Worker } from '@playwright/test';
import path from 'node:path';
const extPath = path.resolve(__dirname, '../dist'); // folder containing manifest.json
export const test = base.extend<{ context: BrowserContext; sw: Worker; extId: string }>({
context: async ({}, use) => {
const ctx = await chromium.launchPersistentContext('', {
channel: 'chromium',
args: [`--disable-extensions-except=${extPath}`, `--load-extension=${extPath}`],
});
await use(ctx);
await ctx.close();
},
sw: async ({ context }, use) => {
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker'));
await use(worker);
},
extId: async ({ sw }, use) => use(new URL(sw.url()).host),
});
export const expect = test.expect;
A few notes worth keeping in mind:
- Passing an empty string as the profile directory gives each test a fresh profile, so storage starts empty and
onInstalledfires withreason: 'install'. It costs roughly a second per test; for big suites, share a worker-scoped context and reset storage instead. - Load your build output (
dist/from Vite, webpack or WXT), so you test what you ship.
Testing the three moving parts
Content scripts. Don't depend on the live site in your main suite. Use context.route() to serve fixed HTML for a URL that matches your content_scripts.matches pattern. Chrome injects scripts based on the URL, so the mocked response gets the content script just like the real one.
The popup. You can't click the toolbar button, but the popup is just a page. Open chrome-extension://${extId}/popup.html in a new tab and assert on it normally. One gotcha: that tab is now the active tab, so a popup that calls chrome.tabs.query({ active: true, currentWindow: true }) finds itself. Call bringToFront() on the target page first.
The service worker and storage. sw.evaluate() runs a function inside the worker with full chrome.* access, which is perfect for seeding and reading state:
test('defaults are written on install', async ({ sw }) => {
await expect
.poll(() => sw.evaluate(() => chrome.storage.local.get(null)))
.toEqual({ enabled: true, pagesCounted: 0 });
});
This also lets you test real limits that mocks never enforce, such as quota errors from chrome.storage.sync.
Four timing traps behind flaky extension tests
-
onInstalledraces your setup. If you seed storage before the install handler has written its defaults, the handler overwrites your seed. Poll until the defaults exist, then seed. -
Negative assertions pass instantly. "Badge count is 0" is true before your content script has even run. Have the script set a marker (for example a
data-*attribute on<html>) when it finishes, wait for that, then assert absence. -
Popup writes are async. Toggling a checkbox calls
chrome.storage.local.set()in the background. Poll storage for the new value before navigating away. -
The worker sleeps. MV3 workers suspend after about 30 seconds idle. Playwright keeps the same
Workerobject alive across restarts, and only anevaluate()running at the exact moment of suspension throws. Just don't rely on worker globals surviving, which is true in production too.
With these handled, the original author's six-test suite passed all 18 runs of --repeat-each 3 in about 12 seconds.
CI and a daily live check
In GitHub Actions, the key line is npx playwright install --with-deps chromium after your build step. Exclude live tests from the push workflow with --grep-invert @live, and set trace: 'retain-on-failure' so failures come with a trace you can open locally.
Then tag a couple of tests @live that hit the real target site, and run only those on a daily schedule cron. Mocked tests prove your code is correct; the live check tells you the site changed its markup overnight. Pick an odd minute (scheduled runs are busiest on the hour), and remember GitHub disables schedules in public repos after 60 days without activity.
Key takeaways
- Use
launchPersistentContext()withchannel: 'chromium'; branded Chrome no longer side-loads extensions. - Read the extension ID from the service worker URL and open extension pages as normal tabs.
-
serviceWorker.evaluate()is your direct line intochrome.storageand messaging. - Wait for positive signals before asserting; most flakiness is timing, not logic.
- Run mocked tests on every push and a tiny live suite once a day.
Tests can't see every user's setup, so the full guide on testing a Chrome extension with Playwright, including CI workflows and an FAQ also covers what to watch after release, and Extenify's Chrome extension analytics and review alerts can flag a drop in users or ratings that slipped past your tests.
What has been the flakiest part of your extension test suite so far: the service worker, content scripts, or the popup?
Top comments (0)