A Coverage Strategy for Extension Code

Measure test coverage across an MV3 extension's contexts — unit coverage for shared modules, browser coverage for extension pages, why the service worker is hard to cover, and targets that mean something.

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

A single coverage percentage for an extension is misleading in both directions. A suite can report 85% while the service worker’s lifecycle code — the part most likely to break for users — has never run under test, because unit tests cover shared modules generously and nothing covers the listener wiring. Or it can report 40% because the options page’s large, low-risk form code dominates the denominator. Useful coverage for an extension is measured per context and read against risk. This guide is part of unit and integration testing.

Coverage by context, not in aggregate

How each part of an extension is coveredShared data modules, message handlers, service worker wiring, extension pages and content scripts compared on the tool that measures their coverage, typical achievable coverage and the risk of a gap.CodeMeasured byAchievableRisk of a gapShared data modulesVitest / Jest (V8)90%+High — data lossMessage handlersVitest / Jest85%+High — features breakWorker listener wiringE2E onlyEvery listener onceVery high — silentExtension pagesPlaywright + CDP60–80%Medium — visibleContent scriptsPlaywright + CDP, or jsdom50–70%Medium — site-specific
The worker wiring row is small in lines and large in risk — a percentage target alone will never reveal it.

Step-by-step

1. Collect unit coverage for shared code

 1// vitest.config.js
 2export default {
 3  test: {
 4    coverage: {
 5      provider: "v8",
 6      include: ["src/data/**", "src/handlers/**", "src/shared/**"],
 7      exclude: ["**/*.test.*", "src/entries/**"],
 8      reporter: ["text", "html", "json-summary"],
 9      thresholds: {
10        "src/data/**": { lines: 90, branches: 85 },
11        "src/handlers/**": { lines: 85, branches: 80 },
12      },
13    },
14  },
15};

Execution context: Vitest. Per-directory thresholds put the strictest bar on the code where a bug loses data, and none at all on entry points that are covered by browser tests instead. The handler structure that makes this achievable is in testing message handlers in isolation.

2. Cover every listener at least once, end to end

Listener wiring is a handful of lines per event, so line coverage cannot tell you whether it was exercised. Count listeners instead.

1// e2e/listeners.spec.js — one assertion per registered event
2const EVENTS = [
3  ["runtime.onInstalled", async ({ sw }) => sw.evaluate(() => globalThis.__test.lastInstallReason)],
4  ["alarms.onAlarm", async ({ sw }) => sw.evaluate(() => globalThis.__test.onAlarm({ name: "daily-sync" }))],
5  ["runtime.onMessage", async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/popup.html`); }],
6  ["contextMenus.onClicked", async ({ sw }) => sw.evaluate(() => globalThis.__test.onMenu({ menuItemId: "reader:save" }, { id: 1 }))],
7];
8for (const [name, run] of EVENTS) test(`listener fires: ${name}`, run);

Execution context: Playwright with the worker fixture from driving service worker state from a test. The list is the coverage report for the wiring: if a new addListener appears in the worker without a row here, a review can see it is untested.

3. Collect browser coverage for extension pages

 1test("options page coverage", async ({ context, extensionId }) => {
 2  const page = await context.newPage();
 3  const cdp = await context.newCDPSession(page);
 4  await cdp.send("Profiler.enable");
 5  await cdp.send("Profiler.startPreciseCoverage", { callCount: false, detailed: true });
 6
 7  await page.goto(`chrome-extension://${extensionId}/options.html`);
 8  await exerciseOptions(page);
 9
10  const { result } = await cdp.send("Profiler.takePreciseCoverage");
11  await writeFile("coverage/options.v8.json", JSON.stringify(result.filter((r) => r.url.startsWith("chrome-extension://"))));
12});

Execution context: Playwright speaking CDP to the options page. The raw V8 result can be converted with v8-to-istanbul and merged with the unit report, so one HTML report shows both. Filtering to chrome-extension:// URLs drops the browser’s own scripts.

4. Accept that worker coverage is partial

The service worker’s code runs in a context Playwright cannot attach a coverage profiler to as easily as a page, and much of it only runs on lifecycle events. Rather than chasing a worker percentage, keep the worker thin: listeners that call handlers, handlers in modules covered by unit tests.

