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.

Published September 18, 2026 Updated September 18, 2026 9 min read
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

Layers of a message handlerThe runtime.onMessage listener wiring at the top, a registry that dispatches by type, the pure handler functions, and the storage and API dependencies they receive.runtime.onMessage listenerreturn true, sendResponseone small file, tested end to endRegistrytype → handler, error serialisationtested onceHandlers(msg, sender, deps) → resultmost tests live hereDependenciesstorage, tabs, fetchpassed in, faked in tests
Only the top layer needs a browser — everything below it can be tested in Node with plain objects.

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.

A handler test, end to end in NodeThe test builds handlers with a fake storage and a fixed clock, calls one with a fake sender, and inspects both the return value and the fake's contents.TestHandlerFake storageAssertionsmakeArticleHandlers({storage, now})call with msg + fake senderget("items")set({ items }){ saved: true, count: 1 }dump() → at === 1000
No browser, no timers, no network — and the edge cases are one argument away.

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.

Test count and runtime by layerNumber of tests and total runtime for handler unit tests, registry tests and end-to-end round-trip tests in a typical extension suite.120 handler unit tests1.8 seconds6 registry tests0.2 seconds8 end-to-end round trips24 seconds
Most tests live in the cheapest layer; the browser tests are few and targeted.

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 true for 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) → result do not care which engine called them — the portability lives entirely in the thin wiring layer.

Verification

  1. Run the unit suite and confirm it completes in seconds without launching a browser.
  2. Remove the sender.tab check and confirm the “did not come from a tab” test fails.
  3. Break the registry’s return true and 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.

  1. 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.

Other Testing, Debugging & Performance Optimization Resources