Mocking chrome APIs in Jest

Concrete patterns for mocking chrome.storage, chrome.runtime, and chrome.tabs in Jest: typed global stubs, promisified mocks, call assertions, per-test resets, and @types/chrome integration.

Published June 19, 2026 Updated June 19, 2026 9 min read
Table of Contents

The immediate symptom: you run npx jest on a module that calls chrome.storage.local.get and the test output reads ReferenceError: chrome is not defined. The fix is straightforward — attach a stub to global.chrome before any test module loads — but the details matter enormously. A stub that returns undefined from every mock will let tests pass for the wrong reason. This guide belongs to the Unit & Integration Testing section.

MV3 service worker code calls chrome.* APIs at the top level of the module, at listener registration time, and deep inside async callbacks. All three call sites need the stub installed before the module’s first import resolves. That is why the stub lives in setupFilesAfterFramework rather than inside individual test files — by the time a test file runs, the module has already been imported and its top-level side effects have already fired.

Step 1. Install @types/chrome and configure the setup file

@types/chrome provides the complete typeof chrome type tree. Install it as a dev dependency so TypeScript can catch typos in mock property names at compile time.

1npm install --save-dev @types/chrome jest ts-jest

Execution context: Your local shell or CI runner. These are pure dev dependencies; none of them are included in the built extension package.

1// jest.config.ts
2import type { Config } from "jest";
3
4export default {
5  preset: "ts-jest",
6  testEnvironment: "node",
7  setupFilesAfterFramework: ["<rootDir>/jest.setup.ts"],
8} satisfies Config;

Execution context: Read by the Jest CLI before spawning any test workers. setupFilesAfterFramework runs once per worker after the Jest globals (expect, jest, beforeEach) are available but before any test or source module is imported. Using setupFiles (without “AfterFramework”) runs before Jest globals exist, which makes jest.fn() undefined and causes the setup to throw.

Step 2. Build a typed global.chrome stub

The stub must satisfy typeof chrome so TypeScript does not complain at call sites in the source modules. Use as unknown as typeof chrome for the outermost cast; use specific sub-types for individual API groups to keep autocomplete useful.

 1// jest.setup.ts
 2// Backing store shared across all chrome.storage.local mock calls within a test
 3const _local: Record<string, unknown> = {};
 4const _sync: Record<string, unknown> = {};
 5
 6function makeStorageArea(store: Record<string, unknown>) {
 7  return {
 8    get: jest.fn(async (keys?: string | string[] | Record<string, unknown> | null) => {
 9      if (keys == null) return { ...store };
10      const ks =
11        typeof keys === "string" ? [keys]
12        : Array.isArray(keys) ? keys
13        : Object.keys(keys);
14      return Object.fromEntries(ks.map((k) => [k, store[k]]));
15    }),
16    set: jest.fn(async (items: Record<string, unknown>) => {
17      Object.assign(store, items);
18    }),
19    remove: jest.fn(async (keys: string | string[]) => {
20      (Array.isArray(keys) ? keys : [keys]).forEach((k) => delete store[k]);
21    }),
22    clear: jest.fn(async () => {
23      Object.keys(store).forEach((k) => delete store[k]);
24    }),
25    getBytesInUse: jest.fn(async () => 0),
26    onChanged: { addListener: jest.fn(), removeListener: jest.fn(), hasListener: jest.fn(() => false) },
27  } as unknown as chrome.storage.StorageArea;
28}
29
30global.chrome = {
31  storage: {
32    local: makeStorageArea(_local),
33    sync: makeStorageArea(_sync),
34    session: makeStorageArea({}),
35    managed: makeStorageArea({}),
36    onChanged: { addListener: jest.fn(), removeListener: jest.fn(), hasListener: jest.fn(() => false) },
37  },
38  runtime: {
39    sendMessage: jest.fn(),
40    onMessage: {
41      addListener: jest.fn(),
42      removeListener: jest.fn(),
43      hasListener: jest.fn(() => false),
44    },
45    onInstalled: { addListener: jest.fn(), removeListener: jest.fn() },
46    onStartup: { addListener: jest.fn(), removeListener: jest.fn() },
47    onConnect: { addListener: jest.fn(), removeListener: jest.fn() },
48    connect: jest.fn(() => ({
49      postMessage: jest.fn(),
50      disconnect: jest.fn(),
51      onMessage: { addListener: jest.fn(), removeListener: jest.fn() },
52      onDisconnect: { addListener: jest.fn(), removeListener: jest.fn() },
53    })),
54    getURL: jest.fn((path: string) => `chrome-extension://fake-extension-id/${path}`),
55    id: "fake-extension-id",
56    lastError: undefined as chrome.runtime.LastError | undefined,
57  },
58  tabs: {
59    query: jest.fn(async () => [] as chrome.tabs.Tab[]),
60    get: jest.fn(async () => ({ id: 1, url: "https://example.com" } as chrome.tabs.Tab)),
61    sendMessage: jest.fn(),
62    update: jest.fn(async () => ({} as chrome.tabs.Tab)),
63    onUpdated: { addListener: jest.fn(), removeListener: jest.fn() },
64    onActivated: { addListener: jest.fn(), removeListener: jest.fn() },
65    onRemoved: { addListener: jest.fn(), removeListener: jest.fn() },
66  },
67  alarms: {
68    create: jest.fn(),
69    get: jest.fn(async () => undefined),
70    getAll: jest.fn(async () => []),
71    clear: jest.fn(async () => true),
72    clearAll: jest.fn(async () => true),
73    onAlarm: { addListener: jest.fn(), removeListener: jest.fn() },
74  },
75  action: {
76    setBadgeText: jest.fn(),
77    setBadgeBackgroundColor: jest.fn(),
78    setIcon: jest.fn(),
79    setTitle: jest.fn(),
80    onClicked: { addListener: jest.fn(), removeListener: jest.fn() },
81  },
82} as unknown as typeof chrome;
83
84beforeEach(() => {
85  // wipe backing stores
86  [_local, _sync].forEach((store) =>
87    Object.keys(store).forEach((k) => delete store[k])
88  );
89  // reset spy state (call counts, return-value overrides) without removing implementations
90  jest.clearAllMocks();
91  // clear lastError so one test's error path does not bleed into the next
92  (chrome.runtime as { lastError?: chrome.runtime.LastError }).lastError = undefined;
93});

