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.
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.
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.
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.
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.
getBoundingClientRectis 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
- Break a selector and confirm the fixture test fails.
- Remove the idempotency check and confirm the double-annotation test fails.
- Confirm the full content-script unit suite runs in under a few seconds.
- Run
vitest --coverageand 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.
Related
- Testing content scripts against real pages — browser-level tests.
- Injecting chrome APIs for testability — the dependency pattern used here.
- Mocking chrome APIs in Jest — broader fakes.
- Unit and integration testing — the parent topic.