Testing Content Scripts with JSDOM

Unit-test MV3 content script logic quickly with JSDOM: structuring scripts so DOM logic is importable, fixture HTML, faking chrome.runtime messaging, MutationObserver and timers, shadow DOM checks, and what JSDOM cannot tell you.

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

The content script finds prices, wraps them in highlight elements, injects a badge into a shadow root, reacts to DOM changes and talks to the service worker. End-to-end tests with a real browser cover it, but each takes seconds and needs a built extension. Most of the logic — which nodes match, what gets wrapped, how mutations are handled, what messages are sent — is plain DOM code that runs in milliseconds under JSDOM, a JavaScript implementation of the DOM used by Jest and Vitest. Fast unit tests let you cover dozens of page shapes on every save. This guide structures content scripts to be testable and writes those tests, and it is explicit about what JSDOM cannot verify. It belongs to unit and integration testing.

What JSDOM is good for

JSDOM implements the DOM tree, selectors, events, MutationObserver, shadow DOM, custom events and timers well enough for logic tests. It does not lay out or render: getBoundingClientRect returns zeros, CSS is not applied, IntersectionObserver and scheduler.yield are absent, and there is no isolated world or CSP. So test what the script does to the DOM and what it sends, under JSDOM; test how it looks and how it behaves on real pages in a browser. Most content-script bugs — wrong selectors, double processing, missed mutations, malformed messages — are in the first category.

What to test whereContent script concerns divided between JSDOM unit tests and real-browser end-to-end tests: selectors and DOM changes, mutation handling, messaging, shadow DOM structure, layout and visibility, CSS isolation, CSP and performance.ConcernJSDOMReal browserSelectors, DOM changesYes — fastYesMutationObserver logicYesYesMessages sentYes (fake chrome)YesLayout, visibilityNoYesCSS isolation, CSPNoYesPerformanceNoYes
JSDOM for logic; a real browser for rendering and the page environment.

Step-by-step: JSDOM tests for a content script

1. Separate logic from bootstrapping

 1// src/content/prices.js — pure, importable logic
 2export function findPrices(root) {
 3  return [...root.querySelectorAll("[data-price], .price, [itemprop='price']")].filter((el) => !el.closest("[data-readable-ignore]"));
 4}
 5export function annotate(el) {
 6  if (el.dataset.readablePrice) return false;          // idempotent
 7  el.dataset.readablePrice = "1";
 8  const mark = document.createElement("mark");
 9  mark.className = "readable-price";
10  mark.append(...el.childNodes);
11  el.append(mark);
12  return true;
13}
14
15// src/content/main.js — bootstrapping only
16import { start } from "./controller.js";
17start({ root: document, runtime: chrome.runtime, settings: await loadSettings() });

Execution context: content script modules. Pure functions take a root node; the controller receives its dependencies (runtime, settings) instead of reaching for globals. The entry file is the only part that touches the real environment, and it is trivial. Idempotent annotate protects against double processing — a property tests can assert.

2. Configure the test environment

1// vitest.config.ts
2export default defineConfig({
3  test: { environment: "jsdom", setupFiles: ["tests/setup-chrome.js"] },
4});
 1// tests/setup-chrome.js — a minimal fake for the APIs content scripts use
 2import { vi } from "vitest";
 3globalThis.chrome = {
 4  runtime: {
 5    id: "test-extension-id",
 6    getURL: (p) => `chrome-extension://test-extension-id/${p}`,
 7    sendMessage: vi.fn().mockResolvedValue(undefined),
 8    onMessage: { addListener: vi.fn(), removeListener: vi.fn() },
 9  },
10  storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() }, onChanged: { addListener: vi.fn() } },
11  i18n: { getMessage: (k) => k },
12};

Execution context: the test configuration. Content scripts only have a subset of extension APIs, so the fake can be small. Reset mocks between tests (vi.clearAllMocks() in beforeEach). For broader fakes see using Vitest with WebExtension mocks.

A JSDOM test runEach test loads fixture HTML into document.body, calls the content script's start function with a fake runtime, triggers mutations or messages, flushes microtasks and timers, and asserts on the DOM and on the messages sent.Fixture HTMLdocument.body.innerHTMLstart({root, runtime})fake chromeTriggermutations, messagesflush + assertawait tick()microtasks, observersDOM assertionsmarks, attributesMessage assertionssendMessage calls
Fixture in, behaviour out — in milliseconds.

3. Test DOM logic against fixtures

 1// tests/prices.test.js
 2import { findPrices, annotate } from "../src/content/prices.js";
 3import { readFileSync } from "node:fs";
 4
 5beforeEach(() => { document.body.innerHTML = readFileSync("tests/fixtures/product-list.html", "utf8"); });
 6
 7test("finds prices but skips ignored regions", () => {
 8  expect(findPrices(document).map((e) => e.textContent.trim())).toEqual(["€19.99", "€24.50", "€9.00"]);
 9});
10
11test("annotate is idempotent", () => {
12  const [el] = findPrices(document);
13  expect(annotate(el)).toBe(true);
14  expect(annotate(el)).toBe(false);
15  expect(el.querySelectorAll("mark.readable-price")).toHaveLength(1);
16});

Execution context: Vitest with JSDOM. Store fixtures as HTML files modelled on real page structures (stripped of scripts). Each bug report about a site becomes a new fixture and a test. See testing content scripts against real pages for the browser-level counterpart.

