A mock can hand your callback any entry you like. The useful question is which of those entries correspond to something a browser would actually produce — because a simulation that a browser cannot reach is a test of a code path that will never run.

Problem / Scenario Context

A reveal-on-scroll component has three behaviours worth testing: it does nothing while the panel is off screen, it adds a class once a quarter of the panel is visible, and it stops observing afterwards so the animation never replays. Under JSDOM there is no layout, so none of these can be triggered by scrolling. They have to be simulated.

The temptation at this point is to reach for whatever produces a green test: set isIntersecting to true and move on. That covers the second behaviour and quietly skips the other two, and it also encodes an assumption that turns out to be wrong often enough to matter — that isIntersecting and a useful intersectionRatio always travel together.

Mechanics Explanation

Two properties of real entries constrain what a simulation should produce.

isIntersecting is a boolean derived from the thresholds, not from "any overlap at all". With threshold: 0 an entry is intersecting as soon as a single pixel overlaps. With threshold: 0.5 the flag flips at the halfway line. A simulation that sets isIntersecting: true alongside intersectionRatio: 0.1 on an observer configured with threshold: 0.5 describes a state the browser will never deliver, and any branch it exercises is dead code.

Entries arrive as transitions, not as levels. The callback fires when a threshold is crossed. A component is never told "the ratio is currently 0.4"; it is told "you have just crossed 0.25 going up". Simulating a plausible sequence therefore means walking the configured thresholds in order, which is also the only way to catch a component that mishandles the leave transition.

A third property is easy to overlook: an element can be intersecting with a ratio of exactly zero. A zero-height element inside the viewport, or one clipped to nothing by an ancestor, reports isIntersecting: true and intersectionRatio: 0. Real pages produce this constantly — collapsed accordions, empty list rows, elements mid-transition — and a component that gates on ratio > 0 rather than on the flag will silently do nothing.

Reachable and Unreachable Entry States for threshold 0.5A grid of four combinations for an observer configured with a single threshold of one half. Not intersecting with a ratio of zero is reachable, as is intersecting with a ratio of one half or above. Intersecting with a ratio below the threshold is unreachable for this configuration, and not intersecting with a high ratio is unreachable in every configuration. Simulating either unreachable state tests a branch no browser will run.Observer configured with threshold: 0.5isIntersecting: false, ratio: 0Off screen, or below the threshold. Reachable.Simulate this for the initial delivery.isIntersecting: true, ratio: 0.5 … 1The threshold was crossed upward. Reachable.Simulate this for the trigger.isIntersecting: true, ratio: 0.1Below the configured threshold — not produced here.Only reachable if threshold is 0.isIntersecting: false, ratio: 0.9Contradictory — no configuration produces it.A test using this is testing dead code.

Comparison Table: What Each Simulation Proves

Simulation Proves Does not prove
Single isIntersecting: true The trigger branch runs That the trigger fires at the right moment on a real page
Enter then leave State is reset correctly on exit That the leave threshold is reachable given the layout
Walk of the configured thresholds Every ratio branch is handled That the browser delivers them in that order at any given scroll speed
isIntersecting: true, ratio: 0 Zero-area elements are handled Anything about why the element had zero area
Rapid enter/leave in one tick The component is re-entrant That such a burst happens in practice

The middle row is the one most suites skip and most bugs live in. A reveal that adds a class on entry and never removes it looks correct until the element is re-observed after a route change, and only the enter-then-leave simulation catches that.

Minimal Reproducible Example

TypeScript
// A test that passes while describing an impossible state
lastObserver().trigger(panel, true, 0.1);     // threshold is 0.5 — the browser never sends this
await flushObservers();
expect(panel).toHaveClass('is-visible');       // green, and meaningless

Production-Safe Solution

Drive the simulation from the observer's own normalised thresholds, so an impossible pairing cannot be expressed:

