The complete mock below is the one referenced from testing observers in JSDOM and real browsers: a real class rather than a spy, delivering asynchronously the way the browser does, with a registry that lets a test reach an observer a component created privately.

Problem / Scenario Context

The first symptom is always the same. A component that lazily loads an image, reveals a panel or records an impression imports fine in a browser and throws in the test runner:

ReferenceError: IntersectionObserver is not defined
  at LazyImage.tsx:14:22

Line 14 is usually not inside a function — it is a module-scope singleton, or a default options object, or a small helper that constructs an observer eagerly. That is why "just render it later" does not help: the error fires when the module is evaluated, before any test body runs.

The two common wrong turns from here are equally costly. The first is to stub the constructor with a bare jest.fn(), which removes the error and simultaneously removes every observer-driven behaviour from the test — the suite goes green while covering nothing. The second is to make the mock fire its callback synchronously from observe(), which makes tests pass against timing the browser never produces, so a component that reads observer state immediately after observe() ships broken with a green suite behind it.

Mechanics Explanation

The real API has four behaviours that a useful double has to reproduce.

Construction is inert. new IntersectionObserver(cb) does not observe anything and does not call anything. Only observe() starts work.

Delivery is always asynchronous. Even the initial entry for a newly observed element arrives in a later task, never inline. This is the behaviour that most distinguishes a faithful mock from a convenient one.

Options are normalised. threshold: 0.5 becomes thresholds: [0.5]; rootMargin is expanded to four components; root: undefined becomes root: null. Code that reads these back sees the normalised form.

Targets are tracked per instance. unobserve removes one; disconnect removes all but leaves the instance reusable.

Three Ways to Stub the Constructor, and What Each ProvesThree approaches ranked. A bare spy removes the error but never invokes the callback, so it proves only that the module loaded. A synchronous mock invokes the callback inline, which proves the callback body runs but validates timing the browser never produces. An asynchronous class mock with a registry reproduces the real contract and is the only one whose green result means anything.globalThis.IntersectionObserver = jest.fn()Error gone, callback never runs. Proves the module imported — nothing else.observe(el) { this.cb([entry]) } // synchronousCallback runs, but a component reading state on the next line passes here and fails in a browser.class + queueMicrotask + instance registryReproduces inert construction, async delivery and per-instance targets. A green test means something.

Minimal Reproducible Example

TypeScript
// The failure, in three lines
import { LazyImage } from '../src/LazyImage';   // constructs an observer at module scope
test('renders', () => { render(<LazyImage src="/a.jpg" />); });
// ReferenceError: IntersectionObserver is not defined

Production-Safe Solution

TypeScript
// test/setup-intersection-observer.ts
type IOCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void;

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

  readonly root: Element | Document | null;
  readonly rootMargin: string;
  readonly thresholds: ReadonlyArray<number>;
  readonly targets = new Set<Element>();
  private pending: IntersectionObserverEntry[] = [];

  constructor(private cb: IOCallback, opts: IntersectionObserverInit = {}) {
    this.root = opts.root ?? null;
    this.rootMargin = normaliseMargin(opts.rootMargin ?? '0px');
    this.thresholds = Array.isArray(opts.threshold)
      ? [...opts.threshold].sort((a, b) => a - b)
      : [opts.threshold ?? 0];
    MockIntersectionObserver.instances.push(this);
  }

  observe(el: Element): void {
    this.targets.add(el);
    queueMicrotask(() => {                       // never synchronous
      if (this.targets.has(el)) this.cb([entryFor(el, false, 0)], this);
    });
  }

  unobserve(el: Element): void { this.targets.delete(el); }
  disconnect(): void { this.targets.clear(); }
  takeRecords(): IntersectionObserverEntry[] { const p = this.pending; this.pending = []; return p; }

  /** Test API: deliver a synthetic crossing for one observed element. */
  trigger(el: Element, isIntersecting: boolean, ratio = isIntersecting ? 1 : 0): void {
    if (!this.targets.has(el)) {
      throw new Error(`trigger(): element is not observed by this instance (targets: ${this.targets.size})`);
    }
    this.cb([entryFor(el, isIntersecting, ratio)], this);
  }
}

function normaliseMargin(m: string): string {
  const parts = m.trim().split(/\s+/);
  const [t, r = t, b = t, l = r] = parts;
  return `${t} ${r} ${b} ${l}`;
}

function entryFor(target: Element, isIntersecting: boolean, ratio: number): IntersectionObserverEntry {
  const rect = target.getBoundingClientRect();   // zeros in JSDOM — deliberately not faked
  return {
    target, isIntersecting, intersectionRatio: ratio, time: 0,
    boundingClientRect: rect, intersectionRect: rect, rootBounds: null,
  } as IntersectionObserverEntry;
}

globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver;

// helpers the suite imports
export const flushObservers = () => new Promise<void>((r) => queueMicrotask(() => r()));
export const lastObserver = (): MockIntersectionObserver => {
  const { instances } = MockIntersectionObserver;
  if (!instances.length) throw new Error('no IntersectionObserver was constructed');
  return instances[instances.length - 1];
};
export const findObserver = (pred: (o: MockIntersectionObserver) => boolean) => {
  const hit = MockIntersectionObserver.instances.find(pred);
  if (!hit) throw new Error('no matching IntersectionObserver');
  return hit;
};

