15 KiB
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, core/fixtures-and-hooks.md
Quick Reference
// 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
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<ExtensionFixtures>({
// 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
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
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
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
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
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
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
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
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
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://<id>/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://<id>/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://<id>/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 -- building custom fixtures for extension contexts
- core/service-workers-and-pwa.md -- service worker testing patterns (non-extension)
- core/iframes-and-shadow-dom.md -- content scripts often inject Shadow DOM elements
- core/configuration.md -- project configuration for Chromium-only test suites
- ci/ci-github-actions.md -- CI setup for headed/extension tests