Observer code is unusually easy to write and unusually hard to test, because the thing that makes it valuable — that the browser computes geometry for you — is exactly what a Node-based test runner cannot provide. This page sets out where the boundary sits: what a mock can honestly prove, what it can only pretend to prove, and which assertions have to move into a real browser to mean anything.

Concept Framing

Every test suite that touches IntersectionObserver or ResizeObserver under JSDOM hits the same wall on the first run: ReferenceError: IntersectionObserver is not defined. The instinct is to treat this as a gap in JSDOM. It is better understood as an honest refusal.

JSDOM implements the DOM: a tree of nodes, attributes, events and a CSS object model. What it deliberately does not implement is layout. No element has a box, nothing has a position, nothing scrolls and nothing paints. getBoundingClientRect() returns zeros for everything. Since an intersection ratio is defined as the overlap between two rectangles, and both rectangles are always zero, the only implementation JSDOM could ship would return fabricated numbers that agree with nothing.

That has a direct consequence for how you should structure tests. Observer-driven features split cleanly into two halves:

  • The reaction. Given an entry that says the element is 60% visible, does the component add the class, start the fetch, record the impression, unobserve the target? This half is pure logic and belongs in fast unit tests.
  • The trigger. Given a real page and a real scroll, does an entry saying 60% actually arrive at the right moment? This half is geometry and belongs in a browser.

Mocking is how you test the first half honestly. It becomes dishonest the moment a mock starts asserting things about the second half — which is why a test that stubs getBoundingClientRect to return plausible numbers and then checks a threshold calculation is testing arithmetic you wrote, in a world you invented.

The Testing Boundary: Reaction vs. TriggerA feature split into two halves by a vertical line. The left half, the reaction, covers the callback body, state updates, class toggles, fetches and teardown, and is testable with a mock in JSDOM. The right half, the trigger, covers ratios, box sizes, rootMargin, scroll behaviour and callback ordering against paint, and requires a real browser. A note states that a mock asserting anything from the right half is testing invented numbers.the reaction — mock it in JSDOMDoes the callback body branch correctly?Is the class added, the src swapped, the fetch fired?Is unobserve() called after a one-shot trigger?Is disconnect() called on unmount?Fast, deterministic, runs on every commit.the trigger — only a browser knowsWhat ratio arrives, and when?Does rootMargin expand the box as intended?Do contentRect values match the CSS?Does the callback land before paint?Slower, fewer of them, but the only real evidence.A mock that answers a right-hand question is answering it with numbers you typed yourself — the test proves your arithmetic,not the browser's behaviour.

Spec / Signature Reference Table

A mock is only useful if it reproduces the parts of the contract your code actually depends on. These are the members worth implementing, and the behaviour each must have.

Member Real behaviour What a faithful mock must do
new IntersectionObserver(cb, opts) Stores the callback; does not observe anything yet Record cb and opts; register the instance so tests can find it
observe(el) Adds el to the target list; schedules an initial delivery Add to a per-instance Set; queue an initial entry asynchronously
unobserve(el) Removes one target; other targets keep firing Remove from the Set only
disconnect() Removes every target; the instance stays reusable Clear the Set; leave the instance usable
takeRecords() Returns and drains pending entries (IntersectionObserver only) Return the queued entries and clear the queue
root, rootMargin, thresholds Read-only reflections of the options Expose them, normalised (thresholds is always an array)
Callback signature (entries, observer) Pass both; tests sometimes call observer.unobserve from inside
new ResizeObserver(cb) Stores the callback Same as above, with a ResizeObserverEntry shape
ResizeObserver.observe(el) Always delivers one initial entry Queue an entry unconditionally

The two easiest details to get wrong are thresholds and the initial delivery. The real constructor normalises threshold: 0.5 into thresholds: [0.5], and code that reads observer.thresholds.length will behave differently against a mock that stores the raw option. And a ResizeObserver mock that only fires when a test asks it to will hide a whole class of bug in components that rely on the guaranteed first measurement.

Step-by-Step Implementation

1. Install the constructor before any module is imported

The error happens at module evaluation time, so the stub must be in place before the module under test loads. In Vitest that means a setupFiles entry; in Jest, setupFilesAfterEnv.

TypeScript
// test/setup-observers.ts — loaded via setupFiles, before any import under test
type IOCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void;

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 queued: IntersectionObserverEntry[] = [];

  constructor(private cb: IOCallback, opts: IntersectionObserverInit = {}) {
    this.root = opts.root ?? null;
    this.rootMargin = 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);
    // the real API never fires synchronously — neither do we
    queueMicrotask(() => {
      if (this.targets.has(el)) this.deliver([makeEntry(el, { isIntersecting: false, ratio: 0 })]);
    });
  }

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

  /** Test-facing: deliver a synthetic entry for a target this instance is watching. */
  trigger(el: Element, init: { isIntersecting: boolean; ratio?: number }): void {
    if (!this.targets.has(el)) throw new Error('trigger() called for an element that is not observed');
    this.deliver([makeEntry(el, { isIntersecting: init.isIntersecting, ratio: init.ratio ?? (init.isIntersecting ? 1 : 0) })]);
  }

  private deliver(entries: IntersectionObserverEntry[]): void { this.cb(entries, this); }
}

globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver;

The instances array is what lets a test reach the observer a component created internally, without the component having to expose it.

2. Build entries that look like the real thing

A component that reads entry.intersectionRatio or entry.boundingClientRect will crash against a half-built entry, and a test that only sets isIntersecting will not catch that. Build the full shape once:

TypeScript
function makeEntry(
  target: Element,
  { isIntersecting, ratio }: { isIntersecting: boolean; ratio: number },
): IntersectionObserverEntry {
  const rect = target.getBoundingClientRect(); // all zeros under JSDOM — that is fine and honest
  return {
    target,
    isIntersecting,
    intersectionRatio: ratio,
    time: 0,                       // deterministic: never Date.now() in a test double
    boundingClientRect: rect,
    intersectionRect: rect,
    rootBounds: null,
  } as IntersectionObserverEntry;
}

Note the zeros. Resist the urge to fill them with plausible numbers: a test that asserts against invented geometry is the exact failure mode this page is about.

3. Give the suite a flush helper

Because delivery is asynchronous, every test needs one await between the action and the assertion.

TypeScript
export const flushObservers = () => new Promise<void>((r) => queueMicrotask(() => r()));

export function lastObserver(): MockIntersectionObserver {
  const list = MockIntersectionObserver.instances;
  if (!list.length) throw new Error('no IntersectionObserver was constructed');
  return list[list.length - 1];
}

// reset between tests, or instances leak across the file
afterEach(() => { MockIntersectionObserver.instances.length = 0; });

4. Write the test against behaviour

