Testing Content Scripts Against Real Pages
Test MV3 content scripts end to end on realistic pages: local fixture pages, recorded snapshots of real sites, Playwright with the extension loaded, asserting injected DOM, waiting for injection, testing SPA navigation, and handling hostile page CSS and CSP.
Table of Contents
- Choosing the pages to test against
- Step-by-step: content script tests in a real browser
- 1. Serve fixtures on hostnames your matches cover
- 2. Load the extension and open a page
- 3. Wait for the content script deterministically
- 4. Assert inside the extension’s shadow DOM
- 5. Write fixtures for hostile conditions
- 6. Snapshot real sites for realistic markup
- 7. Run a nightly smoke test against live sites
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
The content script that highlights prices passes every unit test against a hand-written DOM, then breaks on the first real shop page: prices are split across three spans, the page re-renders the product list after load, a global CSS reset hides the extension’s badge, and a strict Content Security Policy blocks an inline style. Content scripts live in other people’s pages, so their real test environment is those pages — with their markup, their scripts, their styles and their policies. Testing against live websites is flaky and slow, but testing only against synthetic DOM misses the bugs that matter. This guide sets up a middle path: realistic local pages, the real extension loaded in a real browser, and assertions on what users see. It belongs to end-to-end testing and automation.
Choosing the pages to test against
Three kinds of pages, used together, cover content scripts well. Fixture pages you write yourself target specific behaviours — a list that re-renders, a page with a strict CSP, an iframe, a shadow root — and are fast and deterministic. Snapshots of real sites, saved as static HTML with their CSS, reproduce real-world markup without the network or live changes; refresh them occasionally. A short smoke list of live sites, run on a schedule rather than every commit, catches changes on the sites your users care about. All are served from a local server so the extension’s matches patterns apply and the pages have real origins.
Step-by-step: content script tests in a real browser
1. Serve fixtures on hostnames your matches cover
1// playwright.config.ts (excerpt)
2export default defineConfig({
3 webServer: { command: "npx http-server tests/pages -p 4173 -s", port: 4173, reuseExistingServer: true },
4 use: { baseURL: "http://localhost:4173" },
5});
1// manifest (test build) — content script matches include the fixture host
2"content_scripts": [{ "matches": ["https://*/*", "http://localhost/*"], "js": ["content.js"], "run_at": "document_idle" }]
Execution context: the test configuration and a test-only manifest. Fixtures need an origin the content script matches. Adding http://localhost/* only in the test build keeps the production manifest unchanged — generate both from one template, as in snapshot testing the generated manifest. To test production patterns against “real” hostnames, map them to localhost with Chromium’s --host-resolver-rules="MAP shop.example 127.0.0.1" flag.
2. Load the extension and open a page
1// tests/fixtures.ts
2import { test as base, chromium, type BrowserContext } from "@playwright/test";
3export const test = base.extend<{ context: BrowserContext }>({
4 context: async ({}, use) => {
5 const context = await chromium.launchPersistentContext("", {
6 channel: "chromium",
7 args: [`--disable-extensions-except=${process.env.EXT_DIR}`, `--load-extension=${process.env.EXT_DIR}`],
8 });
9 await use(context);
10 await context.close();
11 },
12});
Execution context: Playwright test setup. Extensions load only in a persistent context with Chromium; the chromium channel supports headless mode with extensions. See loading an unpacked extension in Playwright.
3. Wait for the content script deterministically
1// content.js — signal readiness for tests (cheap enough to keep in production)
2document.documentElement.dataset.readableReady = "1";
1test("highlights prices on a product list", async ({ context }) => {
2 const page = await context.newPage();
3 await page.goto("/shop.html");
4 await expect(page.locator("html[data-readable-ready='1']")).toBeAttached();
5 await expect(page.locator("[data-readable-price]")).toHaveCount(12);
6});
Execution context: a content script and a Playwright test. Content scripts run at document_idle — after load, at a time the test cannot predict. A ready marker that the script sets after initialising removes sleeps and timing flakiness. Playwright’s auto-waiting expect then polls until the condition is met. See stabilising flaky extension tests.
4. Assert inside the extension’s shadow DOM
1const badge = page.locator("readable-host").locator("css=.badge"); // Playwright pierces open shadow roots
2await expect(badge).toHaveText("12 prices");
3await expect(badge).toBeVisible();
Execution context: a Playwright test. Playwright’s CSS locators pierce open shadow roots automatically. If your injected UI uses a closed shadow root (recommended in production), expose an open root or a test hook only in the test build. toBeVisible catches the real-world failure where page CSS hides your UI.
5. Write fixtures for hostile conditions
1<!-- tests/pages/hostile.html -->
2<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self'">
3<style>
4 * { all: unset !important; } /* aggressive reset */
5 div { z-index: 2147483647 !important; } /* page overlay competing for the top */
6</style>
7<div id="modal-overlay" style="position:fixed;inset:0"></div>
8<iframe src="/frame.html"></iframe>
9<script>customElements.define("readable-host", class extends HTMLElement {});</script> <!-- name collision -->
Execution context: a fixture page. One page that combines the things real sites do — strict CSP, global resets, full-screen overlays, iframes, custom element name collisions — exercises the defensive code in your content script. Your UI should still be visible and functional. See avoiding CSS conflicts between extension and page.
6. Snapshot real sites for realistic markup
1# Save a real page with its assets for offline testing (review licensing first)
2npx single-file-cli https://shop.example/category/headphones tests/pages/snapshots/shop-example.html
Execution context: a developer machine, occasionally. Single-file snapshots inline CSS and images so the page renders offline exactly as captured. Strip scripts that call live APIs, and keep snapshots small. Use them for the sites that matter most to your users, and refresh them when a site redesigns — a failing snapshot test after a refresh is an early warning.
7. Run a nightly smoke test against live sites
Keep a list of five to ten important live URLs and a minimal assertion for each (“at least one price highlighted, no console errors from the extension”). Run it on a schedule, not on every pull request, and treat failures as signals to investigate rather than build breakers.
Common mistakes
- Only synthetic DOM. Misses real-world markup, CSS and CSP.
- Sleeping for the content script. Use a ready marker.
- Testing on live sites every commit. Flaky and slow.
- Closed shadow roots with no test hook. Tests can’t see your UI.
- Not testing SPA navigation. Most modern sites change pages without reloading.
Cross-browser variation
- Chrome / Edge: Playwright loads extensions in Chromium persistent contexts, headless with the
chromiumchannel. - Firefox: use
web-ext runwith Selenium/geckodriver or Playwright’s Firefox with a temporary add-on install via the remote protocol; see testing extensions in Firefox with web-ext. - Safari: automation of Safari extensions is limited; rely on Chromium and Firefox automation plus manual Safari checks.
Verification
- Break the price selector and confirm the fixture test fails with a clear message.
- Add
* { display: none !important }to a fixture and confirmtoBeVisiblefails, then fix your CSS isolation. - Run the SPA test and confirm no duplicate highlights after navigation.
- Confirm the nightly live smoke test reports per-site results.
FAQ
Can content script tests run headless?
Yes, with Playwright’s Chromium channel. Older headless mode did not support extensions.
How do I test content scripts in iframes?
Use page.frameLocator('iframe') and include all_frames: true in the test manifest if production uses it.
Should I test the isolated world’s JS state?
Prefer asserting on DOM and visible behaviour. For internal state, expose a test-only message the content script answers.
Is saving snapshots of real sites legal?
Keep them private to your test suite, review the site’s terms, and prefer your own fixture pages modelled on a site’s structure when in doubt.
How many fixture pages do I need?
Start with one per distinct layout your content script handles plus one hostile page. Add a fixture each time a bug report reveals a page structure you did not cover.
Related
- Loading an unpacked extension in Playwright — setup.
- Mocking network responses in extension tests — controlling page data.
- Testing content scripts with JSDOM — fast unit-level tests.
- End-to-end testing and automation — the parent topic.