Stubbing navigator.share in Unit Tests

This guide is part of Testing and Debugging Web Share Flows, which itself is part of Web Share API and Security Contexts.

Unit tests are where share logic is cheapest to cover, because every branch — unsupported, invalid payload, success, dismissal, denial — is one stub configuration away. The pitfalls are small and specific: navigator.share is often not a writable property, a fake rejection built from new Error() sets the wrong name, and a stub left installed by one test quietly invalidates the unsupported-browser test that follows it.

Feature Detection Gate

The code under test should read the environment through an injectable reference so no global mutation is needed at all where you can avoid it.

// can-share.js
export function canUseNativeShare(payload, env = globalThis) {
  if (env.isSecureContext !== true) return false;
  if (typeof env.navigator?.share !== 'function') return false;
  if (typeof env.navigator.canShare !== 'function') return !payload?.files?.length;
  return env.navigator.canShare(payload);
}

With env injected, most tests need no stub at all — they pass a literal object describing the browser they want. Reserve global stubbing for code you cannot refactor, such as a third-party component that reads navigator directly.

Injected Environment vs Global Stub The left path passes a plain environment object straight into the function under test, requiring no cleanup and allowing parallel tests. The right path replaces the real navigator property, which requires defineProperty on install and deletion afterwards to avoid leaking between tests. Inject the environment canUseNativeShare(payload, fakeEnv) · no global mutated · no cleanup needed · tests stay order-independent · preferred for new code Stub the global Object.defineProperty(navigator, …) · needed for code reading navigator · must be configurable to remove · must be deleted in afterEach · leaks silently if forgotten

Step 1 — Install the Stub with defineProperty

A plain assignment is the usual first attempt and the usual first failure. In several runtimes share is a non-writable accessor inherited from the Navigator prototype, so navigator.share = fn throws in strict mode — which is every ES module.

// share-stub.js
export function installShareStub(behaviour = 'resolve') {
  const calls = [];

  Object.defineProperty(navigator, 'share', {
    configurable: true,               // required so afterEach can remove it
    writable: true,
    value: async (payload) => {
      calls.push(payload);
      if (behaviour === 'abort') throw new DOMException('Share canceled', 'AbortError');
      if (behaviour === 'denied') throw new DOMException('Permission denied', 'NotAllowedError');
      if (behaviour === 'data') throw new DOMException('Invalid file payload', 'DataError');
    }
  });

  Object.defineProperty(navigator, 'canShare', {
    configurable: true,
    writable: true,
    value: () => behaviour !== 'invalid'
  });

  return calls;
}

export function removeShareStub() {
  delete navigator.share;
  delete navigator.canShare;
}

configurable: true is the load-bearing flag. Without it the property cannot be deleted afterwards, and every later test in the file inherits a browser that supports sharing — including the one asserting that unsupported browsers fall back.

Step 2 — Reject with Real DOMException Instances

This is the mistake that produces false confidence. new Error('AbortError') has name === 'Error'; a handler branching on err.name === 'AbortError' therefore takes its generic failure path, the test asserts the generic failure path, and both agree — while the browser does something else entirely.

// share-outcome.test.js
import { describe, it, expect, afterEach } from 'vitest';
import { installShareStub, removeShareStub } from './share-stub.js';
import { shareContent } from './share-content.js';

afterEach(removeShareStub);

describe('shareContent', () => {
  it('records a successful share', async () => {
    const calls = installShareStub('resolve');
    const result = await shareContent({ title: 'Doc', url: 'https://example.com/d' });

    expect(result.outcome).toBe('shared');
    expect(calls[0].url).toBe('https://example.com/d');
  });

  it('treats a dismissal as a non-error', async () => {
    installShareStub('abort');
    const result = await shareContent({ title: 'Doc', url: 'https://example.com/d' });

    expect(result.outcome).toBe('dismissed');
    expect(result.userVisibleError).toBeUndefined();
  });

  it('falls back when the browser denies the request', async () => {
    installShareStub('denied');
    const result = await shareContent({ title: 'Doc', url: 'https://example.com/d' });

    expect(result.outcome).toBe('fallback');
    expect(result.reason).toBe('NotAllowedError');
  });
});

DOMException is available in Node 17+ and in every browser-like test environment, so no polyfill is required. The three names above cover the outcomes users actually hit; DataError joins them once you share files, as resolving DataError when sharing files describes.

Step 3 — Prove the Unsupported Branch Is Still Reachable

The most valuable test in the file is the one with no stub at all, and it only means something if cleanup actually happened.

it('falls back when navigator.share does not exist', async () => {
  expect(typeof navigator.share).toBe('undefined');   // guards against stub leakage

  const result = await shareContent({ title: 'Doc', url: 'https://example.com/d' });
  expect(result.outcome).toBe('fallback');
  expect(result.reason).toBe('unsupported');
});