4. Test mutation handling

 1import { start } from "../src/content/controller.js";
 2const tick = () => new Promise((r) => setTimeout(r, 0));
 3
 4test("annotates prices added after load, once", async () => {
 5  document.body.innerHTML = `<ul id="list"></ul>`;
 6  const stop = start({ root: document, runtime: chrome.runtime, settings: { enabled: true } });
 7  const li = document.createElement("li");
 8  li.innerHTML = `<span class="price">€5.00</span>`;
 9  document.querySelector("#list").append(li);
10  await tick();                                   // MutationObserver callbacks run as microtasks
11  await vi.runAllTimersAsync?.();                 // if the controller defers work with timers
12  expect(document.querySelectorAll("[data-readable-price]")).toHaveLength(1);
13  stop();
14});

Execution context: Vitest with JSDOM. JSDOM’s MutationObserver delivers records asynchronously like browsers, so await a tick. If the controller schedules work with requestIdleCallback (missing in JSDOM), polyfill it in setup with setTimeout, or inject a scheduler dependency so tests can run it synchronously. Always call the returned stop() so observers do not leak between tests.

Asserting a message to the workerThe test loads a fixture, starts the controller with a fake runtime, the controller finds three prices and sends a count message; the test asserts sendMessage was called once with the expected shape.TestControllerFake runtimestart(fixture, fakeRuntime)find + annotate 3sendMessage({type:'count', n:3})expect called once with n:3
Fake the runtime, then assert on what was sent.

5. Test messaging both ways

 1test("reports count and responds to toggle", async () => {
 2  document.body.innerHTML = readFileSync("tests/fixtures/product-list.html", "utf8");
 3  start({ root: document, runtime: chrome.runtime, settings: { enabled: true } });
 4  await tick();
 5  expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ type: "count", n: 3 });
 6
 7  const listener = chrome.runtime.onMessage.addListener.mock.calls[0][0];
 8  listener({ type: "toggle", enabled: false }, { id: chrome.runtime.id }, () => {});
 9  expect(document.querySelectorAll("[data-readable-price]")).toHaveLength(0);
10});

Execution context: Vitest. Capture the registered onMessage listener from the mock and call it directly to simulate messages from the worker, including the sender argument — tests are a good place to check that the script ignores messages from unexpected senders. See testing message handlers in isolation.

6. Check shadow DOM structure

1test("badge renders inside a shadow root", () => {
2  const host = mountBadge(document.body, { count: 3 }, { mode: "open" });     // tests use open mode
3  expect(host.shadowRoot.querySelector(".badge").textContent).toBe("3");
4});

Execution context: Vitest. JSDOM supports attachShadow. Let the mount function take the shadow mode as an option so tests use open while production uses closed. Style and visibility checks belong in browser tests.

7. Keep fixtures realistic and small

Copy the minimal DOM structure that triggers each behaviour from real pages — nesting, attributes, odd whitespace, prices split across elements. Avoid huge saved pages in unit tests; they slow tests down and obscure what is being tested.

8. Test settings and teardown paths

 1test("does nothing when disabled and cleans up on toggle off", async () => {
 2  document.body.innerHTML = readFileSync("tests/fixtures/product-list.html", "utf8");
 3  const stop = start({ root: document, runtime: chrome.runtime, settings: { enabled: false } });
 4  await tick();
 5  expect(document.querySelectorAll("[data-readable-price]")).toHaveLength(0);
 6  expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
 7  stop();
 8
 9  const stop2 = start({ root: document, runtime: chrome.runtime, settings: { enabled: true } });
10  await tick();
11  stop2();                                                            // teardown must remove everything
12  expect(document.querySelectorAll("mark.readable-price, [data-readable-price]")).toHaveLength(0);
13});

Execution context: Vitest with JSDOM. A paused or disabled site should cost nothing and touch nothing, and teardown — on toggle-off, on extension update, on navigation — must restore the page exactly. Both are easy to break and easy to test here. Asserting that no messages were sent when disabled also guards against needless service worker wake-ups. See removing injected UI cleanly.

Common mistakes

  • Logic tangled with bootstrapping. Untestable without a browser.
  • Assuming layout works. getBoundingClientRect is zero in JSDOM.
  • Not awaiting mutation delivery. Observers run asynchronously.
  • Leaking observers between tests. Always stop the controller.
  • Testing appearance in JSDOM. Use browser tests for CSS and visibility.

Cross-browser variation

  • Chrome / Edge: JSDOM approximates Chromium DOM behaviour well for logic.
  • Firefox: content scripts in Firefox see Xray-wrapped page objects; JSDOM cannot model that — cover with Firefox browser tests.
  • Safari: no difference for JSDOM logic; verify behaviour in Safari manually.

Verification

  1. Break a selector and confirm the fixture test fails.
  2. Remove the idempotency check and confirm the double-annotation test fails.
  3. Confirm the full content-script unit suite runs in under a few seconds.
  4. Run vitest --coverage and confirm the content-script logic is covered.

FAQ

JSDOM or happy-dom?

Both work; happy-dom is faster, JSDOM is more complete. Choose one and keep it consistent.

Can I load the built content script bundle?

You can, but testing modules directly gives better errors and coverage.

How do I test document_start behaviour?

Simulate it by running start before adding fixture content, then add the DOM incrementally.

Should I snapshot the annotated HTML?

Small, targeted snapshots of one annotated element are useful; whole-document snapshots break on every unrelated change.

Other Testing, Debugging & Performance Optimization Resources