# Browser Extensions > **When to use**: Testing Chrome extensions — popups, content scripts, background service workers, or extension-injected UI. Requires Chromium and a persistent browser context. > **Prerequisites**: [core/configuration.md](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## Quick Reference ```typescript // Load an unpacked extension with a persistent context (Chromium only) const context = await chromium.launchPersistentContext(userDataDir, { headless: false, // Extensions require headed mode args: [ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], }); ``` **Hard constraints**: Extensions only work in Chromium. They require `launchPersistentContext` (not `browser.newContext`). Headed mode is mandatory — `headless: false` or use the `--headless=new` Chromium flag for "new headless" mode which supports extensions. ## Patterns ### Loading an Extension **Use when**: You need to test any Chrome extension functionality. **Avoid when**: You only need to test the web app that an extension interacts with — mock the extension's effects instead. **TypeScript** ```typescript import { test as base, expect, chromium, type BrowserContext } from '@playwright/test'; import path from 'path'; // Create a fixture that provides a context with the extension loaded type ExtensionFixtures = { context: BrowserContext; extensionId: string; }; export const test = base.extend({ // Override the default context to load the extension context: async ({}, use) => { const extensionPath = path.resolve(__dirname, '../my-extension'); const context = await chromium.launchPersistentContext('', { headless: false, args: [ `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, ], }); await use(context); await context.close(); }, // Extract the extension ID from the service worker URL extensionId: async ({ context }, use) => { let [background] = context.serviceWorkers(); if (!background) { background = await context.waitForEvent('serviceworker'); } const extensionId = background.url().split('/')[2]; await use(extensionId); }, }); export { expect }; ``` **JavaScript** ```javascript const { test: base, expect, chromium } = require('@playwright/test'); const path = require('path'); const test = base.extend({ context: async ({}, use) => { const extensionPath = path.resolve(__dirname, '../my-extension'); const context = await chromium.launchPersistentContext('', { headless: false, args: [ `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, ], }); await use(context); await context.close(); }, extensionId: async ({ context }, use) => { let [background] = context.serviceWorkers(); if (!background) { background = await context.waitForEvent('serviceworker'); } const extensionId = background.url().split('/')[2]; await use(extensionId); }, }); module.exports = { test, expect }; ``` ### Testing Extension Popups **Use when**: Your extension has a browser action popup (the UI that appears when clicking the extension icon). **Avoid when**: The popup is trivial — test the content script or background logic instead. **TypeScript** ```typescript import { test, expect } from './extension-fixture'; test('extension popup displays saved bookmarks', async ({ page, extensionId }) => { // Navigate directly to the popup HTML await page.goto(`chrome-extension://${extensionId}/popup.html`); // Interact with popup UI using standard locators await expect(page.getByRole('heading', { name: 'My Bookmarks' })).toBeVisible(); await page.getByRole('button', { name: 'Add current page' }).click(); await expect(page.getByRole('listitem')).toHaveCount(1); }); test('extension popup settings toggle works', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/popup.html`); await page.getByRole('checkbox', { name: 'Enable notifications' }).check(); await expect(page.getByText('Notifications enabled')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('./extension-fixture'); test('extension popup displays saved bookmarks', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/popup.html`); await expect(page.getByRole('heading', { name: 'My Bookmarks' })).toBeVisible(); await page.getByRole('button', { name: 'Add current page' }).click(); await expect(page.getByRole('listitem')).toHaveCount(1); }); ``` ### Testing Content Scripts **Use when**: Your extension injects scripts or UI into web pages. **Avoid when**: The content script only modifies data without visible effects — test via the background worker or storage. **TypeScript** ```typescript import { test, expect } from './extension-fixture'; test('content script injects price comparison widget', async ({ context }) => { const page = await context.newPage(); await page.goto('https://example-shop.com/product/123'); // Wait for the content script to inject its UI // The extension adds a shadow DOM element — Playwright pierces it automatically await expect(page.getByTestId('price-compare-widget')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Best price: $29.99')).toBeVisible(); }); test('content script highlights search terms', async ({ context }) => { const page = await context.newPage(); await page.goto('https://example.com/article'); // Verify the content script added highlight spans const highlights = page.locator('.ext-highlight'); await expect(highlights).toHaveCount(5); await expect(highlights.first()).toHaveCSS('background-color', 'rgb(255, 255, 0)'); }); ``` **JavaScript** ```javascript const { test, expect } = require('./extension-fixture'); test('content script injects price comparison widget', async ({ context }) => { const page = await context.newPage(); await page.goto('https://example-shop.com/product/123'); await expect(page.getByTestId('price-compare-widget')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Best price: $29.99')).toBeVisible(); }); ``` ### Testing Background Service Workers **Use when**: Your extension uses Manifest V3 service workers for background processing, alarms, or message passing. **Avoid when**: The background logic is simple and already covered by popup or content script tests. **TypeScript** ```typescript import { test, expect } from './extension-fixture'; test('background worker processes messages correctly', async ({ context, extensionId }) => { const page = await context.newPage(); await page.goto(`chrome-extension://${extensionId}/popup.html`); // Trigger an action that sends a message to the background worker await page.getByRole('button', { name: 'Sync data' }).click(); // Verify the response from the background worker updates the popup await expect(page.getByText('Last synced: just now')).toBeVisible(); }); test('service worker handles extension storage', async ({ context, extensionId }) => { const page = await context.newPage(); await page.goto(`chrome-extension://${extensionId}/popup.html`); // Set a value through the popup await page.getByLabel('API Key').fill('test-key-123'); await page.getByRole('button', { name: 'Save' }).click(); // Reload popup and verify persistence through the service worker await page.reload(); await expect(page.getByLabel('API Key')).toHaveValue('test-key-123'); }); ``` **JavaScript** ```javascript const { test, expect } = require('./extension-fixture'); test('background worker processes messages correctly', async ({ context, extensionId }) => { const page = await context.newPage(); await page.goto(`chrome-extension://${extensionId}/popup.html`); await page.getByRole('button', { name: 'Sync data' }).click(); await expect(page.getByText('Last synced: just now')).toBeVisible(); }); ``` ### Testing Extension Options Page **Use when**: Your extension has a dedicated options/settings page. **Avoid when**: Settings are fully covered by popup tests. **TypeScript** ```typescript import { test, expect } from './extension-fixture'; test('options page saves preferences', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/options.html`); await page.getByRole('combobox', { name: 'Theme' }).selectOption('dark'); await page.getByRole('checkbox', { name: 'Auto-update' }).check(); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Settings saved')).toBeVisible(); // Verify persistence after reload await page.reload(); await expect(page.getByRole('combobox', { name: 'Theme' })).toHaveValue('dark'); await expect(page.getByRole('checkbox', { name: 'Auto-update' })).toBeChecked(); }); ``` **JavaScript** ```javascript const { test, expect } = require('./extension-fixture'); test('options page saves preferences', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/options.html`); await page.getByRole('combobox', { name: 'Theme' }).selectOption('dark'); await page.getByRole('checkbox', { name: 'Auto-update' }).check(); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Settings saved')).toBeVisible(); await page.reload(); await expect(page.getByRole('combobox', { name: 'Theme' })).toHaveValue('dark'); await expect(page.getByRole('checkbox', { name: 'Auto-update' })).toBeChecked(); }); ``` ## Decision Guide | Scenario | Approach | Why | |---|---|---| | Test popup UI | Navigate to `chrome-extension:///popup.html` | Direct access without needing to click the extension icon | | Test content script effects | Load a real or test page, assert injected elements | Content scripts run automatically on matching URLs | | Test background logic | Trigger via popup/content script, verify side effects | Cannot directly call service worker functions from Playwright | | Test extension storage | Use popup to set values, reload, verify persistence | `chrome.storage` is only accessible from extension pages | | Test options page | Navigate to `chrome-extension:///options.html` | Same approach as popup testing | | Test cross-page behavior | Open multiple pages in the same context | Persistent context shares extension state across tabs | | Run in CI (headless) | Use `--headless=new` Chromium flag | New headless mode supports extensions unlike old headless | | Test with multiple extensions | Add multiple paths to `--load-extension` | Comma-separate paths in the flag value | ## Anti-Patterns | Don't Do This | Problem | Do This Instead | |---|---|---| | `browser.newContext()` for extensions | Extensions require persistent context | `chromium.launchPersistentContext()` | | `headless: true` without `--headless=new` | Old headless mode does not support extensions | Set `headless: false` or use `args: ['--headless=new']` | | Testing on Firefox or WebKit | Extensions only work in Chromium | Skip extension tests for non-Chromium projects | | Clicking the extension icon via coordinates | Fragile, toolbar layout varies | Navigate directly to `chrome-extension:///popup.html` | | Hardcoding the extension ID | IDs change between builds and machines | Extract dynamically from the service worker URL | | Testing packed `.crx` files directly | Harder to debug, need to unpack first | Test the unpacked extension source directory | | Sharing persistent context user data dir | State leaks between test runs | Use an empty string `''` for a temp directory | | No timeout on content script assertions | Content scripts may load after page load | Use `{ timeout: 10000 }` on content script element assertions | ## Troubleshooting | Symptom | Likely Cause | Fix | |---|---|---| | Extension does not load | Wrong path in `--load-extension` | Use `path.resolve()` to get the absolute path to the extension directory | | `context.serviceWorkers()` returns empty | Service worker not yet registered | Use `context.waitForEvent('serviceworker')` before extracting the ID | | Popup page is blank | Popup HTML path is wrong | Check `manifest.json` for the correct `default_popup` path | | Content script not injecting | Page URL does not match `matches` in manifest | Verify the URL pattern in `content_scripts[].matches` | | Extension works locally but not in CI | CI uses old headless mode | Add `--headless=new` to launch args for CI | | `chrome.storage` calls fail | Accessing storage from non-extension context | Only access storage through extension pages (popup, options, background) | | Multiple extensions conflict | Both extensions modify the same page elements | Test each extension in its own persistent context | | Tests are slow to start | Persistent context initialization overhead | Reuse context across tests in the same file with `test.describe` | ## Related - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- building custom fixtures for extension contexts - [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- service worker testing patterns (non-extension) - [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) -- content scripts often inject Shadow DOM elements - [core/configuration.md](configuration.md) -- project configuration for Chromium-only test suites - [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup for headed/extension tests