// the cleanup every file inherits without having to remember it
afterEach(() => { MockIntersectionObserver.instances.length = 0; });

Register it once per runner:

TypeScript
// vitest.config.ts
export default defineConfig({
  test: { environment: 'jsdom', setupFiles: ['./test/setup-intersection-observer.ts'] },
});
JavaScript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/test/setup-intersection-observer.ts'],
};

And use it against behaviour rather than configuration:

TypeScript
it('loads the image only once it is in view, then stops watching', async () => {
  const { container } = render(<LazyImage dataSrc="/hero.jpg" alt="" />);
  const img = container.querySelector('img')!;

  await flushObservers();
  expect(img.getAttribute('src')).toBeNull();

  lastObserver().trigger(img, true);
  await flushObservers();

  expect(img.getAttribute('src')).toBe('/hero.jpg');
  expect(lastObserver().targets.has(img)).toBe(false);
});

Where the Awaits Go in an Observer TestA five-step test sequence. Render constructs the observer. The first flush lets the initial non-intersecting delivery land. An assertion confirms nothing loaded yet. The trigger call delivers a synthetic intersecting entry. A second flush lets the resulting state update commit before the final assertions. A note marks the two flushes as the steps most often omitted.render()observer builtawait flush()initial entry landsassert deferredsrc === nulltrigger(el, true)synthetic entryawait flush() → assert loadedstate update commits firstThe two green steps are the ones most often missing from a flaky testOmit either flush and the assertion races the microtask — green on a fast machine, red in CI.

Comparison Table: What Each Runner Needs

The mock itself is runner-agnostic; only the registration hook and a couple of globals differ.

Concern Vitest Jest
Where the setup file is registered test.setupFiles in vitest.config.ts setupFilesAfterEnv in the Jest config
Environment that lacks the API environment: 'jsdom' or 'happy-dom' testEnvironment: 'jsdom'
afterEach available in the setup file Yes, globals are on by default Yes, with setupFilesAfterEnv (not with setupFiles)
Spying on a mock method vi.spyOn(MockIntersectionObserver.prototype, 'disconnect') jest.spyOn(...), identical shape
Restoring between tests restoreMocks: true restoreMocks: true
TypeScript for the global assignment globalThis.IntersectionObserver = X as unknown as typeof IntersectionObserver identical

The one genuine difference is the afterEach placement. Jest's setupFiles runs before the test framework is installed, so afterEach is not defined there; setupFilesAfterEnv runs after, and is the hook you want. Vitest has no such split. Getting this wrong produces a confusing error — afterEach is not defined — that reads like a missing import rather than a lifecycle ordering problem.

It is also worth deciding early whether the mock should be strict about misuse. The trigger() implementation above throws when asked to deliver an entry for an element the instance is not watching, and that strictness pays for itself: without it, a test that observes the wrong node silently passes because the callback simply never runs, and the assertion that should have caught it was written to expect no change at that point anyway. A mock that fails loudly on incoherent usage catches a whole class of test that is green for the wrong reason.

Finally, resist the temptation to add convenience methods that have no counterpart in the real API — a triggerAll() that fires every target at once, say, or a setRatio() that mutates an entry in place. Every such method is a behaviour your production code can come to rely on in tests and that no browser will ever provide. The double should be as small as the contract, and no smaller.

What the Mock Must Refuse to DoThree behaviours a faithful test double deliberately does not provide: fabricated geometry, synchronous delivery, and convenience helpers with no counterpart in the real API. Each is paired with the class of false confidence it would create in a passing test.Three things the double must NOT doReturn plausible geometryA test asserting on invented rectangles proves your arithmetic, in a world you made up.Deliver synchronously from observe()Code reading state on the next line then passes here and fails in every browser.Add helpers the real API lacksProduction code comes to rely on behaviour no browser will ever provide.

Verification Steps

  • Prove the mock is installed: expect(typeof IntersectionObserver).toBe('function') at the top of a smoke test, plus expect(IntersectionObserver.name).toBe('MockIntersectionObserver').
  • Prove delivery is asynchronous: call observe() and assert the callback has not run on the next line, then assert it has after a flush.
  • Prove normalisation: construct with { threshold: 0.5, rootMargin: '10px' } and assert thresholds equals [0.5] and rootMargin equals '10px 10px 10px 10px'.
  • Prove isolation: run one file twice in the same process and assert instances.length is the same both times.
  • Prove teardown: unmount and assert targets.size === 0, which is the assertion most suites are missing.

Common Mistakes to Avoid

  • Faking getBoundingClientRect to return plausible numbers. Once the mock returns geometry you invented, any assertion derived from it is testing your arithmetic. Keep the zeros and move geometry assertions to a browser run.
  • Putting the setup in globalSetup. That hook runs in a separate context in both runners; globals set there do not reach the test environment. Use setupFiles / setupFilesAfterEnv.
  • Asserting expect(observeSpy).toHaveBeenCalledWith(el, { threshold: 0.5 }). This passes when the callback is broken and fails when someone harmlessly reorders options. Assert the outcome instead.
  • Forgetting takeRecords returns an array. Teardown code that drains records will call it; returning undefined throws inside a cleanup function and surfaces against an unrelated test.

↑ Back to Testing Observers in JSDOM & Real Browsers