The first assertion is not redundant. It converts “a previous test forgot to clean up” from a mysterious, order-dependent failure into an immediate, self-explanatory one.

Stub Leakage Across Tests Two rows of three tests. In the top row each test installs and removes the stub, so the final unsupported test correctly sees no share function. In the bottom row cleanup is missing, so the stub survives and the unsupported test silently exercises the native path instead. With afterEach cleanup test 1 · install → remove share stubbed, then deleted test 2 · install → remove abort path covered test 3 · no stub share undefined — fallback asserted Cleanup forgotten test 1 · install only stub persists test 2 · install only stub persists test 3 · sees a stub native path runs — false green

Keeping the Stub Honest

A stub is a claim about how the browser behaves, and a stub that drifts from reality is worse than no test at all — it certifies code that will break in production. Three properties are worth defending deliberately.

The first is arity and shape. Real navigator.share accepts a single object and returns a promise that resolves to undefined; a stub that resolves to true invites the code under test to grow a dependency on a value browsers never produce. Resolve with nothing.

The second is rejection fidelity. Browsers reject with a small, stable set of DOMException names, and error.message differs between engines. Assert on name only, and keep the stub’s messages deliberately unlike anything you would match on, so a test that accidentally depends on message text fails immediately.

The third is the absence of validation. A stub does not check user activation, does not enforce payload rules, and does not care whether the page is a secure context. That is exactly why unit tests cannot catch a NotAllowedError caused by an await before the call. Write the gesture rule down as a review checklist item and verify it once per release on hardware, rather than assuming a green suite covers it.

// share-stub.contract.test.js — pins the stub to real browser behaviour
import { it, expect, afterEach } from 'vitest';
import { installShareStub, removeShareStub } from './share-stub.js';

afterEach(removeShareStub);

it('resolves with undefined, like a real browser', async () => {
  installShareStub('resolve');
  await expect(navigator.share({ url: 'https://example.com/' })).resolves.toBeUndefined();
});

it('rejects with a DOMException carrying the documented name', async () => {
  installShareStub('abort');
  await expect(navigator.share({ url: 'https://example.com/' })).rejects.toMatchObject({
    name: 'AbortError'
  });
});

Failure Modes and Recovery

Symptom Cause Minimal fix
TypeError: Cannot set property share share is a non-writable prototype accessor Install with Object.defineProperty
delete navigator.share has no effect Property defined without configurable: true Add the flag when installing
Handler takes the generic error path Rejected with new Error('AbortError') Throw new DOMException(msg, 'AbortError')
Unsupported test passes with a stub installed Missing afterEach cleanup Assert typeof navigator.share === 'undefined' first
canShare is not a function Only share was stubbed Stub both, or guard with typeof in the code under test
Tests pass, phone still fails Stubs ignore user activation Verify the gesture rule on hardware
Only DOMException Sets the Right Name A plain Error constructed with the text AbortError still reports its name as Error, so a handler branching on name takes the wrong path. A DOMException constructed with AbortError as its second argument reports the name browsers actually throw. construction err.name reported new Error('AbortError') "Error" — handler takes the wrong branch { name: 'AbortError' } thrown as an object "AbortError", but not an Error instance new DOMException(msg, 'AbortError') "AbortError" — exactly what browsers throw

Browser and Platform Caveat

jsdom and happy-dom implement neither share nor canShare, so an unstubbed unit environment always looks like an unsupported browser — convenient for fallback coverage, and the reason native-path tests must stub explicitly. isSecureContext is likewise not always true in a DOM shim; if your gate reads it, set it on the fake environment or define it alongside the stub. Note too that older Safari builds expose share without canShare, a real combination worth reproducing: install only the share stub and assert that your code still routes file payloads sensibly rather than throwing a TypeError of its own.

FAQ

Why does navigator.share = fn throw in some test runners?

In several runtimes navigator exposes share as a non-writable accessor on the prototype, so a plain assignment fails silently in sloppy mode and throws in strict mode, which ES modules always use. Install the stub with Object.defineProperty and mark it configurable so it can be removed again.

Is new Error(‘AbortError’) good enough for a rejection test?

No. Its name property is 'Error', not 'AbortError', so any handler that branches on err.name takes the wrong path while the test still passes. Use new DOMException('Share canceled', 'AbortError'), which sets name correctly and matches what browsers actually throw.

Does jsdom provide navigator.share?

No, jsdom and happy-dom implement neither share nor canShare, so an unstubbed test environment always exercises the unsupported branch. That makes the fallback path free to test, and it makes stubbing mandatory whenever you want to cover the native path.