Injecting chrome APIs for Testability

Make MV3 extension code testable by passing chrome APIs in rather than reaching for globals: thin ports for storage, tabs and messaging, in-memory fakes, wiring at the entry point, avoiding over-abstraction, and keeping listeners registered at top level.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The sync module calls chrome.storage.local.get, chrome.alarms.create, chrome.runtime.sendMessage and fetch from deep inside its functions. Testing it means stubbing the entire chrome global — a large mock that drifts from the real API, leaks between tests, and makes each test file start with forty lines of setup. The alternative is ordinary dependency injection: business logic receives the small set of capabilities it needs as parameters, the service worker’s entry file passes the real chrome APIs, and tests pass simple in-memory fakes. Done lightly, it makes extension code dramatically easier to test without turning it into an enterprise framework. This guide shows that light version. It belongs to unit and integration testing.

The idea in extension terms

Separate code into two layers. The shell is the entry file of each context (sw.js, popup.js, content.js): it registers listeners at the top level, constructs dependencies from real chrome APIs, and calls into the core. The core is everything else: functions and classes that take their dependencies — a storage port, a clock, a messenger, fetch — as arguments. Ports are narrow interfaces shaped like what the core needs, not like the whole chrome API: getItems(), saveItems(), now(), schedule(name, when). Real implementations wrap chrome; fakes keep state in plain objects. Tests exercise the core with fakes and never touch globals.

Shell and coreThe service worker shell registers top-level listeners and builds real ports from chrome APIs; the core sync logic receives ports as parameters; tests build the same core with in-memory fakes.sw.js (shell)listeners at top levelReal portswrap chrome.*createSync(ports)corein teststest fileno globalsFake portsin-memorycreateSync(fakes)same core
Real chrome at the edge, plain parameters inside.

Step-by-step: injectable extension code

1. Define ports by what the core needs

1// core/ports.ts
2export interface Store {
3  get<T>(key: string, fallback: T): Promise<T>;
4  set(values: Record<string, unknown>): Promise<void>;
5}
6export interface Scheduler { schedule(name: string, whenMs: number): Promise<void>; clear(name: string): Promise<void>; }
7export interface Clock { now(): number; }
8export interface Api { fetch(path: string, init?: RequestInit): Promise<Response>; }

Execution context: a shared core module. Each port has a handful of methods named for the core’s needs. That is narrower than chrome.storage (no areas, no onChanged, no quotas) — which is the point: fakes are trivial and the core cannot accidentally depend on API details. Add methods only when the core needs them.

2. Implement real ports in one place

 1// shell/real-ports.ts
 2import type { Store, Scheduler, Clock, Api } from "../core/ports";
 3export const realStore = (area = chrome.storage.local): Store => ({
 4  async get(key, fallback) { const r = await area.get({ [key]: fallback }); return r[key]; },
 5  async set(values) { await area.set(values); },
 6});
 7export const realScheduler: Scheduler = {
 8  schedule: (name, when) => chrome.alarms.create(name, { when }),
 9  clear: async (name) => { await chrome.alarms.clear(name); },
10};
11export const realClock: Clock = { now: () => Date.now() };
12export const realApi = (base: string): Api => ({ fetch: (p, i) => fetch(base + p, { ...i, credentials: "omit" }) });

Execution context: the shell layer. All direct chrome usage for the core lives here, so cross-browser differences (browser vs chrome, callback vs promise) are handled once. See handling promise and callback API differences.

Global mocks versus injected portsStubbing the chrome global and injecting narrow ports compared on test setup, isolation between tests, drift from the real API, readability and cross-browser handling.AspectGlobal chrome mockInjected portsTest setupLarge shared mockFew lines of fakesIsolationLeaks via globalsPer test instanceAPI driftMock divergesPort is yoursCross-browserSpread through codeIn real ports onlyExtra codeNoneThin adapters
Ports trade a little wiring for much simpler tests.

3. Write the core against ports

 1// core/sync.ts
 2import type { Store, Scheduler, Clock, Api } from "./ports";
 3export function createSync({ store, scheduler, clock, api }: { store: Store; scheduler: Scheduler; clock: Clock; api: Api }) {
 4  return {
 5    async run() {
 6      const queue = await store.get<Item[]>("outbox", []);
 7      if (!queue.length) return { sent: 0 };
 8      const res = await api.fetch("/v1/items", { method: "POST", body: JSON.stringify(queue) });
 9      if (!res.ok) {
10        const attempt = (await store.get("syncAttempt", 0)) + 1;
11        await store.set({ syncAttempt: attempt });
12        await scheduler.schedule("sync-retry", clock.now() + Math.min(2 ** attempt, 60) * 60_000);
13        return { sent: 0, retry: attempt };
14      }
15      await store.set({ outbox: [], syncAttempt: 0, lastSyncAt: clock.now() });
16      return { sent: queue.length };
17    },
18  };
19}

