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
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 '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()andcrypto.randomUUID()at the worker level using--seedor a customMathoverride. - Invalidate compiled mock registries on every PR merge by appending
--no-cacheto 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.,
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: 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
afterAllusingdelete window.__MOCK_REGISTRY__. - Run
global.gc()in Node.js environments with--expose-gcto 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.WebSocketbefore 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.