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: typeof originalValue === 'object' && originalValue !== null
? { ...originalValue, ...implementation }
: implementation,
writable: true,
configurable: true,
enumerable: true,
});
// Return teardown function
return () => {
if (originalDescriptor) {
Object.defineProperty(window, key, originalDescriptor);
} else {
// @ts-expect-error -- restoring dynamic key
window[key] = originalValue;
}
};
}
Step 2: Bind to test lifecycle hooks
// 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 GeolocationCoordinates } as GeolocationPosition)
),
watchPosition: vi.fn(),
clearWatch: vi.fn(),
} as Geolocation,
});
});
afterAll(() => {
teardown();
});
it('resolves coordinates without network dependency', () => {
expect(navigator.geolocation.getCurrentPosition).toBeDefined();
});
});
Execution Guidance:
- Never mock globals inside
beforeEachunless the implementation changes per test. UsebeforeAllfor static mocks to reduce overhead. - Always capture the teardown closure and invoke it in
afterAllorafterEach. Failure to restore descriptors causes state bleed across parallel workers. - For
Proxy-based shimming (e.g.,window.IntersectionObserver), wrap the native constructor and interceptobserve/unobservecalls. 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 { setupGlobalMocks } from './test-utils/global-registry';
// Register all global mocks before any test module imports
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 \
--reporter=verbose
env:
NODE_OPTIONS: "--max-old-space-size=4096"
Step 2: Enforce deterministic seeding & cache rules
- Seed
Math.random()usingvi.spyOn(Math, 'random').mockReturnValue(0.5)or a seeded PRNG insetupFiles. - Use
vi.stubGlobal('crypto', { randomUUID: vi.fn(() => '00000000-0000-0000-0000-000000000000') })for deterministic UUIDs. - Set strict timeout thresholds. 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.,
ResizeObservercallbacks). 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: Parameters<T>) => {
console.debug(`[MOCK_TRACE] ${name} called with:`, JSON.stringify(args, null, 2));
return fn(...args);
}) as T;
}
Step 2: Intercept unhandled rejections & convert to test failures
// vitest.config.ts
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; // Allow all other console output
},
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:
- Reset mock registries in
afterAllusing targeted cleanup (remove event listeners, clear intervals, restore globals). - Run
global.gc()in Node.js environments with--expose-gcto reclaim detached DOM nodes. - Monitor heap snapshots in CI; if memory grows more than 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 disconnect handling for unit-level testing.
// src/test-utils/websocket-mock.ts
export class MockWebSocket implements WebSocket {
readonly CONNECTING = WebSocket.CONNECTING;
readonly OPEN = WebSocket.OPEN;
readonly CLOSING = WebSocket.CLOSING;
readonly CLOSED = WebSocket.CLOSED;
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;
public binaryType: BinaryType = 'blob';
public bufferedAmount = 0;
public extensions = '';
public protocol = '';
public url: string;
private messageQueue: string[] = [];
constructor(url: string) {
this.url = url;
// Simulate async connection handshake
queueMicrotask(() => {
this.readyState = WebSocket.OPEN;
this.onopen?.(new Event('open'));
this.flushQueue();
});
}
send(data: string | ArrayBuffer | Blob | ArrayBufferView) {
if (this.readyState === WebSocket.OPEN) {
console.log(`[WS] Sent: ${data}`);
} else {
this.messageQueue.push(String(data));
}
}
close(code = 1000, reason = 'Normal closure') {
this.readyState = WebSocket.CLOSING;
queueMicrotask(() => {
this.readyState = WebSocket.CLOSED;
this.onclose?.(new CloseEvent('close', { code, reason, wasClean: code === 1000 }));
});
}
addEventListener(): void {}
removeEventListener(): void {}
dispatchEvent(): boolean { return true; }
private flushQueue() {
while (this.messageQueue.length) {
this.send(this.messageQueue.shift()!);
}
}
/** Test helper: simulate a server push message */
simulateMessage(data: string) {
if (this.readyState === WebSocket.OPEN) {
this.onmessage?.(new MessageEvent('message', { data }));
}
}
/** Test helper: simulate an abnormal disconnect */
simulateDisconnect() {
this.readyState = WebSocket.CLOSED;
this.onclose?.(new CloseEvent('close', { code: 1006, reason: 'Abnormal', wasClean: false }));
}
}
// Global override for tests
vi.stubGlobal('WebSocket', MockWebSocket);
Integration Notes:
- Stub
WebSocketbefore importing modules that instantiate connections. Usevi.stubGlobalso Vitest can restore the original onvi.unstubAllGlobals(). - Use
simulateDisconnect()to validate client-side reconnection logic. - For production-ready WebSocket testing in a real browser context, reference Simulating WebSocket connections in Playwright component tests to align mock behavior with headless browser execution contexts.