DOM & Browser API Mocking

Define architectural boundaries for intercepting window, document, and native browser globals before they propagate into component render cycles or utility modules. Align test isolation strategy with foundational principles in Advanced Mocking & Service Isolation Patterns to prevent cross-environment leakage and guarantee deterministic execution across local, CI, and ephemeral preview deployments. Browser API mocking is not a blanket replacement for real execution; it is a surgical isolation mechanism that requires strict lifecycle management, explicit teardown sequences, and environment routing rules.

Framework Integration & Runtime Interception

Map your test runner’s lifecycle hooks directly to mock registration and teardown. Jest and Vitest share similar execution models, while Playwright requires explicit browser context isolation. The following pattern uses Object.defineProperty for immutable globals and Proxy for dynamic API shimming, ensuring module-level factory resets between suites.

Step 1: Implement a deterministic mock factory

// 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: { ...originalValue, ...implementation },
 writable: true,
 configurable: true,
 enumerable: true,
 });

 // Return teardown function
 return () => {
 if (originalDescriptor) {
 Object.defineProperty(window, key, originalDescriptor);
 } else {
 (window[key] as any) = originalValue;
 }
 };
}

Step 2: Bind to test lifecycle hooks

// src/__tests__/geolocation.test.ts
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.0060 } })
 ),
 watchPosition: vi.fn(),
 clearWatch: vi.fn(),
 },
 });
 });

 afterAll(() => {
 teardown();
 });

 it('resolves coordinates without network dependency', () => {
 expect(navigator.geolocation.getCurrentPosition).toBeDefined();
 // Execute component logic...
 });
});

Execution Guidance:

  • Never mock globals inside beforeEach unless the implementation changes per test. Use beforeAll for static mocks to reduce overhead.
  • Always capture the teardown closure and invoke it in afterAll or afterEach. Failure to restore descriptors causes state bleed across parallel workers.
  • For Proxy-based shimming (e.g., window.IntersectionObserver), wrap the native constructor and intercept observe/unobserve calls. Reset the internal observer registry on teardown.

Configuration Steps & Environment Setup

Select the DOM emulation engine based on your execution constraints. jsdom provides comprehensive spec compliance but carries higher memory overhead. happy-dom offers faster initialization and lighter footprint, making it preferable for high-throughput CI pipelines.

Step 1: Initialize environment routing

// 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, // Reduces parse latency
 },
 },
 },
 },
});

Step 2: Configure polyfill injection order Polyfills must be injected before test execution begins. Misordered imports cause native APIs to override mocks or vice versa.

// src/test-setup.ts
import 'whatwg-fetch'; // Polyfill fetch before network stubbing
import { setupGlobalMocks } from './test-utils/global-registry';

setupGlobalMocks();

Step 3: Differentiate DOM vs. Network boundaries Do not conflate DOM manipulation with HTTP interception. Use DOM-level mocks for localStorage, window.matchMedia, and document.execCommand. Route all fetch/XMLHttpRequest traffic through dedicated network interceptors. Refer to HTTP Request Stubbing Techniques for boundary selection and request lifecycle isolation. Mixing DOM mocks with network stubs in the same module increases coupling and obscures failure surfaces.

CI Pipeline Rules & Execution Constraints

Deterministic execution in CI requires explicit worker isolation, resource quotas, and cache invalidation strategies. Parallel execution amplifies state bleed if mock registries are not scoped per worker.

Step 1: Define matrix execution flags

# .github/workflows/test-matrix.yml
name: Test Execution Matrix
on: [push]
jobs:
 test:
 runs-on: ubuntu-latest
 strategy:
 matrix:
 shard: [1, 2, 3, 4]
 steps:
 - uses: actions/checkout@v4
 - uses: actions/setup-node@v4
 with:
 node-version: 20
 cache: 'npm'
 - run: npm ci
 - run: |
 npx vitest run \
 --shard=${{ matrix.shard }}/4 \
 --maxWorkers=2 \
 --testTimeout=10000 \
 --bail=1 \
 --reporter=verbose
 env:
 NODE_OPTIONS: "--max-old-space-size=4096"

Step 2: Enforce deterministic seeding & cache rules

  • Seed Math.random() and crypto.randomUUID() at the worker level using --seed or a custom Math override.
  • Invalidate compiled mock registries on every PR merge by appending --no-cache to the test runner.
  • Set strict timeout thresholds (--testTimeout=10000). If a mock fails to resolve within the threshold, treat it as a hard failure, not a flake.
  • Implement retry guards exclusively for verified DOM race conditions (e.g., ResizeObserver callbacks). Do not retry mock resolution failures; they indicate architectural coupling or incorrect teardown.

Debugging Workflows & Flaky Test Mitigation

Silent mock bypasses and unhandled promise rejections are the primary sources of flaky DOM tests. Deploy explicit tracing and snapshot diffing to isolate execution drift.

Step 1: Implement mock call tracing

