- Chrome Extensions
- Manifest V3
- Testing
- Releases
Test a Chrome extension with Playwright: popup, worker, CI
Test a Chrome extension with Playwright: load your MV3 build, test the popup, content scripts and service worker, run it in CI and catch breakage daily.
- Author
- Extenify Team
- Published
- Reading time
- 12 min read

On this page
To test a Chrome extension with Playwright you need three things most web testing guides skip: a persistent browser context, the Chromium build that still accepts the --load-extension flag, and a way to reach code that never runs in a normal page, such as the service worker and the popup. Get those right and Playwright can load your Manifest V3 build, open its popup, drive its content scripts on real pages and read chrome.storage, in CI and headless.
This guide builds that setup from scratch: the fixture that loads the extension and finds its ID, tests for the popup, a content script, the service worker and storage, the timing traps that make extension tests flaky, a GitHub Actions workflow, and a scheduled job that catches a broken content script before your users do. Every test below was run with @playwright/test 1.64.1 and its bundled Chromium 156 on October 19 and 20, 2026, against a small word-counter extension whose code is included.
Key takeaways
- Load the extension with
chromium.launchPersistentContext()and the--disable-extensions-exceptand--load-extensionflags. A regularbrowser.newContext()cannot load extensions.- Use
channel: 'chromium'. Branded Google Chrome dropped--load-extensionin Chrome 137, and the bundled Chromium also runs extensions headless.- Get the extension ID from the service worker URL, then open
chrome-extension://<id>/popup.htmlin a tab to test the popup.serviceWorker.evaluate()runs code inside the worker, so you can seed and readchrome.storagedirectly.- Wait for positive signals. Storage writes and content scripts are asynchronous, and "element count is 0" passes before your script has even run.
Why you cannot test a Chrome extension with Playwright's defaults
Playwright's Chrome extensions guide starts with two constraints. First, "Extensions only work in Chromium when launched with a persistent context." Second, "Google Chrome and Microsoft Edge removed the command-line flags needed to side-load extensions, so use Chromium that comes bundled with Playwright."
The second point is recent. Google announced on the Chromium Extensions group that --load-extension stops working in branded Chrome builds from Chrome 137, and a later notice did the same for --disable-extensions-except from Chrome 139. Both flags "continue to function as before" in Chromium and Chrome for Testing. If your old Selenium or Puppeteer suite broke in mid-2025, that is why. Playwright's bundled Chromium is the simplest fix, and channel: 'chromium' is also what lets extensions run in headless mode.
What you can test this way:
- Content scripts on any page, including pages you serve from the test itself.
- Extension pages: the popup, the options page, a side panel page, by opening their URL in a tab.
- The service worker: run code inside it, read and seed
chrome.storage, check messages and alarms.
What you cannot test this way: clicking the toolbar icon itself, the real popup window (calling chrome.action.openPopup() opened it in our run, but Playwright did not expose it as a page), and the Chrome Web Store install flow. Test the popup as a page instead, and keep store checks for the store.
The extension under test
The examples use a small MV3 extension that counts the words on example.com pages, shows a badge, and keeps a counter and an on/off switch in chrome.storage.local:
{
"manifest_version": 3,
"name": "Word Counter Demo",
"version": "1.0.0",
"permissions": ["storage"],
"background": { "service_worker": "background.js" },
"action": { "default_popup": "popup.html" },
"content_scripts": [
{ "matches": ["https://example.com/*"], "js": ["content.js"] }
]
}
The content script reads the switch, adds a badge, and tells the service worker it counted a page. Note the data-word-counter attribute: it is a small test hook that says "the script has finished", and it pays for itself later.
// content.js
(async () => {
const { enabled } = await chrome.storage.local.get({ enabled: true });
// A test hook: tells tests (and you, in DevTools) that the script has finished.
document.documentElement.dataset.wordCounter = enabled ? 'on' : 'off';
if (!enabled) return;
const words = document.body.innerText.trim().split(/\s+/).filter(Boolean).length;
const badge = document.createElement('div');
badge.id = 'word-count-badge';
badge.textContent = `${words} words`;
document.body.append(badge);
await chrome.runtime.sendMessage({ type: 'page-counted' });
})();
The service worker writes defaults in chrome.runtime.onInstalled and increments the counter on each message:
// background.js
chrome.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') {
await chrome.storage.local.set({ enabled: true, pagesCounted: 0 });
}
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'page-counted') {
chrome.storage.local.get({ pagesCounted: 0 }).then(async ({ pagesCounted }) => {
await chrome.storage.local.set({ pagesCounted: pagesCounted + 1 });
sendResponse({ pagesCounted: pagesCounted + 1 });
});
return true; // keep the channel open for the async response
}
});
The popup shows the counter and a checkbox that writes enabled to storage. Your extension will be bigger, but these are the same moving parts: a content script, a worker, storage and a popup.
Step 1: Install Playwright and the bundled Chromium
npm init playwright@latest # or: npm i -D @playwright/test
npx playwright install chromium
npm i -D @types/chrome # editor types for chrome.* inside evaluate()
A minimal playwright.config.ts is enough, because the fixture below launches the browser itself:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: { trace: 'retain-on-failure' },
});
Step 2: A fixture that loads the extension
Fixtures are how Playwright hands each test a ready browser. This one is the pattern from the official guide with one addition: it exposes the service worker as its own fixture, because most extension tests need it.
// tests/fixtures.ts
import { test as base, chromium, type BrowserContext, type Worker } from '@playwright/test';
import path from 'node:path';
// Point this at your built extension (the folder that holds manifest.json).
const pathToExtension = path.resolve(__dirname, '../my-extension');
export const test = base.extend<{
context: BrowserContext;
serviceWorker: Worker;
extensionId: string;
}>({
context: async ({}, use) => {
const context = await chromium.launchPersistentContext('', {
channel: 'chromium', // bundled Chromium; also enables headless mode for extensions
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
serviceWorker: async ({ context }, use) => {
let [worker] = context.serviceWorkers();
if (!worker) worker = await context.waitForEvent('serviceworker');
await use(worker);
},
extensionId: async ({ serviceWorker }, use) => {
// chrome-extension://<id>/background.js -> <id>
await use(new URL(serviceWorker.url()).host);
},
});
export const expect = test.expect;
Three details matter here:
- The empty string as the user data directory gives every test a fresh temporary profile, so
chrome.storagestarts empty andonInstalledfires withreason: 'install'each time. That is what you want for isolation. It costs about a second per test to launch Chromium; if your suite grows large, move the context to a worker-scoped fixture and reset storage between tests instead. - Point at the build output. If you bundle with Vite, webpack or WXT, run the build first and load
dist/, so you test what you ship. - The ID comes from the worker URL. The page
pagefixture comes from this same context, so every page a test opens has the extension loaded.
Step 3: Test a content script on a page you control
Content scripts run on real sites, and real sites change. For the main suite, serve a fixed page with context.route() so the test does not depend on the network:
// tests/extension.spec.ts
import { test, expect } from './fixtures';
const ARTICLE = `<!doctype html><html><body>
<h1>Release notes</h1>
<p>Version two adds dark mode and fixes the export bug.</p>
</body></html>`;
test.beforeEach(async ({ context }) => {
// Serve a fixed page instead of the live site, so the test is deterministic.
await context.route('https://example.com/**', (route) =>
route.fulfill({ contentType: 'text/html', body: ARTICLE }),
);
});
test('content script adds the word count badge', async ({ page }) => {
await page.goto('https://example.com/article');
await expect(page.locator('#word-count-badge')).toHaveText('12 words');
});
The content script was injected into the routed response exactly as it would be on the real site, because Chrome matches on the URL, not on where the bytes came from. Use a URL that matches your content_scripts.matches pattern.
Step 4: Test the popup
You cannot click the toolbar icon, but the popup is just an HTML page inside the extension, so open it in a tab:
test('popup shows the number of pages counted', async ({ page, context, extensionId }) => {
await page.goto('https://example.com/article');
await expect(page.locator('#word-count-badge')).toBeVisible();
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(popup.locator('#count')).toHaveText('1');
});
One trap: a popup opened in a tab is itself the active tab. If your popup calls chrome.tabs.query({ active: true, currentWindow: true }) to find the page the user is on, it gets its own tab back. After opening the popup, call await page.bringToFront() on the target page; in our test, tabs.query then returned the target tab. Its URL is only visible if the extension has the tabs permission or host access to that site, which is also true in production.
Step 5: Test the service worker and storage
serviceWorker.evaluate() runs a function inside the worker with full access to chrome.*. Use it to check what onInstalled wrote, to seed state before a test, and to read results after:
test('onInstalled writes default settings', async ({ serviceWorker }) => {
await expect
.poll(() => serviceWorker.evaluate(() => chrome.storage.local.get(null)))
.toEqual({ enabled: true, pagesCounted: 0 });
});
test('seeded storage state is respected', async ({ serviceWorker, page }) => {
// Wait for onInstalled to write its defaults, or it may overwrite the seed.
await expect
.poll(() => serviceWorker.evaluate(() => chrome.storage.local.get('enabled')))
.toEqual({ enabled: true });
await serviceWorker.evaluate(() => chrome.storage.local.set({ enabled: false }));
await page.goto('https://example.com/article');
await expect(page.locator('html')).toHaveAttribute('data-word-counter', 'off');
});
To test message handling, send a message from any extension page, just as a content script would:
test('service worker answers messages and counts pages', async ({ serviceWorker, extensionId, context }) => {
await expect
.poll(() => serviceWorker.evaluate(() => chrome.storage.local.get('pagesCounted')))
.toEqual({ pagesCounted: 0 }); // onInstalled has run
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
const reply = await page.evaluate(() => chrome.runtime.sendMessage({ type: 'page-counted' }));
expect(reply).toEqual({ pagesCounted: 1 });
});
The same approach works for limits that mocks never enforce. A test that writes a 9 KB value to chrome.storage.sync and expects a rejection catches a whole class of bugs; our guide to Chrome extension storage limits lists the exact errors, all measured with this setup.
Make extension tests reliable
Most flaky extension tests come from four timing problems:
onInstalledraces your setup. The listener runs right after the extension loads, possibly after your test has started. If a test seeds storage, poll until the defaults exist first, as above, oronInstalledoverwrites the seed. Our post on chrome.runtime.onInstalled explains the event's reasons and ordering.- Negative assertions pass too early.
expect(badge).toHaveCount(0)is true the instant the page loads, before the content script runs. Wait for a positive signal first, such as thedata-word-counter="off"hook, then assert the absence. - UI writes are asynchronous. Unchecking a box in the popup calls
chrome.storage.local.set()in the background. Before you navigate, poll storage until it has the new value. - The worker goes to sleep. Playwright's docs note that MV3 workers are "automatically suspended after ~30 seconds of inactivity", that Playwright keeps "the same Worker object alive" across the restart, and that only an
evaluate()in flight at the moment of suspension throws"Service worker restarted". Long tests are fine; just do not hold state in worker globals and expect it to survive, which is true in production too.
With those four handled, our six routed tests passed every time across three repeated runs (--repeat-each 3, 18 test runs in about 12 seconds).
Run the tests in GitHub Actions
Headless runs work in CI as long as you install the bundled Chromium with its system dependencies:
# .github/workflows/extension-tests.yml
name: Extension tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build # produce the extension folder the fixture loads
- run: npx playwright install --with-deps chromium
- run: npx playwright test --grep-invert @live
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: test-results/
The trace: 'retain-on-failure' setting in the config means a failed CI run uploads a trace you can open with npx playwright show-trace to see each step, screenshot and console message.
Catch a broken content script with a daily live check
The routed tests prove your code works. They cannot tell you that the site your content script targets changed its markup last night. For that, tag a few tests that run against the live site:
// tests/live.spec.ts
import { test, expect } from './fixtures';
test('badge still appears on the live page', { tag: '@live' }, async ({ page }) => {
await page.goto('https://example.com/');
await expect(page.locator('html')).toHaveAttribute('data-word-counter', 'on');
await expect(page.locator('#word-count-badge')).toHaveText(/\d+ words/);
});
Then run only those on a schedule:
# .github/workflows/live-check.yml
name: Live content script check
on:
schedule:
- cron: '17 6 * * *' # daily at 06:17 UTC
workflow_dispatch:
jobs:
live:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
- run: npx playwright install --with-deps chromium
- run: npx playwright test --grep @live
GitHub's workflow trigger reference has three caveats: scheduled runs "can be delayed during periods of high loads" (the start of every hour is busiest, hence the odd minute), they "only run on the default branch", and in a public repository they are "automatically disabled when no repository activity has occurred in 60 days." Turn on failure notifications for the workflow so a red run reaches a person.
When the tests pass but users still complain
Playwright covers your code against pages you can predict. It does not see the Chrome version a user runs, a conflict with another extension, or a change you did not test for. Those show up as a drop in weekly users, a dip in the rating or a run of one-star reviews in the days after a release. Extenify is a chrome extension analytics dashboard that records your extension's users, rating, reviews and version on the Chrome Web Store, Edge Add-ons and Firefox Add-ons every day, works as an extension version tracker that notices when each update goes live, and sends extension review monitoring alerts to email, Slack, Discord, Telegram or Teams. Pair your test suite with our checklist of what to watch after an extension release and the guide to diagnosing a drop in weekly users. Plans start with a 7-day free trial; see the pricing page.
Frequently asked questions
Can Playwright test Chrome extensions?
Yes, in Chromium. Launch a persistent context with chromium.launchPersistentContext(), pass --disable-extensions-except and --load-extension with the path to your unpacked build, and use channel: 'chromium'. Firefox and WebKit extensions are not supported this way.
How do I get the extension ID in Playwright?
Wait for the service worker with context.serviceWorkers() or context.waitForEvent('serviceworker'), then read the host from its URL: new URL(worker.url()).host. For a fixed ID across machines, add a key to your development manifest.
Can Playwright run Chrome extensions in headless mode?
Yes. Playwright's docs say the chromium channel "allows to run extensions in headless mode", because it uses Chrome's new headless mode, which Google describes as suitable for "browser extension testing". That is what makes CI runs practical.
Why does my extension not load in Google Chrome with Playwright?
Branded Google Chrome ignores --load-extension from Chrome 137 and --disable-extensions-except from Chrome 139. Use the Chromium that ships with Playwright, or Chrome for Testing, where both flags still work.
How do I test a Chrome extension popup with Playwright?
Open chrome-extension://<extension-id>/popup.html in a new page and test it like any other page. The toolbar popup window itself is not available to Playwright. If the popup looks up the active tab, bring the target page to the front first.
Can I test a Manifest V2 extension?
No. Playwright 1.55 dropped support for Chromium extension Manifest V2, and according to Google's MV2 support timeline, Chrome 138 was the last version that could run MV2 extensions at all. Migrate to MV3 first.
Summary
To test a Chrome extension with Playwright, launch the bundled Chromium in a persistent context with the two extension flags, read the extension ID from the service worker, and treat the popup and options pages as ordinary pages at chrome-extension://<id>/. serviceWorker.evaluate() gives tests direct access to chrome.storage and messaging. Most flakiness comes from timing, so wait for onInstalled, for storage writes and for a positive signal from your content script before asserting. Run the routed suite on every push, a small live suite once a day, and watch the store data after each release for the problems no test predicted.
Image credits
- Cover photo: Newari dancing puppets hanging from strings by Alina Kakshapati on the WordPress Photo Directory, CC0.


