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.
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
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.
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.
Cross-browser variation
- Chrome / Edge: the fake mirrors
chrome.*, includingreturn truefor async message replies. - Firefox: code written against
browser.*needs the fake exposed asglobalThis.browsertoo, with promise-returning listeners supported in_emit. Aliasingbrowserto 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
- Run
npx vitest runand confirm it passes with no browser installed. - Import a worker module that references
documentand confirm thenodeenvironment fails it. - 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.
- Mutate an object returned by
getand confirm a secondgetreturns 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.
Related
- Mocking Chrome APIs in Jest — the Jest version of the same approach.
- Testing message handlers in isolation — structuring code so the fake stays small.
- TypeScript project setup for extensions — typing the tests alongside the code.
- Unit and integration testing — the parent guide.