# 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