Unit & Integration Testing for MV3 Extensions
Set up Jest or Vitest to test Manifest V3 extension logic: mock the chrome.* namespace, test message handlers and storage modules, and work around jsdom's hard limits.
Without a working chrome.* mock, your test suite cannot import a single module that touches the extension API surface. Every call to chrome.storage.get, chrome.runtime.sendMessage, or chrome.tabs.query throws ReferenceError: chrome is not defined at import time, before any describe block runs. Fixing that is the prerequisite for everything else in this guide. This topic is part of Testing, Debugging & Performance Optimization.
The secondary constraint is environmental: Jest’s default jsdom test environment emulates a browser DOM but provides none of the extension-specific globals, workers, or IPC mechanisms that MV3 relies on. Service worker logic in particular must be tested under testEnvironment: "node" — jsdom adds overhead and false confidence because it adds window and document globals that do not exist in the real worker context.
Prerequisites checklist
Before writing a test, verify the following:
- Jest ≥ 29 or Vitest ≥ 1.0 installed as a dev dependency.
ts-jest(for Jest) or native TypeScript support (for Vitest) configured so.tsfiles transpile in tests.@types/chromeinstalled — provides the fullchromenamespace type tree even though the runtime implementation is mocked.- A setup file (
jest.setup.ts) listed undersetupFilesAfterFrameworkinjest.config.tsthat installs theglobal.chromestub before any test module loads. testEnvironment: "node"for service-worker logic;"jsdom"only for modules that referencedocumentorwindow.- No
localStorageorsessionStoragein your extension modules — those do not exist in the service worker andjsdomfaking them would mask real bugs.
1. Jest configuration and the setup file
The first decision is which mock library to use. jest-webextension-mock covers the widest API surface for the least setup. sinon-chrome provides richer spy assertions. A hand-rolled stub gives full type-safety and zero transitive dependencies. All three approaches require the same Jest configuration entry point.
1// jest.config.ts
2import type { Config } from "jest";
3
4const config: Config = {
5 preset: "ts-jest",
6 testEnvironment: "node",
7 setupFilesAfterFramework: ["./jest.setup.ts"],
8 moduleNameMapper: {
9 // if your extension modules use path aliases
10 "^@/(.*)$": "<rootDir>/src/$1",
11 },
12 coveragePathIgnorePatterns: ["/node_modules/", "/dist/"],
13};
14
15export default config;
Execution context: Read by the Jest CLI in the Node.js process that spawns test workers. setupFilesAfterFramework runs after the test framework is installed but before any test files, making it the correct hook for attaching globals. Using setupFiles instead causes jest.fn() to be undefined when the setup file runs.
2. Choosing and installing the chrome mock
Option A — jest-webextension-mock (fastest path):
1npm install --save-dev jest-webextension-mock
Execution context: Your local shell or CI runner. This adds jest-webextension-mock to devDependencies; it is never bundled into the extension itself.
1// jest.setup.ts — option A
2import "jest-webextension-mock";
Execution context: Node.js test worker. The library installs a complete global.chrome stub with Jest spy functions already attached. Default return values are mostly undefined; override per-test with chrome.storage.local.get.mockResolvedValue(...).
Option B — hand-rolled stub (recommended for complex message-passing scenarios):
1// jest.setup.ts — option B: typed hand-rolled stub
2const _storage: Record<string, unknown> = {};
3
4const storageMock = {
5 local: {
6 get: jest.fn(async (keys?: string | string[] | Record<string, unknown>) => {
7 if (!keys) return { ..._storage };
8 const ks = typeof keys === "string" ? [keys]
9 : Array.isArray(keys) ? keys
10 : Object.keys(keys);
11 return Object.fromEntries(ks.map((k) => [k, _storage[k]]));
12 }),
13 set: jest.fn(async (items: Record<string, unknown>) => {
14 Object.assign(_storage, items);
15 }),
16 remove: jest.fn(async (keys: string | string[]) => {
17 (Array.isArray(keys) ? keys : [keys]).forEach((k) => delete _storage[k]);
18 }),
19 clear: jest.fn(async () => { Object.keys(_storage).forEach((k) => delete _storage[k]); }),
20 },
21 sync: {
22 get: jest.fn(async () => ({})),
23 set: jest.fn(async () => {}),
24 },
25 onChanged: { addListener: jest.fn(), removeListener: jest.fn() },
26};
27
28global.chrome = {
29 storage: storageMock,
30 runtime: {
31 sendMessage: jest.fn(),
32 onMessage: { addListener: jest.fn(), removeListener: jest.fn(), hasListener: jest.fn(() => false) },
33 onInstalled: { addListener: jest.fn() },
34 onStartup: { addListener: jest.fn() },
35 lastError: undefined,
36 },
37 tabs: {
38 query: jest.fn(async () => []),
39 sendMessage: jest.fn(),
40 onUpdated: { addListener: jest.fn(), removeListener: jest.fn() },
41 },
42 alarms: {
43 create: jest.fn(),
44 get: jest.fn(async () => undefined),
45 getAll: jest.fn(async () => []),
46 clear: jest.fn(async () => true),
47 onAlarm: { addListener: jest.fn(), removeListener: jest.fn() },
48 },
49} as unknown as typeof chrome;
50
51beforeEach(() => {
52 // reset in-memory storage but keep mock references stable
53 Object.keys(_storage).forEach((k) => delete _storage[k]);
54 jest.clearAllMocks();
55 (global.chrome.runtime as { lastError?: chrome.runtime.LastError }).lastError = undefined;
56});
Execution context: Node.js test worker. The beforeEach hook clears accumulated storage state and resets spy call histories between tests. jest.clearAllMocks() resets call counts and return-value overrides; it does not remove the mock implementation, so the storage simulation stays intact.
3. Testing storage modules
Storage modules are the most testable part of an MV3 extension because their interface is a thin async wrapper around chrome.storage. The key testing discipline is asserting both the return value and the underlying mock call.
1// src/storage.ts — module under test
2export async function loadSettings(): Promise<{ theme: string; enabled: boolean }> {
3 const { settings } = await chrome.storage.local.get("settings");
4 return settings ?? { theme: "system", enabled: true };
5}
6
7export async function saveSettings(settings: { theme: string; enabled: boolean }) {
8 await chrome.storage.local.set({ settings });
9}
Execution context: Production source module, compiled by ts-jest at test runtime. This file runs in the Jest worker process (Node.js) with global.chrome already stubbed from setupFilesAfterFramework.
1// src/__tests__/storage.test.ts
2import { loadSettings, saveSettings } from "../storage";
3
4describe("loadSettings", () => {
5 it("returns defaults when nothing is stored", async () => {
6 const result = await loadSettings();
7 expect(result).toEqual({ theme: "system", enabled: true });
8 });
9
10 it("returns the stored value when present", async () => {
11 await saveSettings({ theme: "dark", enabled: false });
12 const result = await loadSettings();
13 expect(result).toEqual({ theme: "dark", enabled: false });
14 });
15
16 it("calls chrome.storage.local.get with the correct key", async () => {
17 await loadSettings();
18 expect(chrome.storage.local.get).toHaveBeenCalledWith("settings");
19 });
20});
Execution context: Jest test worker (Node.js). The in-memory _storage map from the setup file acts as the actual backing store, so saveSettings → loadSettings round-trips work without additional mocking. Assertions on chrome.storage.local.get verify that the module uses the correct key names.
4. Testing message handlers
Message handlers are harder to test because they use the sendResponse callback pattern, which is synchronous by signature but asynchronous in practice whenever return true is present. The test must capture the registered listener and invoke it directly.
1// src/message-handler.ts — module under test
2chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
3 if (msg.type === "GET_CONFIG") {
4 chrome.storage.local.get("config").then(({ config }) => {
5 sendResponse({ ok: true, config: config ?? {} });
6 });
7 return true; // keep port open for async sendResponse
8 }
9 return false;
10});
Execution context: Production source module. The addListener call executes at import time in the Jest worker; the stub’s addListener spy records it so tests can retrieve it from mock.calls. In the real extension this file runs in the service worker’s top-level scope.
1// src/__tests__/message-handler.test.ts
2import "../message-handler"; // registers the listener as a side effect
3
4describe("GET_CONFIG handler", () => {
5 let listener: (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void) => boolean;
6
7 beforeEach(() => {
8 // capture the listener that was registered
9 const calls = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls;
10 listener = calls[calls.length - 1][0];
11 });
12
13 it("responds with the stored config", async () => {
14 await chrome.storage.local.set({ config: { debug: true } });
15 const sendResponse = jest.fn();
16 const keepOpen = listener({ type: "GET_CONFIG" }, {}, sendResponse);
17 expect(keepOpen).toBe(true);
18 // flush the storage.get Promise
19 await Promise.resolve();
20 await Promise.resolve();
21 expect(sendResponse).toHaveBeenCalledWith({ ok: true, config: { debug: true } });
22 });
23
24 it("responds with empty config when nothing is stored", async () => {
25 const sendResponse = jest.fn();
26 listener({ type: "GET_CONFIG" }, {}, sendResponse);
27 await Promise.resolve();
28 await Promise.resolve();
29 expect(sendResponse).toHaveBeenCalledWith({ ok: true, config: {} });
30 });
31});
Execution context: Jest test worker. Two await Promise.resolve() calls are needed because storage.local.get resolves in one microtask tick and the .then() callback fires in the next. If your handler has deeper Promise chains, add more ticks or use await new Promise(setImmediate) for a full macrotask flush.
5. Structuring code so it can be unit tested
The biggest obstacle to unit testing an extension is rarely the test framework; it is code that reaches for chrome.* globals from everywhere, so that nothing can be imported without a browser. A small structural change removes the obstacle for most of the codebase.
Split each feature into three layers. Pure logic — parsing, validation, ranking, migrations — takes plain data and returns plain data, with no chrome references at all, and is tested exhaustively in Node. Handlers take a message, a sender and their dependencies (storage, tabs, a clock) as parameters, so tests pass fakes and production passes the real APIs; this pattern is developed in testing message handlers in isolation. Wiring — the few lines that register listeners and pass real APIs into handlers — is covered by a handful of end-to-end tests rather than unit tests.
Once code is shaped this way, the fake chrome object needed for unit tests shrinks to the handful of methods the handlers actually receive, and most tests need no fake at all.
6. What a good fake must reproduce
Where a fake is needed, its fidelity matters more than its breadth. Three behaviours cause most false passes when a fake gets them wrong.
Copy semantics. Real chrome.storage returns copies; mutating a returned object does not change what is stored. A fake that returns references lets tests pass that would fail in the browser. Using structuredClone on every read and write fixes it.
Asynchrony. Every real storage and messaging call resolves on a later tick. A synchronous fake hides ordering bugs that appear in production when two writes interleave.
Events. storage.onChanged fires after every write, including your own. A fake that never emits it cannot test listener code, and one that emits synchronously can mask feedback loops. A small event emitter that fires after set resolves reproduces the real behaviour closely enough for almost every test — the setup in using Vitest with webextension mocks.
7. Deciding what to test first
With limited time, test in order of the cost of a bug. Data migrations and schema parsing come first: a bug there can destroy user data, and it only shows up for users who skipped versions, long after release. Message handlers come next, because they hold most of the extension’s behaviour. Security checks at context boundaries — sender validation, input sanitisation — follow, because they are easy to break in a refactor and invisible when broken. UI component tests are valuable but come last; UI bugs are visible, reported quickly and usually cheap to fix. The coverage side of this prioritisation is in a coverage strategy for extension code.
8. Keeping the suite fast and trusted
A unit suite earns its keep by running constantly — in watch mode while editing, on every commit in CI — and that only happens if it stays fast and its failures are believed. Extension suites tend to slow down and lose trust in the same few ways.
The first is creeping browser dependence: a test that needs jsdom for one assertion, then another, until half the suite runs in a simulated DOM that is slower and less faithful than either Node or a real browser. Keep the default environment node, opt into jsdom per file only for UI components, and move anything that needs a real extension runtime into the end-to-end suite where it belongs.
The second is shared state. A fake storage that is not reset between tests lets one test’s data leak into the next, producing failures that depend on test order. Reset every fake in a beforeEach, and run the suite in random order occasionally to prove it.
The third is over-mocking. A test that stubs every call with vi.fn() returning canned values checks that the code calls functions, not that it works. Prefer a small fake with realistic behaviour over a large number of per-test stubs; the fake then acts as a single, reviewable description of how the browser APIs behave, and a mismatch with reality is fixed once rather than in every test.
Measured by feedback time, a healthy extension unit suite runs the affected tests in under a second after a save and the whole suite in a few seconds. When it grows past that, look for the three problems above before looking for a faster runner.
MV3 constraints to design around
- No real
chrome.*runtime in Node.js: every API call must be stubbed — there is no browser process to dispatch to. jsdomdoes not emulate service worker globals:self.addEventListener("fetch"),caches, andServiceWorkerRegistrationare absent unless polyfilled manually.- Listener registration is a side effect: modules that call
chrome.runtime.onMessage.addListenerat import time must be imported in the test for the listener to be registered; Jest module isolation means each test file gets a fresh module registry unlessjest.resetModules()is called. return truepattern is synchronous: the test framework does not wait for it; use explicit Promise flushing as shown above.chrome.runtime.lastError: must be set and cleared manually on the mock when testing error paths — the real browser sets it automatically.
Cross-browser notes
Firefox exposes the same API surface under browser.* with native Promises. Testing Firefox compatibility in unit tests usually means running the same suite against a browser.* stub:
1// jest.setup.firefox.ts — mirror stub under browser.*
2// @ts-ignore
3global.browser = global.chrome;
Execution context: Node.js test worker, used when Jest’s projects config points a Firefox-targeted project at this setup file. The assignment makes browser.* and chrome.* identical stubs without duplicating any mock logic.
Safari uses the same browser.* namespace as Firefox for its WebExtension implementation. The webextension-polyfill package provides a unified browser.* facade over chrome.* at runtime; in tests you only need the stub, not the polyfill, since there is no real runtime to wrap.
Scheduling is the part of an extension people give up on unit-testing, because the obvious approach is to wait. It is not: testing alarms and timers deterministically builds a fake alarm registry you fire by hand, which turns a schedule into an assertion that runs in a millisecond.
Further guides in this topic
The guides below go deeper into specific unit and integration testing for mv3 extensions problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- A Coverage Strategy for Extension Code — Measure test coverage across an MV3 extension’s contexts — unit coverage for shared modules, browser coverage for extension pages, why the service worker is hard to cover, and targets that mean something.
- Contract Testing a Storage Schema — Treat what an MV3 extension writes to chrome.storage as a contract — schema definitions, fixtures from every released version, round-trip and migration tests, and catching incompatible writes in CI.
- 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.
- Using Vitest with WebExtension Mocks — Unit-test MV3 extension code with Vitest — a chrome/browser global fake in setupFiles, per-test storage isolation, event emitters for onMessage and onChanged, and jsdom only where the DOM is needed.
Related
- Mocking chrome APIs in Jest — deep dive into stub strategies, promisified mocks, and TypeScript typing.
- Testing alarms and timers deterministically — a fake alarm registry and fake timers.
- End-to-end Testing & Automation — test real extension behaviour in a loaded browser.
- Debugging Extension Contexts — DevTools workflow when a test reveals a bug you need to trace live.
- Up to the Testing, Debugging & Performance Optimization overview.