// src/test-utils/mock-tracer.ts
export function traceMock<T extends (...args: any[]) => any>(
 name: string,
 fn: T
): T {
 return ((...args: any[]) => {
 console.debug(`[MOCK_TRACE] ${name} called with:`, JSON.stringify(args));
 return fn(...args);
 }) as T;
}

Step 2: Intercept unhandled rejections & DOM snapshots

// vitest.config.ts (extended)
import { defineConfig } from 'vitest/config';

export default defineConfig({
 test: {
 setupFiles: ['./src/test-setup.ts'],
 onConsoleLog: (log, type) => {
 if (type === 'stderr' && log.includes('Unhandled')) {
 throw new Error(`Unhandled rejection intercepted: ${log}`);
 }
 return true;
 },
 snapshotFormat: {
 printBasicPrototype: false,
 escapeString: true,
 },
 },
});

Step 3: Temporal control for animation & debounce Race conditions in requestAnimationFrame, setTimeout, and CSS transitions require deterministic clock manipulation. Integrate temporal control via Time & Date Control Strategies to isolate animation-driven race conditions and debounce failures. Advance the clock explicitly after triggering DOM events, then assert state changes. Never rely on await new Promise(r => setTimeout(r, 100)) in CI; it introduces non-deterministic latency.

Reliability Tradeoffs & Performance Impact

Full DOM emulation introduces measurable memory overhead and execution latency. Evaluate tradeoffs before scaling mock coverage.

Scenario Mock Strategy Rationale
Component rendering logic happy-dom + minimal API mocks Fast initialization, low memory footprint
Complex layout calculations Real browser (Playwright) jsdom lacks layout engine; mocks diverge from reality
localStorage / sessionStorage In-memory mock registry Zero I/O latency, deterministic state reset
IntersectionObserver / ResizeObserver Proxy shim + manual trigger Avoids heavy polyfills; explicit control over viewport state

Garbage Collection & Cleanup Routines:

  • Force mock registry cleanup in afterAll using delete window.__MOCK_REGISTRY__.
  • Run global.gc() in Node.js environments with --expose-gc to reclaim detached DOM nodes.
  • Monitor heap snapshots in CI; if memory grows >15% per suite, isolate heavy mocks into separate worker pools.

Advanced Implementation: Real-Time Communication Mocks

Simulating WebSocket, EventSource, and BroadcastChannel requires intercepting constructor calls and managing internal state machines. The following implementation provides connection state simulation, message framing, and exponential backoff for reconnection.

// src/test-utils/websocket-mock.ts
export class MockWebSocket {
 public readyState: number = WebSocket.CONNECTING;
 public onopen: ((event: Event) => void) | null = null;
 public onmessage: ((event: MessageEvent) => void) | null = null;
 public onclose: ((event: CloseEvent) => void) | null = null;
 public onerror: ((event: Event) => void) | null = null;
 private messageQueue: string[] = [];
 private reconnectAttempts = 0;

 constructor(url: string) {
 // Simulate async connection
 setTimeout(() => {
 this.readyState = WebSocket.OPEN;
 this.onopen?.(new Event('open'));
 this.flushQueue();
 }, 50);
 }

 send(data: string) {
 if (this.readyState === WebSocket.OPEN) {
 console.log(`[WS] Sent: ${data}`);
 } else {
 this.messageQueue.push(data);
 }
 }

 close(code = 1000, reason = 'Normal closure') {
 this.readyState = WebSocket.CLOSING;
 setTimeout(() => {
 this.readyState = WebSocket.CLOSED;
 this.onclose?.(new CloseEvent('close', { code, reason }));
 }, 20);
 }

 private flushQueue() {
 while (this.messageQueue.length) {
 const msg = this.messageQueue.shift();
 if (msg) this.send(msg);
 }
 }

 // Simulate server push
 simulateMessage(data: string) {
 if (this.readyState === WebSocket.OPEN) {
 this.onmessage?.(new MessageEvent('message', { data }));
 }
 }

 // Simulate disconnect & backoff
 simulateDisconnect() {
 this.readyState = WebSocket.CLOSED;
 this.onclose?.(new CloseEvent('close', { code: 1006, reason: 'Abnormal' }));
 const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000);
 this.reconnectAttempts++;
 setTimeout(() => {
 this.readyState = WebSocket.CONNECTING;
 this.onopen?.(new Event('open'));
 this.readyState = WebSocket.OPEN;
 }, delay);
 }
}

// Global override
Object.defineProperty(window, 'WebSocket', {
 value: MockWebSocket,
 writable: true,
 configurable: true,
});

Integration Notes:

  • Replace window.WebSocket before importing modules that instantiate connections.
  • Use simulateDisconnect() to validate client-side reconnection logic and exponential backoff.
  • For scalable real-time testing architectures, reference production-ready patterns in Simulating WebSocket connections in Playwright component tests to align mock behavior with headless browser execution contexts.