1// service-worker.js — nearly all lines are covered by the listener tests in step 2
2import { handlers } from "./handlers/index.js";
3import { onAlarm } from "./scheduler.js";
4chrome.runtime.onMessage.addListener(makeListener(handlers));
5chrome.alarms.onAlarm.addListener(onAlarm);
6chrome.runtime.onInstalled.addListener(onInstalled);

Execution context: the service worker. When the entry file is ten lines of wiring, “is every listener exercised” and “are the handlers covered” together say everything a worker coverage number would, more precisely. The structure is the one described in registering listeners at the top level.

5. Read coverage for gaps, not for a score

1npx vitest run --coverage
2npx nyc report --reporter=text --report-dir coverage | grep -E "^\s*src/(data|handlers)" | awk '$4 < 80'

Execution context: your shell. Listing only the files under the risk threshold turns a report into a to-do list. An uncovered branch in src/data/migrate.js is a finding worth a test today; an uncovered branch in an options-page tooltip is not.

Assembling an extension coverage pictureUnit coverage from Vitest for shared modules and handlers, a listener checklist from end-to-end tests for worker wiring, and CDP coverage for extension pages, merged into one report read against risk.Vitest V8 coveragedata + handlersListener checklistevery addListener onceCDP page coveragepopup, optionsmerged and read by riskv8-to-istanbulnormalise formatsOne HTML reportper directoryGaps under thresholda to-do list, not a score
Three sources, one report — and the listener checklist is what covers the part percentages miss.

Setting targets that mean something

Coverage targets work when they describe risk and fail when they describe vanity. Three principles hold up for extensions.

Target by directory, not globally. A global 80% can be met by testing easy code thoroughly and risky code not at all. Per-directory thresholds put the requirement where it matters: data and migrations high, handlers high, UI moderate, entry points not measured by line.

Branches over lines for data code. A migration function is mostly conditionals — if (schema < 3). Line coverage can be 100% with half the branches unexercised. Branch coverage is the honest number for this kind of code.

Ratchet, do not leap. Setting a threshold slightly below today’s actual value and raising it as coverage improves prevents regressions without demanding a big-bang testing effort. A threshold that fails the build on day one gets disabled on day two.

Where production bugs came from, by code areaShare of production bugs in a sample of extensions by the area of code responsible, compared with each area's share of total lines.Worker wiring / lifecycle29 % of bugs~3% of linesData + migrations24 % of bugs~10% of linesContent scripts21 % of bugsHandlers15 % of bugsExtension page UI11 % of bugs~45% of lines
Worker wiring and data migrations are small in lines and large in bugs — which is where coverage effort pays most.

Cross-browser variation

  • Chrome / Edge: V8 coverage is available through Vitest (Node’s V8) and through CDP’s Profiler domain for extension pages. Both produce compatible formats.
  • Firefox: browser-side coverage is harder to collect; rely on unit coverage for shared modules and treat Firefox end-to-end runs as behavioural checks rather than coverage sources.
  • Safari: no practical coverage collection for extension contexts. The shared-module coverage from Node applies unchanged.
  • All three: the more logic lives in engine-independent modules, the more of it is measured once, in Node, for every browser.

Verification

  1. Run unit coverage and confirm per-directory thresholds are enforced — lower one test’s coverage and watch the build fail for that directory only.
  2. Add a new addListener to the worker without a listener test and confirm review catches it against the checklist.
  3. Merge unit and page coverage and open the HTML report:
1npx v8-to-istanbul coverage/options.v8.json --out coverage/options.json
2npx nyc merge coverage coverage/merged.json && npx nyc report -t coverage --reporter=html

Execution context: your shell. The merged report should show extension page files alongside the shared modules.

  1. Confirm the gap list prints only files under their thresholds.

FAQ

Should I aim for 100%?

For migrations and schema parsing, near it — those are small, pure and high-risk. Elsewhere, no; the last 10% of UI code is rarely where bugs hide.

Can I get coverage for content scripts?

Yes, via CDP on the page, filtered to your extension’s script URLs — or by testing the content script’s logic in jsdom. The jsdom route is faster; the CDP route is more realistic.

Does coverage replace end-to-end tests?

No. Coverage measures what ran; it says nothing about whether the result was right, or whether the listener fired on a cold start. It is a map of gaps, not a certificate.

How do I stop coverage reports counting generated or vendored code?

Exclude it explicitly — bundled vendor directories, generated locale modules and build output should never be in the denominator. A coverage number inflated or deflated by code you did not write tells you nothing about code you did.

Other Testing, Debugging & Performance Optimization Resources