Execution context: Node.js test worker. The makeStorageArea factory creates independent backing stores for local and sync areas so writes to one do not contaminate the other. jest.clearAllMocks() in beforeEach resets call counts and mockReturnValue overrides but keeps the jest.fn() implementation function, which is what we want — the storage simulation stays active between tests.

Step 3. Promisified mocks and overriding return values

The stub above returns real async behaviour through the backing store. For unit tests that need to simulate a specific state without calling set first, use mockResolvedValueOnce to short-circuit the implementation for a single call.

 1// test: override get to return specific data for one call
 2it("applies dark theme when stored config says so", async () => {
 3  (chrome.storage.local.get as jest.Mock).mockResolvedValueOnce({
 4    settings: { theme: "dark", enabled: true },
 5  });
 6
 7  const settings = await loadSettings();
 8  expect(settings.theme).toBe("dark");
 9  // subsequent calls fall through to the real backing-store implementation
10});

Execution context: Jest test worker. mockResolvedValueOnce stacks with the underlying jest.fn() implementation: the first call gets the overridden resolved value; subsequent calls use the backing-store implementation. mockResolvedValue (without “Once”) permanently overrides the implementation for all calls until jest.clearAllMocks() runs in the next beforeEach.

Step 4. Asserting that the correct API was called

Asserting return values alone is insufficient. A refactor might switch chrome.storage.local to chrome.storage.sync and all value assertions would still pass while the functional behaviour changed. Always assert the mock directly.

 1// src/__tests__/save-settings.test.ts
 2import { saveSettings } from "../storage";
 3
 4it("writes to chrome.storage.local not sync", async () => {
 5  await saveSettings({ theme: "light", enabled: true });
 6
 7  expect(chrome.storage.local.set).toHaveBeenCalledTimes(1);
 8  expect(chrome.storage.local.set).toHaveBeenCalledWith({
 9    settings: { theme: "light", enabled: true },
10  });
11  // verify sync was NOT touched
12  expect(chrome.storage.sync.set).not.toHaveBeenCalled();
13});
14
15it("batches multiple keys in a single set call", async () => {
16  await saveSettings({ theme: "system", enabled: false });
17  const [[callArgs]] = (chrome.storage.local.set as jest.Mock).mock.calls;
18  expect(Object.keys(callArgs)).toHaveLength(1); // one top-level key
19});

