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.
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.
Before writing a test, verify the following:
ts-jest (for Jest) or native TypeScript support (for Vitest) configured so .ts files transpile in tests.@types/chrome installed — provides the full chrome namespace type tree even though the runtime implementation is mocked.jest.setup.ts) listed under setupFilesAfterFramework in jest.config.ts that installs the global.chrome stub before any test module loads.testEnvironment: "node" for service-worker logic; "jsdom" only for modules that reference document or window.localStorage or sessionStorage in your extension modules — those do not exist in the service worker and jsdom faking them would mask real bugs.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.
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.
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.
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.
chrome.* runtime in Node.js: every API call must be stubbed — there is no browser process to dispatch to.jsdom does not emulate service worker globals: self.addEventListener("fetch"), caches, and ServiceWorkerRegistration are absent unless polyfilled manually.chrome.runtime.onMessage.addListener at 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 unless jest.resetModules() is called.return true pattern 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.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.