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.
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
// 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:
/** 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;
}
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.
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: truewith 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
intersectionRatioas 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.
Related
- Mocking IntersectionObserver in Jest and Vitest — the test double these simulations are driven through
- How IntersectionObserver Threshold Works in Practice — why a fast scroll skips values a slow one reports
- Debugging Flaky Observer Tests in CI — when a simulation is right but the awaits are not