Execution context: Jest test worker. jest.Mock).mock.calls contains an array of argument arrays, one per invocation. The pattern const [[callArgs]] = mock.calls destructures the first call’s first argument. This is especially useful when the argument is an object and you want to inspect individual properties separately from the toHaveBeenCalledWith assertion.

Step 5. Resetting between tests — why it matters

Consider two tests in the same file. The first stores a value; the second expects storage to be empty. Without the beforeEach reset, the backing store accumulates state across tests and the second test fails for a non-obvious reason.

 1describe("storage isolation demo", () => {
 2  it("stores a value", async () => {
 3    await chrome.storage.local.set({ x: 42 });
 4    const { x } = await chrome.storage.local.get("x");
 5    expect(x).toBe(42);
 6  });
 7
 8  it("starts with empty storage", async () => {
 9    // relies on beforeEach clearing _local
10    const result = await chrome.storage.local.get("x");
11    expect(result.x).toBeUndefined(); // passes only because beforeEach cleared _local
12  });
13});

Execution context: Jest test worker. The beforeEach in jest.setup.ts clears _local and _sync before every test in every file. If you use jest.resetAllMocks() instead of jest.clearAllMocks(), the implementation function is removed and the backing-store simulation stops working — subsequent get calls return undefined instead of the stored value. Use clearAllMocks, not resetAllMocks.

Cross-browser variation

  • Chrome/Edge: chrome.* namespace; Promises returned from all storage and runtime methods in MV3. The stub above reflects this surface exactly.
  • Firefox: browser.* namespace with native Promises. To test Firefox-targeted code, add global.browser = global.chrome to the setup file (or a separate jest.setup.firefox.ts via a second projects entry in Jest config). webextension-polyfill wraps chrome.* in browser.* at runtime but is not needed in tests — the stub already returns Promises.
  • Safari: uses the same browser.* namespace as Firefox. Safari’s storage.session is not supported as of Safari 17; if your code branches on session storage availability, add a test for the fallback path.

Verification

After completing the setup, run npx jest --verbose and expect:

  1. No ReferenceError: chrome is not defined errors.
  2. Test output shows the correct call count in any toHaveBeenCalledTimes assertions.
  3. --coverage output includes your storage and message-handler modules with meaningful branch coverage.

To verify the reset is working, temporarily remove the jest.clearAllMocks() call from beforeEach and run the isolation demo above — the second test should fail with expect(received).toBeUndefined() received 42. Restore the call and confirm both tests pass.

FAQ

Why use setupFilesAfterFramework instead of a beforeAll in each test file?

A beforeAll inside a test file runs after the test file’s modules are imported. If the source module calls chrome.runtime.onMessage.addListener at import time, that call fires before beforeAll and before global.chrome exists. setupFilesAfterFramework runs before any imports in any test file, so global.chrome is always present when the module loader resolves the first chrome.* call.

Why does jest.resetAllMocks() break the storage simulation?

resetAllMocks replaces each jest.fn() with a fresh spy that has no implementation. The storage simulation depends on the get mock’s implementation function to look up values in _local. After resetAllMocks, get returns undefined instead of the stored value. Use clearAllMocks to wipe call history and return-value overrides without touching the implementation.

Can I use jest-webextension-mock instead of a hand-rolled stub?

Yes. import "jest-webextension-mock" in setupFilesAfterFramework installs a full global.chrome stub in one line. The trade-off is that its storage mocks do not maintain a backing store — get always returns {} unless you call .mockResolvedValueOnce(...) per test. That pattern is fine for small suites but becomes verbose once you have many tests that depend on prior set calls.

How do I test code that uses chrome.runtime.lastError?

Set chrome.runtime.lastError to a { message: "..." } object before calling the function under test, then clear it in afterEach. The real browser sets and clears lastError synchronously within the callback scope; the stub must do the same manually:

1it("handles a runtime error", async () => {
2  (chrome.storage.local.get as jest.Mock).mockImplementationOnce(async () => {
3    (chrome.runtime as { lastError?: { message: string } }).lastError = {
4      message: "Extension context invalidated.",
5    };
6    return {};
7  });
8  await expect(loadSettings()).rejects.toThrow("Extension context invalidated.");
9});

Execution context: Jest test worker. The mockImplementationOnce callback sets lastError synchronously before returning; the module under test checks it on the resolved Promise, matching the real browser’s behaviour.

Other Testing, Debugging & Performance Optimization Resources