DOM & Browser API Mocking
Component tests run inside an emulated document, not a real browser, so any code that reaches for window, document, or a native global hits an API that jsdom or happy-dom only partially implements. Mocking those globals is the discipline of substituting deterministic stand-ins for the browser surfaces your code touches — geolocation, storage, media queries, observers, and real-time transports — so render cycles and utility modules behave identically on every machine. This work belongs to the broader practice of Advanced Mocking & Service Isolation Patterns, but where service isolation virtualizes the network, DOM mocking virtualizes the runtime itself. Done well it removes a whole class of flake; done carelessly it masks real layout bugs and bleeds state across parallel workers. This guide gives frontend and full-stack developers, QA engineers, and platform teams a precise, lifecycle-driven approach to intercepting browser globals without sacrificing fidelity.
The reason this discipline earns its own topic area is that browser globals fail differently from the code you write. Your own modules fail loudly — a thrown exception, a red assertion, a stack trace that points at a line. Environmental surfaces fail quietly: a missing IntersectionObserver throws only in the one file that uses it, a shared localStorage mock passes in isolation and corrupts a sibling file three shards away, and a hard-coded getBoundingClientRect turns a genuine layout regression into a green checkmark. The whole point of a deterministic mocking strategy is to convert those quiet, order-dependent, environment-dependent failures into loud, local, reproducible ones — or to eliminate them entirely by never letting non-determinism into the test in the first place.
Architectural Scope & Boundaries
DOM and browser API mocking operates strictly at the unit and component tiers, where code executes against an emulated document rather than a live browser. Its job is to make non-deterministic browser surfaces — geolocation prompts, viewport observers, animation timers, real-time sockets — behave predictably so that a render assertion measures component logic, not environmental noise. Positioned this way, the technique sits below integration testing and well below any end-to-end tier: it never launches a browser process, never resolves a real URL, and never touches a real device sensor. Everything it substitutes is a value or a callback you control from inside the test file, which is precisely what makes the tier fast enough to run on every keystroke and reliable enough to gate a merge.
The boundary that matters most is the line between the runtime and the network. DOM-level mocks own localStorage, sessionStorage, window.matchMedia, document.execCommand, IntersectionObserver, and ResizeObserver. They do not own fetch or XMLHttpRequest; routing HTTP through a DOM mock couples two unrelated concerns and obscures the failure surface. Send request traffic through the patterns in HTTP Request Stubbing Techniques instead, and keep the two registries separate. The practical test for whether something belongs to the DOM registry is simple: does it live on the global object as a synchronous browser capability, or does it cross a wire? A capability that resolves in-process — reading a stored token, matching a media query, observing an element — is yours to mock here. Anything that would in production travel over a socket belongs to the network layer, where request matching, latency simulation, and response shaping are first-class concerns rather than awkward afterthoughts bolted onto a window stub.
Confusing those two registries is the most common architectural mistake teams make, and it is expensive because it hides where a test actually failed. When a single mock stands in for both a browser API and a network call, a red assertion could mean the component mishandled a stored value, mis-parsed a response, or simply hit a mock that was configured for the wrong concern — and the test gives you no way to tell which. Keeping the boundary crisp means every failure has exactly one plausible cause on one side of the line. It also keeps each registry small and legible: the DOM registry enumerates the browser surfaces your components genuinely depend on, and the network registry enumerates the endpoints they call. Neither grows into an undocumented grab-bag that new contributors have to reverse-engineer.
This technique is also explicitly not a substitute for real layout. jsdom and happy-dom have no layout engine, so anything depending on real geometry — getBoundingClientRect returning meaningful values, scroll-driven sticky behaviour, true paint timing — must be verified in a headless browser. For those cases, defer to Playwright component testing, which renders in Chromium and produces real box metrics. Mock the API; never fake the physics. The distinction is worth internalizing as a rule of thumb: you may substitute a browser interface freely, because your code only cares about the contract it exposes, but you may never substitute a browser measurement, because the value your code reads back is exactly the thing under test. A stubbed matchMedia that reports matches: false is honest — your code asked a question and got a definite answer. A stubbed getBoundingClientRect that reports a 200-pixel-tall element is a fabrication, because the height is what the layout engine was supposed to compute, and no emulator computed it.
Prerequisites
The distinction between mocking and polyfilling in that checklist deserves a moment, because teams that skip it end up with an inconsistent suite. A polyfill supplies a faithful implementation of a missing API — a real, spec-shaped IntersectionObserver that genuinely tracks intersections against a fake viewport — and is appropriate when the code under test only needs the API to exist and behave plausibly. A mock supplies a controllable stand-in whose behaviour you drive from the test — an observer whose callback you fire manually at the exact moment you want to assert. Most component suites need the mock, because determinism comes from controlling when callbacks fire, not merely from the API being present. Record the choice per global so a reviewer reading the setup file can tell at a glance whether a given surface is meant to behave like the browser or meant to obey the test.
Step-by-Step Implementation
The implementation builds from a reusable mock factory to lifecycle binding to a centralized setup file, so every test inherits the same deterministic globals. The progression is deliberate: a factory gives you one correct way to install and restore any global, lifecycle hooks tie that installation to a well-defined window, and a setup file guarantees the ordering that makes the whole thing reproducible. Skip any one layer and the cracks show up as order-dependent flake that only appears in CI.
Step 1: Implement a deterministic mock factory
The factory captures the original property descriptor, installs the override, and returns a teardown closure. Capturing the descriptor — not just the value — is what lets you restore non-writable or accessor-backed globals exactly. Many browser globals are defined as getters rather than plain data properties, and a naive window.x = original restore silently converts an accessor into a value, changing its semantics for every subsequent test. The descriptor round-trip is the only restore that is genuinely lossless, and returning a closure rather than exposing a global restore() keeps each installation’s teardown bound to the exact state it captured, which matters when several mocks stack in one file.
// src/test-utils/browser-mocks.ts
export function mockWindowAPI<T extends keyof Window>(
key: T,
implementation: Partial<Window[T]>,
): () => void {
const originalDescriptor = Object.getOwnPropertyDescriptor(window, key);
const originalValue = window[key];
Object.defineProperty(window, key, {
value:
typeof originalValue === 'object' && originalValue !== null
? { ...originalValue, ...implementation }
: implementation,
writable: true,
configurable: true,
enumerable: true,
});
return () => {
if (originalDescriptor) {
Object.defineProperty(window, key, originalDescriptor);
} else {
// @ts-expect-error -- restoring a dynamically added key
delete window[key];
}
};
}
Note the branch on originalDescriptor being undefined. That case — a global that did not exist before the test installed it — is not an edge case you can ignore; it is the common case for IntersectionObserver, ResizeObserver, and any API jsdom omits. If the factory blindly tried to redefine such a key on teardown it would leave a dangling stub that the next file inherits. Deleting the key when there was no prior descriptor is what returns the environment to its true starting state, and it is the single most important line for keeping worker isolation intact.
Step 2: Bind the mock to test lifecycle hooks
Use beforeAll for static mocks that never change between tests, and always invoke the returned teardown in afterAll. Skipping teardown leaks descriptors into sibling workers and produces order-dependent failures. The choice between beforeAll/afterAll and beforeEach/afterEach is a real trade-off, not a style preference: block-scoped hooks install once and are cheaper, but they force every test in the block to share one configuration, so a test that needs a different geolocation result has to re-stub locally. Per-test hooks cost more setup time but give each test a pristine, independently configured global. Reach for the block hooks when the mock is a fixed environmental fact and the per-test hooks when the mocked value is itself part of what a given test is exercising.
// src/__tests__/geolocation.test.ts
import { vi, describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mockWindowAPI } from '../test-utils/browser-mocks';
describe('Geolocation service', () => {
let teardown: () => void;
beforeAll(() => {
teardown = mockWindowAPI('navigator', {
geolocation: {
getCurrentPosition: vi.fn((success) =>
success({
coords: { latitude: 40.7128, longitude: -74.006, accuracy: 10 },
} as GeolocationPosition),
),
watchPosition: vi.fn(),
clearWatch: vi.fn(),
} as Geolocation,
});
});
afterAll(() => teardown());
it('resolves coordinates without a network dependency', () => {
expect(navigator.geolocation.getCurrentPosition).toBeDefined();
});
});
The lifecycle a stub travels through is worth picturing explicitly, because every failure mode in the later sections is really a stub caught in the wrong state. A stub is captured, then installed, then active while the test body runs, then torn down, and finally the global is restored to exactly the descriptor it started with. The contract that keeps the suite honest is that every capture has a matching restore before the worker moves to the next file — no exceptions, no early returns that skip the afterAll.
Step 3: Centralize global stubs in a setup file
Polyfills and stubs must register before any test module imports the code under test. A single setup file guarantees ordering. window.matchMedia is the classic missing global in jsdom — supply it once for the whole suite. Centralizing has a second benefit beyond ordering: it makes the environmental contract auditable. When every global stub lives in one file, a reviewer can read the exact surface your suite fakes in a single scroll, and a new contributor debugging a is not a function error knows precisely where to look. Scattering stubs across dozens of individual test files reintroduces the very unpredictability the factory was meant to remove, because now the set of installed globals depends on which files happened to run and in what order.
// src/test-setup.ts
import { vi, afterEach } from 'vitest';
vi.stubGlobal(
'matchMedia',
vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
afterEach(() => {
vi.clearAllMocks();
});
The afterEach(() => vi.clearAllMocks()) line resets call history between tests without uninstalling the stubs themselves, which is exactly the behaviour you want for a suite-wide global: the matchMedia mock stays present for every test, but each test starts with a clean record of how many times it was called and with what arguments. Distinguish this from vi.restoreAllMocks, which would undo the stub entirely, and from vi.resetAllMocks, which clears implementations too. For suite-level environmental globals you almost always want clearAllMocks between tests and a single unstubAllGlobals at the very end.
Step 4: Wire the setup file into the runner
happy-dom initializes faster and uses less memory; jsdom is more spec-complete. Choose per workload and register the setup file so every test inherits the stubs. The environmentOptions block is where you seed the facts your stubs depend on — most importantly url, which becomes window.location.origin and therefore governs origin-scoped storage and any matchMedia logic that reads the location. Setting it to a stable, realistic value rather than the default http://localhost avoids a subtle class of bug where storage keys or cookie domains differ between local and CI because the emulated origin drifted.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
environmentOptions: {
happyDom: {
url: 'https://app.internal.test',
settings: { disableCSSFileLoading: true },
},
},
},
});
Configuration Reference
| Option | Type | Default | Effect |
|---|---|---|---|
test.environment |
'jsdom' | 'happy-dom' | 'node' |
'node' |
Selects the DOM emulation engine; happy-dom is lighter, jsdom is more spec-complete. |
test.globals |
boolean |
false |
Exposes vi, describe, expect globally so setup files can stub without imports. |
test.setupFiles |
string[] |
[] |
Modules run before each test file; the only safe place to register global stubs. |
environmentOptions.happyDom.url |
string |
'http://localhost' |
Seeds window.location; matters for matchMedia and origin-scoped storage. |
environmentOptions.jsdom.resources |
'usable' | undefined |
undefined |
When 'usable', jsdom fetches external resources — leave unset to keep tests offline. |
pool |
'forks' | 'threads' |
'forks' |
Worker model; global stubs must be re-registered per worker, never shared. |
restoreMocks |
boolean |
false |
Auto-restores spies after each test; pair with explicit vi.unstubAllGlobals() for stubs. |
A note on the pool row, since it is the one most often misread. Whichever worker model you choose, each worker runs in its own JavaScript realm with its own globalThis, so a stub installed in one worker is invisible to the others — which is a feature, not a limitation. It is what allows sharded CI runs to remain isolated. The mistake teams make is hoisting a mock registry into a module-level singleton and importing it everywhere, expecting one shared source of truth; under a forked or threaded pool each worker gets its own copy, and any assumption that they coordinate is false. Register stubs through setupFiles, which runs once per worker per file, and let each realm own its own globals.
Verification & Assertions
Confirm a global is actually intercepted before trusting the assertions that depend on it. The cheapest check is asserting the mock identity, then asserting the behaviour it drives. This two-step matters because a passing behavioural assertion does not, on its own, prove your mock was the thing that produced it — the real jsdom default might have coincidentally returned the same value, in which case your test is green for the wrong reason and will break the moment the environment changes. Asserting vi.isMockFunction first anchors the test to your controlled implementation, so a later regression that bypasses the mock fails loudly instead of drifting.
import { expect, it, vi } from 'vitest';
it('uses the stubbed matchMedia rather than the real one', () => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
expect(mql.matches).toBe(false);
expect(vi.isMockFunction(window.matchMedia)).toBe(true);
});
For observer-driven UI, assert that the callback you control produces the rendered effect. The pattern is to install a shim whose observe method captures the callback, render the component, then invoke that callback with a synthetic entry and assert the DOM changed. This is where mocking beats polyfilling decisively: you are not waiting for a real intersection to happen, you are causing one at a precise line and asserting the consequence on the next. A passing run looks like a clean Vitest summary with no is not a function errors and no unhandled-rejection warnings:
✓ src/__tests__/lazy-image.test.ts (3)
✓ renders placeholder until intersection
✓ swaps to full image after manual trigger
Test Files 1 passed (1)
Tests 3 passed (3)
The decisive signal is determinism: run the file ten times with --repeat and expect identical output every pass. If the output varies — a test that passes nine times and fails once — you have a stub that is not fully controlling its surface, most often a timer or an observer callback firing on the real event loop instead of on your explicit trigger. Treat any variance under --repeat as a defect in the mock, not as acceptable noise, because the whole reason to mock the DOM is to buy determinism, and a mock that leaks non-determinism has failed at its one job. The related discipline of controlling asynchronous fire order is covered in the observer and timer guides linked below.
Edge Cases & Failure Modes
State bleed across parallel workers. A stub installed in one file leaks into the next when teardown is skipped or when a registry is module-scoped and shared. Diagnosis: tests pass in isolation but fail in a full run. Fix: capture and invoke teardown in afterAll, and call vi.unstubAllGlobals() so each worker starts clean. The tell-tale signature is a failure that moves — reorder the files, and a different test fails — because the corruption is positional rather than logical. When you see that pattern, stop debugging the failing test and audit teardown coverage instead; the test that fails is rarely the test that caused the leak.
Mocking a global that has no jsdom default. IntersectionObserver and ResizeObserver are absent in jsdom, so Object.getOwnPropertyDescriptor returns undefined and a naive teardown leaves a dangling stub. Diagnosis: ReferenceError: IntersectionObserver is not defined only in some files. Fix: provide a full shim with a controllable trigger — covered in depth in the guide below on observer mocking. The factory in Step 1 already handles the restore correctly by deleting the key when there was no prior descriptor, so the durable fix is to route these absent globals through the same factory rather than assigning them ad hoc in a single file, which is what creates the dangling state in the first place.
Faking layout-dependent values. Returning a fixed getBoundingClientRect makes scroll or sticky logic pass under jsdom while failing in production. Diagnosis: green unit tests, broken behaviour in the real browser. Fix: move geometry-dependent assertions to a real-browser runner. This is the failure mode that most erodes trust in a suite, because it is silent and directional — the test does not flake, it confidently asserts a lie. Guard against it with a team rule that any assertion reading back a measured dimension is out of scope for the DOM tier and belongs in Playwright component testing, where the numbers are real.
Timer-driven race conditions. requestAnimationFrame, debounce, and CSS-transition callbacks fire on their own schedule. Diagnosis: intermittent failures around animation or debounced input. Fix: control the clock with Time & Date Control Strategies, advance it explicitly after dispatching events, then assert — never await setTimeout in CI. A real wall-clock delay in a test is both slow and unreliable, because the amount of time a callback needs is a function of machine load that varies wildly between a developer laptop and a saturated CI runner. Fake timers convert that variance into a deterministic instruction: advance exactly the number of milliseconds the code expects, and the callback fires at a line you control.
Storage that survives across tests. localStorage and sessionStorage persist within a single worker for the lifetime of the realm, so a value written in one test is readable in the next unless you clear it. Diagnosis: a test asserting an empty store fails only when run after a test that wrote to it. Fix: clear both stores in an afterEach, or mock them with a fresh backing map per test so isolation is structural rather than a cleanup step someone can forget.
Performance & CI Impact
DOM emulation is the dominant cost in a component suite. happy-dom typically initializes two to three times faster than jsdom and holds a smaller heap, which compounds across thousands of files. Prefer it for high-throughput suites and reserve jsdom for tests that exercise spec corners happy-dom does not yet cover. The compounding is the part teams underestimate: a 40-millisecond-per-file environment setup that sounds trivial becomes forty seconds of pure overhead across a thousand files, before a single assertion runs, and it is paid again on every shard. Choosing the lighter engine where you can is often the single highest-leverage change available to a slow component suite, precisely because the cost is fixed per file rather than proportional to how much each test does.
Run tests sharded across workers in CI, but re-register every global stub per worker — never hoist a shared registry into module scope, or one shard’s teardown will race another’s. Seed any nondeterminism (Math.random, crypto.randomUUID) in the setup file so sharded runs are reproducible. Monitor heap growth: if a suite’s resident memory climbs more than roughly 15% per file, a stub is retaining detached DOM nodes — isolate it into its own pool and confirm teardown removes listeners and clears intervals. The payoff is a suite that runs in milliseconds per test and never flakes on environmental drift.
Heap retention is worth a closer look because it is the failure mode that turns a fast suite slow over months without anyone noticing a single bad commit. Every listener you attach to a mocked global, every interval a component starts, and every detached node a stub holds a reference to is memory that cannot be reclaimed until the reference is dropped. In a long shard that runs hundreds of files in one worker, those retentions accumulate until the worker is thrashing the garbage collector or, worse, crashing with an out-of-memory error late in the run — a failure that looks random because it depends on the total volume of work rather than any one test. The discipline that prevents it is the same one that prevents state bleed: exhaustive teardown. If every afterAll restores its descriptor, removes its listeners, and clears its timers, memory returns to baseline between files and the suite’s per-file cost stays flat no matter how many files you add.
In This Topic Area
- Simulating WebSocket connections in Playwright component tests — route real-time frames through Playwright’s
routeWebSocketAPI for deterministic, browser-level socket testing. - Mocking IntersectionObserver and ResizeObserver in jsdom — polyfill the two observer APIs jsdom omits and fire their callbacks deterministically from your Vitest tests.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- HTTP Request Stubbing Techniques — keep
fetchand XHR traffic out of your DOM registry. - Time & Date Control Strategies — tame animation and debounce timers that destabilize DOM tests.
- Playwright component testing — verify layout-dependent behaviour a DOM emulator cannot model.
- Testing Library best practices — assert on rendered output rather than mock internals.
Simulating WebSocket connections in Playwright component tests
Intercept WebSocket frames in Playwright component tests with routeWebSocket so real-time UI updates stay deterministic, leak-free, and reproducible in CI.
Mocking IntersectionObserver and ResizeObserver in jsdom
Polyfill IntersectionObserver and ResizeObserver in jsdom for Vitest, then fire their callbacks deterministically so lazy-loading and responsive UI stay testable.