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.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

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.

Page sources for content script testsHand-written fixtures, saved snapshots of real sites, and live sites compared on determinism, realism, speed and when to run them.SourceDeterministicRealisticRunFixture pagesYesTargetedEvery commitSaved snapshotsYesHigh (frozen)Every commitLive sitesNoHighestNightly smoke
Fixtures every commit, snapshots every commit, live sites nightly.

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.

A content script test runPlaywright launches Chromium with the built extension, opens a fixture page from the local server, waits for the content script's ready marker, interacts with the page, and asserts on the injected UI and page changes, capturing a screenshot on failure.Launch Chromium--load-extensionOpen fixturelocalhost:4173/shop.htmlWait for readydata attributethenInteractscroll, click, navigateAssertshadow DOM, page DOMOn failuretrace + screenshot
Real browser, real extension, local pages, user-visible assertions.

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.

Testing behaviour across an SPA re-renderThe test opens a fixture SPA; the content script highlights 12 prices; the test clicks Next page, which replaces the list via history.pushState without a reload; the content script's MutationObserver highlights the new 12 prices; the test asserts the new count and no duplicates.TestFixture SPAContent scriptgoto /spa.htmlhighlight 12click Next (pushState)DOM replacedobserver → highlight 12 newexpect count 12, no dupes
SPAs change the page without reloading — test that the script keeps up.

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 chromium channel.
  • Firefox: use web-ext run with 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

  1. Break the price selector and confirm the fixture test fails with a clear message.
  2. Add * { display: none !important } to a fixture and confirm toBeVisible fails, then fix your CSS isolation.
  3. Run the SPA test and confirm no duplicate highlights after navigation.
  4. 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.

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.

Other Testing, Debugging & Performance Optimization Resources