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.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Vitest is a natural fit for extensions built with Vite: the same config, the same module resolution, and fast watch-mode re-runs. What it does not provide is a chrome global — any module that touches chrome.storage at import time fails before a test runs. The answer is a small, honest fake installed in a setup file: enough of the API surface your code uses, with the same asynchronous and copy semantics as the real thing, reset between tests. This guide is part of unit and integration testing.

Choosing a fake

Ways to provide chrome.* in VitestA hand-written fake, a community mock library and a sinon-chrome-style stub library compared on fidelity to copy semantics, event support, maintenance and setup effort.OptionCopy semanticsEventsEffortHand-written fakeAs faithful as you make itReal emitters~100 linesMock library (auto-generated)Often noneStubs onlyInstallStub-per-test (vi.fn)Whatever each test returnsManualPer test
A hand-written fake of the APIs you use is usually the most faithful — and shorter than it sounds.

Step-by-step

1. Configure Vitest with a setup file

 1// vitest.config.js
 2import { defineConfig } from "vitest/config";
 3
 4export default defineConfig({
 5  test: {
 6    environment: "node",                  // default: no DOM
 7    setupFiles: ["./test/setup-chrome.js"],
 8    restoreMocks: true,
 9    include: ["src/**/*.test.{js,ts}", "test/**/*.test.{js,ts}"],
10  },
11});

Execution context: the Vitest configuration. Starting from the node environment keeps worker-side tests honest — a module that accidentally touches document fails, just as it would in the real service worker. DOM-dependent tests opt in per file, as in step 5.

2. Write a fake with real semantics

 1// test/setup-chrome.js
 2import { beforeEach } from "vitest";
 3
 4function event() {
 5  const listeners = new Set();
 6  return {
 7    addListener: (fn) => listeners.add(fn),
 8    removeListener: (fn) => listeners.delete(fn),
 9    hasListener: (fn) => listeners.has(fn),
10    _emit: (...args) => [...listeners].map((fn) => fn(...args)),
11  };
12}
13
14function area(name, onChanged) {
15  let data = {};
16  return {
17    _reset: () => { data = {}; },
18    async get(keys) {
19      const all = structuredClone(data);
20      if (keys == null) return all;
21      const list = typeof keys === "string" ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys);
22      const defaults = typeof keys === "object" && !Array.isArray(keys) ? keys : {};
23      return Object.fromEntries(list.map((k) => [k, k in all ? all[k] : defaults[k]]).filter(([, v]) => v !== undefined));
24    },
25    async set(obj) {
26      const changes = {};
27      for (const [k, v] of Object.entries(structuredClone(obj))) {
28        changes[k] = { oldValue: data[k], newValue: v };
29        data[k] = v;
30      }
31      onChanged._emit(changes, name);
32    },
33    async remove(keys) { for (const k of [].concat(keys)) delete data[k]; },
34    async clear() { data = {}; },
35  };
36}
37
38const onChanged = event();
39globalThis.chrome = {
40  runtime: { id: "testextensionid", onMessage: event(), onInstalled: event(), getManifest: () => ({ version: "0.0.0-test" }) },
41  storage: { local: area("local", onChanged), sync: area("sync", onChanged), session: area("session", onChanged), onChanged },
42  alarms: { create: async () => {}, clear: async () => true, getAll: async () => [], onAlarm: event() },
43};
44
45beforeEach(() => {
46  for (const a of ["local", "sync", "session"]) chrome.storage[a]._reset();
47});

Execution context: Node, before every test file. structuredClone on the way in and out reproduces the real API’s copying; emitting onChanged on set means listener code is exercised exactly as in the browser. Resetting storage before each test prevents one test’s data leaking into the next.

3. Test code that reacts to storage events

 1import { describe, it, expect, vi } from "vitest";
 2import { subscribe } from "../src/data/settings.js";
 3
 4describe("settings.subscribe", () => {
 5  it("calls back when settings change in sync storage", async () => {
 6    const cb = vi.fn();
 7    const unsubscribe = subscribe(cb);
 8    await chrome.storage.sync.set({ settings: { theme: "dark" } });
 9    expect(cb).toHaveBeenCalledWith(expect.objectContaining({ theme: "dark" }));
10    unsubscribe();
11  });
12});

Execution context: Vitest in the node environment. Because the fake emits real events, this test exercises the actual listener path — the pattern in syncing options form state with chrome.storage — rather than a mocked shortcut.

4. Drive message listeners directly

 1import { it, expect } from "vitest";
 2import "../src/service-worker.js";          // registers listeners on the fake
 3
 4it("answers settings:read", async () => {
 5  await chrome.storage.sync.set({ settings: { theme: "light" } });
 6  const reply = await new Promise((resolve) => {
 7    const [keepOpen] = chrome.runtime.onMessage._emit({ type: "settings:read" }, { id: chrome.runtime.id }, resolve);
 8    if (keepOpen !== true) resolve(undefined);
 9  });
10  expect(reply.theme).toBe("light");
11});

