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.

DOM and browser API mocking boundary A test module routes window, storage, and observer access through a mock registry, while fetch and XHR traffic is routed to a separate network interceptor, keeping DOM and network boundaries distinct. Test Module renders component Mock Registry window / storage Observer Shims IO / RO / WS Network Interceptor fetch / XHR Assertions deterministic

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.

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.

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.

Prerequisites

  • jsdom or happy-dom, set via the environment field.
  • @testing-library/react (or your framework’s binding) for render assertions, aligned with Testing Library best practices.
  • setupFiles entry where all global stubs register before any test module imports.
  • vi.stubGlobal / vi.unstubAllGlobals lifecycle (or jest.spyOn equivalents).

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.

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.

// 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];
    }
  };
}

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.

// 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();
  });
});

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.

// 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();
});

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.

// 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 Table

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.

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.

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

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.

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.

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.

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.

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.

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.

In-Depth Guides