Testing Message Handlers in Isolation
Unit-test MV3 message handlers without a browser — separating handler logic from runtime.onMessage wiring, faking sender and storage, and covering the error and timeout paths real tabs produce.
Table of Contents
Message handlers are where most extension logic lives, and they are usually tested last because they look entangled with the browser: registered on chrome.runtime.onMessage, handed a sender object, replying through a sendResponse callback. Untangled, a handler is a function from a message and a sender to a value or an error — which Node can test in milliseconds, including the edge cases that are hard to produce in a real browser. This guide is part of unit and integration testing.
Separating the handler from the wiring
Step-by-step
1. Write handlers that take their dependencies
1// handlers/articles.js
2export function makeArticleHandlers({ storage, now = Date.now }) {
3 return {
4 "articles:save": async ({ url, title }, sender) => {
5 if (!sender.tab) throw new Error("must come from a tab");
6 if (!/^https?:\/\//.test(url)) throw new Error("bad url");
7 const { items = [] } = await storage.get("items");
8 if (items.some((i) => i.url === url)) return { saved: false, reason: "duplicate" };
9 await storage.set({ items: [...items, { url, title: title.slice(0, 200), at: now() }] });
10 return { saved: true, count: items.length + 1 };
11 },
12 };
13}
Execution context: a plain module with no chrome references. In the worker, storage is chrome.storage.local; in tests, it is a fake. Injecting now makes timestamps deterministic without a fake-timer library. The shape mirrors the registry described in wrapping message passing in promises.
2. Keep the wiring thin
1// service-worker.js
2import { makeArticleHandlers } from "./handlers/articles.js";
3import { register } from "./rpc/registry.js";
4
5register(makeArticleHandlers({ storage: chrome.storage.local }));
Execution context: the service worker, at the top level. The only browser-specific code is this line and the registry’s single onMessage listener — small enough to cover with one end-to-end test, leaving the handlers to unit tests.
3. Fake storage with an in-memory object
1// test/fakes.js
2export function fakeStorage(initial = {}) {
3 let data = structuredClone(initial);
4 return {
5 async get(keys) {
6 if (keys == null) return structuredClone(data);
7 const list = typeof keys === "string" ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys);
8 return Object.fromEntries(list.filter((k) => k in data).map((k) => [k, structuredClone(data[k])]));
9 },
10 async set(obj) { Object.assign(data, structuredClone(obj)); },
11 async remove(keys) { for (const k of [].concat(keys)) delete data[k]; },
12 dump: () => structuredClone(data),
13 };
14}
Execution context: Node. structuredClone on every read and write reproduces the real API’s copy semantics — a test that mutates a returned object and then expects storage to be unchanged will behave as it would in the browser. That is the bug a shallow fake hides.
4. Test the success and the edges
1import { test } from "node:test";
2import assert from "node:assert/strict";
3import { makeArticleHandlers } from "../handlers/articles.js";
4import { fakeStorage } from "./fakes.js";
5
6const tabSender = { id: "ext", tab: { id: 7, url: "https://example.com/" } };
7
8test("saves a new article", async () => {
9 const storage = fakeStorage();
10 const h = makeArticleHandlers({ storage, now: () => 1000 });
11 const r = await h["articles:save"]({ url: "https://example.com/a", title: "A" }, tabSender);
12 assert.deepEqual(r, { saved: true, count: 1 });
13 assert.equal(storage.dump().items[0].at, 1000);
14});
15
16test("rejects messages that did not come from a tab", async () => {
17 const h = makeArticleHandlers({ storage: fakeStorage() });
18 await assert.rejects(h["articles:save"]({ url: "https://x.com", title: "x" }, { id: "ext" }), /must come from a tab/);
19});
20
21test("ignores duplicates", async () => {
22 const storage = fakeStorage({ items: [{ url: "https://example.com/a", title: "A", at: 1 }] });
23 const h = makeArticleHandlers({ storage });
24 assert.deepEqual(await h["articles:save"]({ url: "https://example.com/a", title: "A" }, tabSender), { saved: false, reason: "duplicate" });
25});
Execution context: Node’s built-in test runner. Faking sender is how you test the security checks from protecting extension messages from web pages — a real browser makes it awkward to send a message that lacks a tab.
5. Test the registry’s error serialisation once
1test("handler errors become serialised replies", async () => {
2 const replies = [];
3 const listener = makeListener({ "boom": async () => { throw new TypeError("nope"); } });
4 const keepOpen = listener({ type: "boom" }, tabSender, (r) => replies.push(r));
5 assert.equal(keepOpen, true);
6 await new Promise((r) => setImmediate(r));
7 assert.deepEqual(replies, [{ __error: { name: "TypeError", message: "nope" } }]);
8});
Execution context: Node. makeListener is the registry’s factory for the function passed to onMessage.addListener. Asserting it returns true guards the one line that, if broken, closes every async channel in the extension.
What unit tests cannot cover — and what covers it
Isolated handler tests prove the logic. They cannot prove the wiring: that the listener was registered at the top level, that it returns true in the real runtime, that the message actually reaches the worker from a content script on a real page. Those need a browser, and one or two end-to-end tests per message type are enough.
The division of labour that works is roughly this. Unit tests cover every branch of every handler — validation, duplicates, errors, limits — because they are cheap and exhaustive. One end-to-end test per message type confirms a real round trip, including a cold worker. And one test confirms the registry’s listener returns true, because that single line breaks everything if wrong.
1// e2e: one real round trip for articles:save
2test("content script can save an article", async ({ context, sw }) => {
3 const page = await context.newPage();
4 await page.goto("https://example.com/");
5 await page.evaluate(() => chrome.runtime.sendMessage({ type: "articles:save", url: location.href, title: document.title }));
6 const items = await sw.evaluate(() => chrome.storage.local.get("items").then((r) => r.items));
7 expect(items).toHaveLength(1);
8});
Execution context: Playwright, with the fixture from driving service worker state from a test. Note this page.evaluate runs in the page’s main world, which has no chrome.runtime — in practice the test triggers the content script’s own save path; the sketch shows the shape of the assertion.
Refactoring existing handlers into this shape
Most extensions do not start with injected dependencies — they start with a single onMessage listener containing a switch statement that calls chrome.storage directly in each case. Moving to testable handlers does not require a rewrite, and doing it incrementally is lower risk than it looks.
Start by extracting one case at a time into a function that still calls chrome.* globally but takes the message and sender as arguments. That alone makes the function callable from a test with a global fake. Then, one function at a time, replace the global calls with a deps parameter defaulting to the real API — function save(msg, sender, deps = { storage: chrome.storage.local }) — so production code keeps working unchanged while tests pass a fake. When every case has moved, the switch collapses into the registry.
The order of extraction matters less than the discipline of adding a test before each move, against the old behaviour, and keeping it green through the change. That test then becomes the permanent unit test for the handler, and the refactor has improved coverage as a side effect rather than as a separate project.
Cross-browser variation
- Chrome / Edge: the listener must return
truefor async replies; a returned promise closes the channel. The registry test enforces this. - Firefox: accepts a returned promise from the listener as well as
sendResponse+true. The handler logic is identical; only the registry adapter differs if you use the promise form. - Safari: follows the Chrome contract. Handler tests in Node cover all three engines equally.
- All three: handlers written as
(msg, sender, deps) → resultdo not care which engine called them — the portability lives entirely in the thin wiring layer.
Verification
- Run the unit suite and confirm it completes in seconds without launching a browser.
- Remove the
sender.tabcheck and confirm the “did not come from a tab” test fails. - Break the registry’s
return trueand confirm its test fails:
1node --test test/
2# ✖ handler errors become serialised replies — expected true, got undefined
Execution context: your shell. This is the regression the registry test exists to catch.
- Confirm the fake’s copy semantics by mutating a returned object in a test and asserting storage is unchanged.
FAQ
Should I use a mocking library for chrome?
For handlers written with injected dependencies, no — a small fake of the few methods used is clearer and more accurate. Libraries help when legacy code references chrome globally, as covered in mocking Chrome APIs in Jest.
How do I test a handler that calls chrome.tabs?
Inject a tabs dependency the same way as storage, with a fake that records calls. Assert on what was called, not on browser state.
Are these unit or integration tests?
Handler tests with a realistic fake storage sit between the two. The label matters less than the property: they exercise real logic end to end within Node, fast.
Related
- Mocking Chrome APIs in Jest — when globals cannot be injected.
- Using Vitest with webextension mocks — the same approach with Vitest.
- Contract testing a storage schema — testing the data handlers write.
- Unit and integration testing — the parent guide.