Execution context: Vitest. Importing the worker module registers its listeners on the fake onMessage; emitting a message then runs them exactly as the browser would, including the return true contract. The handler-level approach is in testing message handlers in isolation; this is the thin wiring test on top.

5. Use jsdom only for DOM code

 1// src/ui/toggle.test.js
 2// @vitest-environment jsdom
 3import { it, expect } from "vitest";
 4import { toggle } from "./toggle.js";
 5
 6it("renders a labelled checkbox", () => {
 7  const root = document.createElement("div");
 8  toggle(root, { label: "Auto-save", checked: true, onChange: () => {} });
 9  const input = root.querySelector("input");
10  expect(input.checked).toBe(true);
11  expect(root.querySelector(`label[for="${input.id}"]`).textContent).toBe("Auto-save");
12});

Execution context: Vitest with jsdom for this file only. Keeping DOM tests opt-in per file means worker modules are always tested in an environment without document — which is the environment they run in. The component follows the pattern in sharing code between popup, options and side panel.

How a Vitest run sees the extensionThe setup file installs a chrome fake with real events and copy semantics; worker modules run in the node environment, UI modules in jsdom; storage is reset before each test.setup-chrome.jsevents + structuredClonebeforeEach resetstorage areas clearednode environmentworker + data modulesopt-in per file@vitest-environment jsdomUI components_emit() on eventsdrive listenersWatch modesub-second reruns
The environment split mirrors the browser: no DOM for worker code, DOM only where the UI needs it.

Keeping the fake honest

A fake that drifts from the real API produces tests that pass against behaviour the browser does not have. Three practices keep it trustworthy.

Fake only what you use. A complete chrome fake is thousands of lines and mostly wrong. A fake of the fifteen methods your code calls is a hundred lines and can be reviewed against the documentation in one sitting.

Mirror the failure modes, not just the happy path. The real storage.sync.set rejects over quota; the real tabs.sendMessage rejects when no receiver exists. Where your code handles those, give the fake a way to produce them.

1chrome.tabs = { sendMessage: vi.fn(async () => { throw new Error("Could not establish connection. Receiving end does not exist."); }) };

Execution context: a single test. Using the exact message text the browser produces means the code under test’s error matching is exercised realistically — the case covered in fixing “message port closed before response” errors.

Cross-check with a few browser tests. The end-to-end suite is the fake’s referee: if a behaviour passes in Vitest and fails in the browser, the fake is wrong and should be fixed first.

Feedback time by test layerTime from saving a file to seeing test results for Vitest in watch mode, a full Vitest run, and a Playwright end-to-end run.Vitest watch (affected tests)0.4 secondsVitest full run3 secondsPlaywright e2e suite45 seconds
Watch-mode unit tests give feedback before you have switched windows — the reason to push logic down into them.

Cross-browser variation

  • Chrome / Edge: the fake mirrors chrome.*, including return true for async message replies.
  • Firefox: code written against browser.* needs the fake exposed as globalThis.browser too, with promise-returning listeners supported in _emit. Aliasing browser to the same object is usually enough.
  • Safari: no additional fake surface; Safari follows the Chrome contract.
  • All three: the fake is engine-neutral by construction — it describes the API your code relies on, not any browser’s implementation.

Verification

  1. Run npx vitest run and confirm it passes with no browser installed.
  2. Import a worker module that references document and confirm the node environment fails it.
  3. Confirm storage isolation — write in one test, read in the next:
1it("a", async () => { await chrome.storage.local.set({ x: 1 }); });
2it("b", async () => { expect(await chrome.storage.local.get("x")).toEqual({}); });

Execution context: Vitest. Test b passing proves the reset runs between tests.

  1. Mutate an object returned by get and confirm a second get returns the original — the copy semantics are working.

FAQ

Should the fake live in the repo or be a dependency?

In the repo. It is small, specific to the APIs you use, and needs to evolve with your code.

Can I share the fake between Vitest and Jest?

Yes — it is plain JavaScript. Only the beforeEach import differs. The Jest-specific setup is in mocking Chrome APIs in Jest.

How do I test alarms.onAlarm handlers?

Emit the event directly — chrome.alarms.onAlarm._emit({ name: "sync" }) — and combine with fake timers only if the handler itself uses time, as in testing alarms and timers deterministically.

Does Vitest’s browser mode help for extensions?

Vitest’s browser mode runs tests in a real browser page, but not as an extension — there is no chrome.runtime there either. It helps for DOM-heavy UI components where jsdom is inaccurate; extension APIs still need the fake. Use Playwright for tests that need the real extension runtime.

How do I assert a listener was registered at all?

The fake’s hasListener works, but a stronger check is to emit the event and assert on the effect. A registered listener that does nothing passes hasListener and fails the behavioural test.

Other Testing, Debugging & Performance Optimization Resources