Execution context: the core. No chrome, no Date.now(), no global fetch — every effect goes through a port. The logic (retry with exponential backoff capped at an hour) is now testable deterministically. See retrying failed background jobs with backoff.

4. Wire real ports in the shell, keeping listeners top level

1// sw.ts (shell)
2import { createSync } from "./core/sync";
3import { realStore, realScheduler, realClock, realApi } from "./shell/real-ports";
4
5const sync = createSync({ store: realStore(), scheduler: realScheduler, clock: realClock, api: realApi(API_BASE) });
6
7chrome.alarms.onAlarm.addListener(({ name }) => { if (name === "sync" || name === "sync-retry") sync.run(); });
8chrome.runtime.onMessage.addListener((m) => { if (m.type === "sync-now") sync.run(); });

Execution context: the service worker entry. Construction is synchronous and cheap, and listeners are still registered synchronously at the top level, so MV3 event delivery after a worker restart is unaffected. Do not hide listener registration inside the core or behind async setup. See rebuilding in-memory state after termination.

Testing retry logic with fakesThe test builds the sync core with a fake store containing two outbox items, a fake API returning 500, a fake scheduler that records calls and a fixed clock; it runs sync and asserts the attempt counter and the scheduled retry time.TestSync coreFakesstore.outbox = [a, b]; api → 500sync.run()store.set({syncAttempt: 1})schedule('sync-retry', now + 2 min)assert recorded calls
No globals, no timers, no network — just assertions.

5. Write fakes as plain objects

1// tests/fakes.ts
2export function fakeStore(initial: Record<string, unknown> = {}) {
3  const data = { ...initial };
4  return { data, get: async (k: string, f: unknown) => (k in data ? data[k] : f), set: async (v: Record<string, unknown>) => { Object.assign(data, v); } };
5}
6export function fakeScheduler() { const calls: [string, number][] = []; return { calls, schedule: async (n: string, w: number) => { calls.push([n, w]); }, clear: async () => {} }; }
7export const fixedClock = (t: number) => ({ now: () => t });
8export const fakeApi = (status: number) => ({ fetch: async () => new Response("{}", { status }) });
1// tests/sync.test.ts
2test("schedules a backoff retry on server error", async () => {
3  const store = fakeStore({ outbox: [{ id: 1 }, { id: 2 }] });
4  const scheduler = fakeScheduler();
5  const sync = createSync({ store, scheduler, clock: fixedClock(1_000_000), api: fakeApi(500) });
6  expect(await sync.run()).toEqual({ sent: 0, retry: 1 });
7  expect(store.data.syncAttempt).toBe(1);
8  expect(scheduler.calls).toEqual([["sync-retry", 1_000_000 + 2 * 60_000]]);
9});

Execution context: Vitest or Jest in Node. Fakes are a few lines each, hold state you can inspect, and are created fresh per test. Node 18+ provides Response, so API fakes return real response objects. Compare with mocking chrome APIs in Jest, which remains useful for shell-level tests.

6. Test the real ports once, against the real API

Real ports are thin, but they are where API misuse hides. Cover them with a few integration tests in a real browser (Playwright evaluating in the service worker) or against a well-maintained API fake, rather than in every core test. See driving service worker state from a test.

7. Don’t over-abstract

Inject what makes tests hard: storage, time, scheduling, network, messaging, tabs. Leave pure utilities, chrome.i18n.getMessage in UI code, and one-line glue alone. If a port has more methods than the core uses, trim it. The goal is testable logic, not an abstraction for every API.

Common mistakes

  • Ports shaped like the whole chrome API. Fakes become as big as global mocks.
  • Hiding listener registration in the core. Breaks MV3 event delivery after restarts.
  • Calling Date.now() in the core. Time-dependent tests flake; inject a clock.
  • Sharing fakes between tests. State leaks; create per test.
  • Injecting everything. Unnecessary indirection.

Cross-browser variation

  • Chrome / Edge: real ports wrap chrome.* promises.
  • Firefox: real ports can wrap browser.*; the core is unchanged.
  • Safari: same; differences in API support stay inside real ports, where they can be feature-detected.

Verification

  1. Run the core test suite with the chrome global undefined and confirm it passes.
  2. Confirm no core module imports from the shell or references chrome.
  3. Stop the worker and confirm alarm-driven sync still runs (listeners top level).
  4. Measure test suite time; core tests should run in milliseconds each.

FAQ

Is this compatible with WXT or Plasmo?

Yes. They structure entry points; the core stays framework-independent.

Should content scripts use ports too?

Yes, for messaging and storage — see testing content scripts with JSDOM.

Do I need a DI container?

No. Passing objects to factory functions is enough for extensions.

How do I enforce the boundary?

Add an ESLint no-restricted-globals rule for chrome and browser in the core folder, so accidental direct calls fail linting.

Other Testing, Debugging & Performance Optimization Resources