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.
Minimal Reproducible Example
// 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
// 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:
// vitest.config.ts
export default defineConfig({
test: { environment: 'jsdom', setupFiles: ['./test/setup-intersection-observer.ts'] },
});
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/test/setup-intersection-observer.ts'],
};
And use it against behaviour rather than configuration:
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);
});
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.
Verification Steps
- Prove the mock is installed:
expect(typeof IntersectionObserver).toBe('function')at the top of a smoke test, plusexpect(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 assertthresholdsequals[0.5]androotMarginequals'10px 10px 10px 10px'. - Prove isolation: run one file twice in the same process and assert
instances.lengthis 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
getBoundingClientRectto 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. UsesetupFiles/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
takeRecordsreturns an array. Teardown code that drains records will call it; returningundefinedthrows inside a cleanup function and surfaces against an unrelated test.
Related
- Simulating Viewport Intersection in Unit Tests — driving ratios and threshold sequences with this mock
- Debugging Flaky Observer Tests in CI — what a missing flush looks like on a slower machine
- Testing ResizeObserver Callbacks with Playwright — the assertions that cannot be mocked