Mocking IntersectionObserver and ResizeObserver in jsdom

IntersectionObserver and ResizeObserver power a huge share of modern UI — lazy-loaded images, infinite scroll, sticky headers, responsive charts, and virtualized lists — yet neither exists in jsdom or happy-dom. The moment a component instantiates one under test, you get ReferenceError: IntersectionObserver is not defined, and even a bare polyfill leaves you with no way to trigger the callback, because there is no real viewport or layout engine to drive it. This guide is for frontend developers and QA engineers running Vitest 2.x (the patterns port directly to Jest 29.x) who need to test observer-driven components deterministically. It covers stubbing both constructors and, crucially, firing their callbacks on demand. It is the in-process complement to the runtime-isolation work in DOM & Browser API Mocking.

Observer mock trigger sequence A component observes an element, the test grabs the registered callback from the mock registry, and invokes it with synthetic entries to drive a deterministic render assertion. Component .observe(el) Mock Registry stores callbacks Test triggers entry Assertion rendered state

Root Cause Analysis

Both observer APIs are part of the browser platform, not the DOM core, so jsdom — which implements the DOM and HTML specs — deliberately omits them. There is no viewport to compute intersection ratios against and no layout engine to report element box sizes, which means even a real polyfill would have nothing meaningful to observe.

The failure shows up in two stages. First, a missing-global crash: any component calling new IntersectionObserver(cb) throws ReferenceError the instant it mounts, taking the whole render down. Second, the silent-no-op trap: developers add a stub whose observe method does nothing, the crash disappears, but the callback never fires, so assertions on the post-intersection state hang or fail with stale UI. The component “renders”, yet the behaviour you actually care about — the image swapping in, the next page loading, the chart resizing — is never exercised.

The fix has to do two jobs at once: satisfy the constructor so mounting succeeds, and expose a handle to the registered callback so the test can drive it. Storing callbacks in a registry is what turns a dead stub into a controllable one, the same controllability principle that underpins all Advanced Mocking & Service Isolation Patterns.

Reproducible Setup

Confirm a DOM environment is active. jsdom is the spec-complete choice; the stubs work identically under happy-dom.

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

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});

A representative component lazy-loads an image once it scrolls into view:

// src/components/LazyImage.tsx
import { useEffect, useRef, useState } from 'react';

export function LazyImage({ src }: { src: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) setVisible(true);
    });
    if (ref.current) io.observe(ref.current);
    return () => io.disconnect();
  }, []);

  return (
    <div ref={ref} data-testid="lazy-root">
      {visible ? <img src={src} alt="" data-testid="loaded" /> : <span>Loading…</span>}
    </div>
  );
}

Implementation

The strategy is a controllable mock class that records every instance and the callback it was constructed with, plus a helper that fires synthetic entries on demand.

Step 1: Build a controllable IntersectionObserver mock.

// src/test-utils/observer-mocks.ts
type IOCallback = IntersectionObserverCallback;

class MockIntersectionObserver implements IntersectionObserver {
  static instances: MockIntersectionObserver[] = [];

  readonly root = null;
  readonly rootMargin = '';
  readonly thresholds = [];
  callback: IOCallback;
  observed = new Set<Element>();

  constructor(cb: IOCallback) {
    this.callback = cb;
    MockIntersectionObserver.instances.push(this);
  }

  observe(el: Element) { this.observed.add(el); }
  unobserve(el: Element) { this.observed.delete(el); }
  disconnect() { this.observed.clear(); }
  takeRecords(): IntersectionObserverEntry[] { return []; }
}

export function installIntersectionObserver() {
  MockIntersectionObserver.instances = [];
  vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
  return MockIntersectionObserver;
}

Step 2: Add a deterministic trigger helper.

The helper synthesizes an entry for an observed element and invokes the stored callback synchronously, so the React update is flushed inside act when you wrap it.

// src/test-utils/observer-mocks.ts (continued)
export function triggerIntersection(el: Element, isIntersecting: boolean) {
  for (const inst of MockIntersectionObserver.instances) {
    if (inst.observed.has(el)) {
      const entry = {
        isIntersecting,
        intersectionRatio: isIntersecting ? 1 : 0,
        target: el,
        boundingClientRect: el.getBoundingClientRect(),
        intersectionRect: el.getBoundingClientRect(),
        rootBounds: null,
        time: Date.now(),
      } as IntersectionObserverEntry;
      inst.callback([entry], inst as unknown as IntersectionObserver);
    }
  }
}

Step 3: Mirror the pattern for ResizeObserver.

ResizeObserver follows the same shape; its entries carry contentRect instead of intersection geometry.

// src/test-utils/observer-mocks.ts (continued)
class MockResizeObserver implements ResizeObserver {
  static instances: MockResizeObserver[] = [];
  callback: ResizeObserverCallback;
  observed = new Set<Element>();

