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.
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.
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 |
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.