TypeScript
/** Walk a target upward through every configured threshold, then back down. */
export async function scrollThrough(
  observer: MockIntersectionObserver,
  el: Element,
): Promise<number[]> {
  const seen: number[] = [];
  const steps = [...observer.thresholds].filter((t) => t > 0);

  for (const t of steps) {
    observer.trigger(el, true, t);            // crossing upward
    seen.push(t);
    await flushObservers();
  }
  for (const t of [...steps].reverse()) {
    observer.trigger(el, t > 0 ? true : false, Math.max(0, t - 0.01));
    await flushObservers();
  }
  observer.trigger(el, false, 0);             // fully out
  await flushObservers();
  return seen;
}
TypeScript
it('reveals once and never replays', async () => {
  render(<RevealPanel />);
  const panel = screen.getByTestId('panel');
  await flushObservers();

  const observer = lastObserver();
  await scrollThrough(observer, panel);

  expect(panel).toHaveClass('is-visible');            // revealed on the way up
  expect(observer.targets.has(panel)).toBe(false);    // and stopped watching
});

it('handles a zero-area element without throwing', async () => {
  render(<RevealPanel collapsed />);
  const panel = screen.getByTestId('panel');
  await flushObservers();
  lastObserver().trigger(panel, true, 0);             // intersecting, but nothing visible
  await flushObservers();
  expect(panel).not.toHaveClass('is-visible');        // gated on ratio, correctly
});

The helper reads observer.thresholds rather than taking them as an argument, which means a test cannot drift out of sync with the component's configuration when someone changes it.

A Simulated Pass: Up Through the Thresholds, Then Back DownA ratio axis with three configured thresholds at a quarter, a half and three quarters. The simulation delivers one entry at each threshold going up, marking the element as intersecting, then one entry just below each going down, ending with a final entry at zero and not intersecting. The downward half is highlighted as the part most suites omit.01.00.250.50.75upward: 3 entriesdownward: the half most suites skipWalk the observer's OWN thresholds, so the simulation cannot drift from the configurationSix entries in total for a three-threshold observer — the same count a real slow scroll would produce.

Simulating a Fast ScrollA fast scroll simulation delivering only the first and last ratio of a pass, skipping every configured threshold in between. This reproduces what a real flick produces and catches a component that assumes it will observe every step on the way up.The one simulation most suites omit: a flick that skips thresholds0.100.250.50.750.90three thresholds crossed, none delivered — exactly what a real flick producesA component that waits for 0.5 before acting is stuck here, and only this simulation finds it.

Verification Steps

  • Assert the helper used the real configuration: expect(await scrollThrough(o, el)).toEqual([...o.thresholds].filter(t => t > 0)).
  • Assert the leave path: after a full pass, the component's state should be back to its initial value unless it is deliberately one-shot.
  • Assert the zero-ratio case does not throw: a try-free test that simply completes is sufficient evidence.
  • Cross-check one behaviour in a browser: pick the single most important trigger and assert it in a Playwright run, so the simulation is anchored to at least one real observation.

Common Mistakes to Avoid

  • Pairing isIntersecting: true with a ratio below the configured threshold. Impossible in a browser; the branch it covers is unreachable.
  • Simulating only the entry. Leave transitions are where state-reset bugs live.
  • Hard-coding threshold values in the test. They drift from the component the first time someone tunes the configuration; read them from the observer instead.
  • Treating intersectionRatio as a live level. It is a snapshot at a crossing, and code that polls it between callbacks is reading a stale number.

FAQ

Can I simulate a ratio the observer's thresholds do not include?

You can, and the mock will happily deliver it, but no browser will. The entry that reaches a real callback is always produced by crossing one of the configured thresholds, so a ratio between two of them describes a moment the callback is never invoked at. Walk the configured values instead.

Is isIntersecting ever true with a ratio of zero?

Yes, and it is common. A zero-height element inside the viewport, or one clipped to nothing by an ancestor, reports exactly that. Components that gate on ratio greater than zero rather than on the flag silently do nothing for collapsed accordions and empty rows, which is why this case deserves its own test.

How many entries should one simulated scroll produce?

Two per non-zero threshold, one on the way in and one on the way out, plus the initial delivery. A three-threshold observer therefore produces six entries across a full pass — the same count a slow real scroll would generate, which is a useful sanity check on the helper.

Should the simulation include a fast scroll that skips thresholds?

It is worth one test. A real fast scroll can jump from a ratio of 0.1 to 0.9 between frames, delivering neither of the intermediate thresholds, and a component that assumes it will see every step will get stuck. Simulate it by triggering only the first and last values.


↑ Back to Testing Observers in JSDOM & Real Browsers