Test an Intersection Observer in React

Here is how I have tested an IntersectionObserver in Reactland.

A React Hook for Observering

Let's say that I wanted to set up an IntersectionObserver using a React hook. Reasonable enough. This tracks whether something has been seen. If it's seen, and only the first time, a callback is executed:

import React from "react";

export function useIntersectionTracker<T extends HTMLElement>(
  callback: () => void
) {
  let visited = false;
  const [el, setEl] = React.useState<T>();
  const ref = React.useCallback((node) => setEl(node), []);

  React.useEffect(() => {
    if (!el) return;

    let observer = setupIntersectionObserver((entries) => {
      if (entries[0].intersectionRatio <= 0) return;

      if (!visited) {
        visited = true;
        callback();
      }
    }, el);

    return () => {
      observer.disconnect();
    };
  }, [el]);

  return ref;
}

function setupIntersectionObserver(
  fn: IntersectionObserverCallback,
  target: HTMLElement,
  options?: IntersectionObserverInit
) {
  const observer = new IntersectionObserver(fn, options);
  observer.observe(target);
  return observer;
}

Test Helpers

We're going to be testing in jest. Jest uses jsdom by default. Jsdom doesn't include the concept of intersection, so we're going to need some test helpers.

You can use the whole react-intersection-oserver library. But I didn't, feeling I could get a simpler solution that matched my more limited use case. But the library did teach me how to set up a test that will work.

You'll want this helper set up in your test environment:

// adapted from https://github.com/thebuilder/react-intersection-observer/blob/d35365990136bfbc99ce112270e5ff232cf45f7f/src/test-helper.ts
const observerMap = new Map();
const instanceMap = new Map();

beforeEach(() => {
  // @ts-ignore
  global.IntersectionObserver = jest.fn((cb, options = {}) => {
    const instance = {
      thresholds: Array.isArray(options.threshold)
        ? options.threshold
        : [options.threshold],
      root: options.root,
      rootMargin: options.rootMargin,
      observe: jest.fn((element: Element) => {
        instanceMap.set(element, instance);
        observerMap.set(element, cb);
      }),
      unobserve: jest.fn((element: Element) => {
        instanceMap.delete(element);
        observerMap.delete(element);
      }),
      disconnect: jest.fn(),
    };
    return instance;
  });
});

afterEach(() => {
  // @ts-ignore
  global.IntersectionObserver.mockReset();
  instanceMap.clear();
  observerMap.clear();
});

export function intersect(element: Element, isIntersecting: boolean) {
  const cb = observerMap.get(element);
  if (cb) {
    cb([
      {
        isIntersecting,
        target: element,
        intersectionRatio: isIntersecting ? 1 : -1,
      },
    ]);
  }
}

export function getObserverOf(element: Element): IntersectionObserver {
  return instanceMap.get(element);
}

This helper replaces the IntersectionObserver with a stub that keeps track of what we're observing and allows us to force intersection on those elements. This will allow us to test the cases of intersection and non-intersection.

Test Cases

Now for the test cases. We want to exercise several cases: that observers can be created, that intersection calls callbacks, that non-intersection does not, and, specfic to our use case, that repeated intersections do not call back.

import React from "react";
import { render } from "@testing-library/react";

import { useIntersectionTracker } from "./use-intersection-tracker";
import { getObserverOf, intersect } from "./intersection-observer-test-helper";

const Observed = ({ callback }: { callback: () => void }) => {
  const ref = useIntersectionTracker(callback);
  return (
    <div data-testid="wrapper" ref={ref}>
      {" "}
    </div>
  );
};

it("creates an observer", () => {
  const callback = jest.fn();
  const { getByTestId } = render(<Observed callback={callback} />);
  const wrapper = getByTestId("wrapper");
  const instance = getObserverOf(wrapper);

  expect(instance.observe).toHaveBeenCalledWith(wrapper);
});

it("does not call the callback without intersection", () => {
  const callback = jest.fn();
  const { getByTestId } = render(<Observed callback={callback} />);

  const wrapper = getByTestId("wrapper");
  intersect(wrapper, false);

  expect(callback).not.toHaveBeenCalled();
});

it("calls the callback on intersection", () => {
  const callback = jest.fn();
  const { getByTestId } = render(<Observed callback={callback} />);

  const wrapper = getByTestId("wrapper");
  intersect(wrapper, true);

  expect(callback).toHaveBeenCalledTimes(1);
});

it("calls the callback only once", () => {
  const callback = jest.fn();
  const { getByTestId } = render(<Observed callback={callback} />);

  const wrapper = getByTestId("wrapper");
  intersect(wrapper, true);
  intersect(wrapper, false);
  intersect(wrapper, true);

  expect(callback).toHaveBeenCalledTimes(1);
});

it("unmounts the hook", () => {
  const callback = jest.fn();

  const { getByTestId, unmount } = render(<Observed callback={callback} />);
  const wrapper = getByTestId("wrapper");
  unmount();
  expect(getObserverOf(wrapper).disconnect).toHaveBeenCalledTimes(1);
});

Give that a run, and weep for joy that you've finally come to a working solution.