  constructor(cb: ResizeObserverCallback) {
    this.callback = cb;
    MockResizeObserver.instances.push(this);
  }
  observe(el: Element) { this.observed.add(el); }
  unobserve(el: Element) { this.observed.delete(el); }
  disconnect() { this.observed.clear(); }
}

export function installResizeObserver() {
  MockResizeObserver.instances = [];
  vi.stubGlobal('ResizeObserver', MockResizeObserver);
  return MockResizeObserver;
}

export function triggerResize(el: Element, rect: Partial<DOMRectReadOnly>) {
  for (const inst of MockResizeObserver.instances) {
    if (inst.observed.has(el)) {
      const entry = {
        target: el,
        contentRect: { width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, ...rect },
        borderBoxSize: [],
        contentBoxSize: [],
        devicePixelContentBoxSize: [],
      } as unknown as ResizeObserverEntry;
      inst.callback([entry], inst as unknown as ResizeObserver);
    }
  }
}

Step 4: Register the stubs in the setup file so every test inherits them.

// src/test-setup.ts
import { beforeEach, afterEach, vi } from 'vitest';
import { installIntersectionObserver, installResizeObserver } from './test-utils/observer-mocks';

beforeEach(() => {
  installIntersectionObserver();
  installResizeObserver();
});

afterEach(() => {
  vi.unstubAllGlobals();
});

Calling vi.unstubAllGlobals() in afterEach restores the original (absent) globals and clears the instance registries, preventing the state bleed across workers that the parent guide on DOM & Browser API Mocking warns about.

Verification

A test now mounts the component, fires the callback, and asserts the rendered result — all synchronously and deterministically:

// src/__tests__/lazy-image.test.ts
import { render, screen } from '@testing-library/react';
import { act } from 'react';
import { expect, it } from 'vitest';
import { triggerIntersection } from '../test-utils/observer-mocks';
import { LazyImage } from '../components/LazyImage';

it('swaps to the full image after intersection', () => {
  render(<LazyImage src="/hero.jpg" />);
  expect(screen.queryByTestId('loaded')).toBeNull();

  act(() => {
    triggerIntersection(screen.getByTestId('lazy-root'), true);
  });

  expect(screen.getByTestId('loaded')).toBeInTheDocument();
});

A passing run is unambiguous and stable on repeat:

 ✓ src/__tests__/lazy-image.test.ts (1)
   ✓ swaps to the full image after intersection
 Test Files  1 passed (1)
      Tests  1 passed (1)

Wrapping the trigger in act and asserting through Testing Library queries keeps the test aligned with Testing Library best practices, which favour asserting on what the user sees over inspecting mock internals.

Troubleshooting

ReferenceError: IntersectionObserver is not defined persists. The stub registered after the component imported. Diagnosis: the constructor runs at module-eval time or the setup file is not listed first. Fix: ensure the install call lives in setupFiles and runs in beforeEach, before any render.

The callback fires but the UI never updates. The React state change happened outside act, so the update was not flushed. Diagnosis: an act(...) warning in the console. Fix: wrap every triggerIntersection / triggerResize call in act, as shown above.

contentRect reads all zeros. jsdom has no layout engine, so getBoundingClientRect returns zeros by default. Diagnosis: assertions on real geometry fail. Fix: pass explicit dimensions to triggerResize, or move genuinely layout-dependent checks to a real browser via Playwright component testing.

FAQ

Does this work with Jest as well as Vitest?

Yes. Replace vi.stubGlobal('IntersectionObserver', Mock) with global.IntersectionObserver = Mock (or jest.spyOn on the global) and swap vi.unstubAllGlobals() for an explicit delete (global as any).IntersectionObserver in afterEach. The mock class, the instance registry, and the trigger helpers are framework-agnostic, so only the registration and restoration lines change.

Why not just install the npm polyfill instead of writing a mock?

A polyfill reproduces the real algorithm, but in jsdom there is no viewport or layout to feed it, so it can never decide that an element is intersecting on its own. The point of testing is to control when the callback fires; a hand-written mock with a trigger helper gives you that determinism, whereas a polyfill leaves you waiting on geometry that never changes.

How do I test that the component disconnects the observer on unmount?

Assert against the registry. After unmounting, check that the relevant instance’s observed set is empty, or spy on disconnect. Because every instance is recorded in MockIntersectionObserver.instances, you can confirm cleanup happened without reaching into component internals — a leaked observer is one of the failure modes flagged in DOM & Browser API Mocking.

Can one mock serve multiple observed elements in the same test?

Yes. The registry stores every instance and each instance tracks its own observed set, so triggerIntersection(elementA, true) fires only the callbacks watching elementA. This lets you drive a virtualized list one row at a time and assert incremental rendering without cross-talk between elements.