Testing, Debugging & Performance Optimization

Unit test, end-to-end automate, debug every MV3 execution context, and profile service worker performance — a practical engineering discipline for Chrome, Firefox, and Safari extensions.

Manifest V3 extensions run across at least three distinct execution contexts — a short-lived service worker, one or more content scripts operating in isolated worlds, and extension pages such as the popup or options page — and none of them can be tested with the same tooling you reach for in a standard web app. The unit and integration testing guide is the right starting point: get your business logic covered under Jest or Vitest first, where feedback loops are fast and the chrome.* namespace can be mocked precisely. Everything built on top of that foundation — end-to-end automation in a real browser, live context debugging, and performance profiling — becomes dramatically less painful once the core logic is correct.

The gotcha that surprises developers coming from web development: jsdom, the DOM emulator used by most Jest setups, does not implement the chrome.* namespace at all. Every API call — chrome.storage.get, chrome.runtime.sendMessage, chrome.tabs.query — throws or returns undefined unless you supply a mock. That gap must be bridged before a single meaningful test can run.

MV3 extension testing and debugging discipline overviewFour practice areas — unit/integration testing, end-to-end automation, context debugging, and performance profiling — mapped to the three MV3 execution contexts they target.Service workerbackground · event-drivenContent scriptisolated world · DOMPopup / Optionsextension page · UIUnit / IntegrationJest · Vitest · chrome mocksEnd-to-end automationPlaywright · real browserContext debuggingDevTools · chrome://swPerformance profilingcold-start · CPU · memoryService workerOther contexts

Mocking the chrome.* namespace

The single highest-leverage thing you can do before writing your first test is establish a credible chrome.* stub. Without it, every module that imports an API surface throws at require-time. There are three approaches, each with a different fidelity-versus-effort profile.

jest-webextension-mock installs a pre-built stub on global.chrome and covers the most common API shapes. Add it to setupFilesAfterFramework and most call sites resolve without extra wiring — but the stubs return undefined by default, so you still need per-test overrides for meaningful assertions.

sinon-chrome takes the same approach with Sinon spies already attached, which makes assertion syntax more natural but ties you to Sinon’s ecosystem.

Hand-rolled stubs are the most verbose but give you complete type-safety through @types/chrome and zero third-party surface area. They are the right choice for complex message-passing scenarios where mock sequencing matters.

 1// jest.setup.ts — minimal hand-rolled chrome stub
 2import type { Chrome } from "jest-webextension-mock"; // types only
 3
 4const storageMock: Record<string, unknown> = {};
 5
 6global.chrome = {
 7  storage: {
 8    local: {
 9      get: jest.fn(async (keys) => {
10        if (typeof keys === "string") return { [keys]: storageMock[keys] };
11        return Object.fromEntries(
12          (Array.isArray(keys) ? keys : Object.keys(keys)).map((k) => [k, storageMock[k]])
13        );
14      }),
15      set: jest.fn(async (items) => { Object.assign(storageMock, items); }),
16      remove: jest.fn(async (keys) => {
17        (Array.isArray(keys) ? keys : [keys]).forEach((k) => delete storageMock[k]);
18      }),
19    },
20  },
21  runtime: {
22    sendMessage: jest.fn(),
23    onMessage: { addListener: jest.fn(), removeListener: jest.fn() },
24    lastError: undefined,
25  },
26} as unknown as typeof chrome;
27
28beforeEach(() => {
29  Object.keys(storageMock).forEach((k) => delete storageMock[k]);
30  jest.clearAllMocks();
31});

Execution context: Runs in the Jest worker process under Node.js via setupFilesAfterFramework. No browser globals are available; jsdom is present only if configured as testEnvironment. This file must be listed under setupFilesAfterFramework (not setupFiles) so jest.fn() is already initialised.

End-to-end testing with a real browser

Unit tests cannot catch the failure modes that emerge from MV3’s process model — a service worker that terminates mid-test, a content script that misses an event because the worker restarted, or a popup that renders stale data because chrome.storage.onChanged fired in the wrong order. End-to-end tests load your actual built extension into a headed or headless browser instance.

Playwright is currently the most capable option for MV3 end-to-end testing. Its Chromium build supports loading an unpacked extension via --load-extension, and the chrome.runtime bridge remains active throughout the test.

 1// e2e/setup.ts — load an unpacked extension in Playwright
 2import { chromium, type BrowserContext } from "@playwright/test";
 3import path from "path";
 4
 5export async function launchWithExtension(): Promise<BrowserContext> {
 6  const extensionPath = path.resolve(__dirname, "../dist");
 7  const context = await chromium.launchPersistentContext("", {
 8    headless: false,           // MV3 service workers require headed mode in Playwright ≤ 1.44
 9    args: [
10      `--disable-extensions-except=${extensionPath}`,
11      `--load-extension=${extensionPath}`,
12    ],
13  });
14  return context;
15}

Execution context: Runs in the Playwright test runner process (Node.js). The launched browser is a real Chromium instance; the extension service worker starts in its own renderer process. Note: fully headless mode (headless: true) suppresses service worker startup in Playwright versions before 1.45 — always verify your Playwright version before enabling headless in CI.

Debugging extension contexts