TypeScript
it('swaps data-src into src once the image is in view', async () => {
  const { container } = render(<LazyImage dataSrc="/hero.jpg" alt="" />);
  const img = container.querySelector('img')!;

  await flushObservers();                       // initial, non-intersecting delivery
  expect(img.getAttribute('src')).toBeNull();   // still deferred

  lastObserver().trigger(img, { isIntersecting: true, ratio: 1 });
  await flushObservers();

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

Every assertion here is about what the component did. None of them mentions how it was configured.

How a Test Reaches an Observer It Never CreatedA flow from the test to the component and back. The setup file installs the mock constructor on globalThis. The component under test constructs an observer, which registers itself in a static instances array. The test reads the newest instance from that array and calls trigger on it, which invokes the component's own callback. The component's visible effect is what the test asserts on.setup fileglobalThis.IO = Mockcomponent under testnew IntersectionObserver()Mock.instances[]registers itself on constructthe testlastObserver().trigger(el, …)invokes the component's own callbackThe component never learns it is being tested — no injected observer, no exposed handle, no test-only prop.

Configuration Variants

Different runners need the stub installed in different places, and a few need extra care.

Runner / environment Where the stub goes Notes
Vitest + jsdom test.setupFiles in vitest.config.ts Runs before each test file's imports; the usual choice
Vitest + happy-dom same happy-dom also lacks both observers
Jest + jsdom setupFilesAfterEnv setupFiles also works; AfterEnv gives you afterEach
Storybook test runner a preview decorator The stub must exist before the story module evaluates
Node with no DOM at all not applicable Move the test up to a browser layer instead of stubbing a DOM too
Playwright / WebDriver no stub The real API is present; stubbing here defeats the purpose

One variant deserves a warning. Some teams install the stub globally and then, in browser-based tests, forget to remove it — so the suite that was supposed to exercise the real API quietly exercises the mock. Keep the setup file scoped to the JSDOM project in your runner configuration, and add one assertion at the top of the browser suite that IntersectionObserver.toString() contains [native code].

Edge Cases & Gotchas

A component that constructs its observer lazily. If the observer is only created on first interaction, lastObserver() throws in a test that never interacts. Prefer a helper that waits: poll instances.length across a microtask or two before failing, and make the error message say what was expected.

Multiple observers on one page. A component that uses one observer for lazy loading and another for impressions gives you two instances, and lastObserver() returns whichever was constructed second. Select by options instead — find the instance whose rootMargin matches — or expose a find(pred) helper.

Strict Mode double-invocation. Under React Strict Mode in development, effects run twice, so two instances appear for a single mount. That is not a bug, and a test that asserts instances.length === 1 will fail for the wrong reason. Assert on the last instance and on teardown behaviour instead, as covered in fixing doubled observer callbacks in React Strict Mode.

takeRecords() in teardown paths. Code that drains pending records before disconnecting will call takeRecords() on the mock. If the mock returns undefined, the teardown throws inside a cleanup function, and the resulting error is reported against an unrelated test. Always return an array.

Timers plus observers. A dwell-timer impression test needs both fake timers and an observer flush, and the order matters: trigger the entry, flush microtasks so the callback runs and starts the timer, then advance the fake clock. Advancing the clock first does nothing, because the timer does not exist yet.

JSDOM's getBoundingClientRect is not patchable per element by default. Libraries that stub it globally affect every element in the document for the rest of the file. If a test needs one element to report a size, stub that element's own method and restore it in afterEach.

A Test Pyramid for Observer CodeThree layers with suggested proportions. A wide base of unit tests with a mocked observer covers callback logic, one-shot behaviour and teardown. A narrower middle layer of component tests covers framework wiring such as refs and lifecycle hooks. A small top layer of browser tests covers the geometry-dependent assertions no mock can make.browsercomponentunit, with a mocked observerratios, boxes, loop warningsrefs, lifecycle, cleanupbranches, one-shot, teardownMost of the coverage is cheap; the expensive layer is small and specific

Framework Integration Patterns

React Testing Library. Render, flush, trigger, flush, assert. The double flush is not superstition: the first lets the initial delivery land, the second lets the state update from the triggered entry commit.

TypeScript
render(<RevealOnScroll>content</RevealOnScroll>);
await flushObservers();
act(() => lastObserver().trigger(screen.getByTestId('panel'), { isIntersecting: true }));
await flushObservers();
expect(screen.getByTestId('panel')).toHaveClass('is-visible');

Vue Test Utils. The composable pattern described in Vue observer composables makes this easier, because the composable can be tested directly with a plain element and no component wrapper. Mount only when you are testing the template binding.

Angular TestBed. Directives that use NgZone.runOutsideAngular need fakeAsync plus a tick() for the microtask, and a fixture.detectChanges() afterwards, because the re-entry into the zone is what normally triggers change detection.

Testing the teardown, in every framework. This is the assertion most suites lack and the bug most likely to reach production:

TypeScript
it('disconnects when the component unmounts', async () => {
  const { unmount } = render(<RevealOnScroll>content</RevealOnScroll>);
  await flushObservers();
  const observer = lastObserver();
  expect(observer.targets.size).toBe(1);

  unmount();
  expect(observer.targets.size).toBe(0);   // disconnect() cleared the set
});

Debugging Checklist

  • IntersectionObserver is not defined still throws. The setup file is loading after the module under test. In Vitest, confirm the path is in test.setupFiles and not test.globalSetup — the latter runs in a separate context that does not share globals.
  • The callback never runs. Either observe() was never reached (log inside the mock's observe), or the test asserted before the microtask flushed. Add the missing await.
  • The callback runs twice. Expected under React Strict Mode. If not in Strict Mode, the component is creating a new observer per render — see why an inline options object rebuilds the observer.
  • A test passes alone and fails in the suite. Instances are leaking between files. Clear the static array in afterEach, and make sure the array lives on the mock class rather than in module scope shared across workers.
  • The assertion depends on a number you invented. Move that test into the browser layer. This is not a debugging step so much as a design correction, but it is the most common root cause of an observer test that is green and wrong.
  • toString() on the constructor does not say [native code] in your browser suite. The JSDOM setup file is being applied to the browser project too.

FAQ

Why does JSDOM not implement IntersectionObserver?

JSDOM implements the DOM, not a layout engine. It has no boxes, no scroll positions and no paint, so there is nothing for an intersection ratio to be computed from. Implementing the API would mean either returning fabricated numbers or building a rendering engine; JSDOM correctly does neither and leaves the global undefined.

Should my mock fire the callback synchronously from observe()?

No. The real API always delivers asynchronously, so a synchronous mock makes tests pass against timing that production never produces. Deliver from a queued microtask, and let the test await a flush. A component that reads observer state on the line after observe() should fail its test, because it will fail in a browser.

Can I just assert that observe() was called?

That assertion locks the test to the implementation rather than the behaviour, and it passes even when the callback is broken. Assert the visible outcome instead: the image got a src, the class was added, the fetch fired. The one call worth asserting is disconnect() in a teardown test, because leaked observers have no other visible symptom.

Which observer assertions genuinely need a real browser?

Anything derived from layout: intersection ratios, contentRect values, rootMargin behaviour, sticky positioning, scroll restoration, and the ordering of callbacks against paint. A mock can prove that your code handles an entry correctly; only a browser can prove the entry you receive is the one you expected.

Do I need a separate mock for ResizeObserver?

Yes, because the entry shape differs and because ResizeObserver guarantees an initial delivery for every newly observed element while IntersectionObserver's initial entry may report no intersection. A shared base class covering the registry and the microtask delivery is worth having, with per-API entry factories on top.


↑ Back to Core Observer Fundamentals & Browser APIs