Each MV3 context has a separate DevTools session. Missing this costs hours.

  • Service worker: chrome://extensions → click the “service worker” link next to your extension ID. This opens a dedicated DevTools window whose Console tab shows service-worker logs and whose Sources tab lets you set breakpoints. The worker can be force-terminated from this panel to test cold-start behaviour.
  • Content script: open DevTools on the target page (F12), switch the context dropdown (top-left of the Console panel) from “top” to your extension’s content script world. Breakpoints in Sources work normally.
  • Popup: right-click the extension icon → “Inspect popup”. The popup must remain open; closing it terminates the DevTools session.
  • Options page: navigate to the options page URL (chrome-extension://<id>/options.html) directly and open DevTools normally.
1// sw.js — structured logging survives context switches
2const log = (tag: string, data: unknown) =>
3  console.log(JSON.stringify({ tag, data, ts: Date.now() }));
4
5chrome.runtime.onInstalled.addListener(() => log("install", { reason: "install" }));
6chrome.storage.onChanged.addListener((changes, area) =>
7  log("storage.changed", { area, keys: Object.keys(changes) })
8);

Execution context: Service worker background thread. console.log output appears in the dedicated service-worker DevTools panel at chrome://extensions. Structured JSON makes logs grep-able in CI and parseable by log aggregators. Firefox DevTools shows service-worker logs under about:debugging → This Firefox → Inspect.

Performance profiling

MV3 service workers incur a cold-start penalty every time the browser terminates and restarts them — typically 50–200 ms in Chrome, but up to 600 ms on lower-end hardware. That latency appears as a delay on the first user action after the popup opens or the first tab navigation that triggers an onMessage listener.

Profile it using the Performance panel in the service-worker DevTools window: click Record, trigger the action that wakes the worker, stop recording. Look for a long initial task between the “service worker activated” marker and your first listener firing. The dominant causes are large synchronous module graphs at import time, and synchronous chrome.storage reads in top-level onInstalled/onStartup handlers.

 1// sw.js — defer heavy initialisation behind a flag
 2let initialised = false;
 3
 4async function ensureInitialised() {
 5  if (initialised) return;
 6  initialised = true;
 7  // load rules, decrypt keys, warm caches — deferred from top level
 8  const { config } = await chrome.storage.local.get("config");
 9  await applyConfig(config ?? {});
10}
11
12chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
13  ensureInitialised().then(() => {
14    // handle msg
15    sendResponse({ ok: true });
16  });
17  return true;
18});

Execution context: Service worker. The initialised flag lives in module scope and survives for the lifetime of the current worker instance — it resets to false on the next cold start, which is the correct behaviour. Firefox service workers follow the same event-driven lifecycle; Safari’s are subject to stricter OS-level suspension and may restart more frequently on mobile.

CI & reproducibility

Tests that pass on a developer’s laptop and fail in CI almost always share one root cause: the CI environment loads a different browser binary, uses a different extension build, or omits environment variables that control feature flags.

Pin the exact browser version used for end-to-end tests. Playwright’s npx playwright install chromium downloads a specific Chromium revision tracked in the package lockfile — commit both package.json and the lockfile. For unit tests, Jest’s --runInBand flag eliminates flakiness from parallel worker races when tests share a module-scope mock state (the hand-rolled stub above clears its map in beforeEach, so parallel workers are safe, but some jest-webextension-mock patterns are not).

 1// package.json — reproducible test pipeline
 2{
 3  "scripts": {
 4    "test:unit": "jest --coverage",
 5    "test:e2e": "playwright test",
 6    "test:ci": "jest --ci --forceExit && playwright test --reporter=github"
 7  },
 8  "jest": {
 9    "testEnvironment": "node",          // NOT jsdom — extension logic rarely needs DOM
10    "setupFilesAfterFramework": ["./jest.setup.ts"],
11    "transform": { "^.+\\.tsx?$": ["ts-jest", { "isolatedModules": true }] }
12  }
13}

Execution context: Build/CI environment (Node.js). "testEnvironment": "node" is intentional for service-worker logic; switch to "jsdom" only for modules that touch the DOM directly. The --forceExit flag prevents Jest from hanging when a module holds an open handle (common when mocking chrome.alarms).

Cross-browser testing tooling matrix

CapabilityChrome / EdgeFirefoxSafari
Load unpacked extension--load-extension CLI flag--load-extension or web-ext runXcode Simulator only; no CLI flag
Playwright supportFull; headed + headlessFull via firefox channelwebkit channel; no extension loading
web-ext CLISupported (3rd-party)First-class; web-ext signNot supported
Service worker DevToolschrome://extensions → SW linkabout:debugging → InspectSafari Web Extension inspector (Develop menu)
Content script DevToolsContext switcher in page DevToolsContext switcher in page DevToolsContext switcher in page DevTools
Performance panelFull SW profilingFull (about:profiling)Limited; no SW-specific timeline
chrome.runtime in testsMockable with any jest stubbrowser.* namespace; use webextension-polyfillbrowser.* namespace; same polyfill

What this section covers

The unit and integration testing guide covers mocking the chrome.* namespace in Jest and Vitest, structuring test files for message handlers and storage modules, and the limits of jsdom for extension testing. The end-to-end testing and automation guide walks through loading an unpacked extension in Playwright, writing assertions against real extension behaviour, and integrating browser tests into CI. Debugging extension contexts covers the DevTools workflow for each context type, common startup failures in the service worker, and techniques for reproducing intermittent eviction bugs. Performance profiling and optimization targets cold-start latency, memory consumption, and the per-request overhead of declarative rules versus scripting injection.