Mock for intersection observer in jest and typescript

Viewed 6185

If we use jest with typescript, which have an intersection observer used, the mocking of intersection observer will become hard. So far I'm at:


beforeEach(() => {
  // IntersectionObserver isn't available in test environment
  const mockIntersectionObserver = class {
    observe() {
      console.log(this);
    }

    unobserve() {
      console.log(this);
    }

    disconnect() {
      console.log(this);
    }

    root = null

    rootMargin = '0'

    thresholds=[1]

    takeRecords=() => ([{
      isIntersecting: true,
      boundingClientRect: true,
      intersectionRatio: true,
      intersectionRect: true,
      rootBounds: true,
      target: true,
       time: true,
    }])
  };
  window.IntersectionObserver = mockIntersectionObserver;
});

But still this is throwing error like:

Type 'typeof mockIntersectionObserver' is not assignable to type '{ new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit | undefined): IntersectionObserver; prototype: IntersectionObserver; }'.
  The types returned by 'prototype.takeRecords()' are incompatible between these types.
    Type '{ isIntersecting: boolean; boundingClientRect: boolean; intersectionRatio: boolean; intersectionRect: boolean; rootBounds: boolean; target: boolean; time: boolean; }[]' is not assignable to type 'IntersectionObserverEntry[]'.
      Type '{ isIntersecting: boolean; boundingClientRect: boolean; intersectionRatio: boolean; intersectionRect: boolean; rootBounds: boolean; target: boolean; time: boolean; }' is not assignable to type 'IntersectionObserverEntry'.
        Types of property 'boundingClientRect' are incompatible.
          Type 'boolean' is not assignable to type 'DOMRectReadOnly'.ts(2322

I can keep adding correct types to each element, but is there a better way?

How can I add an intersection observer to the jest environment? I think it will be better than mocking like this.

2 Answers

This works for us

/* eslint-disable class-methods-use-this */

export default class {
  readonly root: Element | null;

  readonly rootMargin: string;

  readonly thresholds: ReadonlyArray<number>;

  constructor() {
    this.root = null;
    this.rootMargin = '';
    this.thresholds = [];
  }

  disconnect() {}

  observe() {}

  takeRecords(): IntersectionObserverEntry[] {
    return [];
  }

  unobserve() {}
}

And to install then do

import MockIntersectionObserver from './MockIntersectionObserver';
window.IntersectionObserver = MockIntersectionObserver;

Generally, the mock should closely follow IntersectionObserver and IntersectionObserverEntry specifications, but the mock can be stripped down depending on the use.

It may be unnecessary to preserve type safety in mocks if proper typing makes things much more complicated. In this case type error shows a mistake in the mock. As it can be seen in IntersectionObserverEntry reference, only isIntersecting property is supposed to be a boolean, while boundingClientRect should be an object, so providing booleans for the rest and ignoring type problems may result in mocked implementation that unintentionally doesn't work.

It's impractical to mock with regular class because it lacks the features provided by Jest spies like call assertions and mock implementations that are controlled by the framework.

A simple implementation would be:

window.IntersectionObserver = jest.fn(() => ({
  takeRecords: jest.fn(),
  ...
}));

A downside is that there's no way to change the implementation of mocked class members when an instance cannot be directly accessed or this needs to be done immediately after the instantiation. This requires to replace the entire class implementation where needed.

For this reason it's beneficial to keep Jest spy a class with prototype chain that can be accessed before the instantiation. Jest auto-mocking can be exploited for this purpose, this allows to define get accessors for read-only properties that can change the implementation like any other Jest spy:

class IntersectionObserverStub {
  get root() {} // read-only property 
  takeRecords() { /* implementation is ignored */ } // stub method
  observe() {}
  ...
}

jest.doMock('intersection-observer-mock', () => IntersectionObserverStub, { virtual: true });

window.IntersectionObserver = jest.requireMock('intersection-observer-mock');

jest.spyOn(IntersectionObserver.prototype, 'root', 'get').mockReturnValue(null);
// jest.spyOn(IntersectionObserver.prototype, 'takeRecords').mockImplementation(() => ({...}));

This results in generating mocked class implementation that is Jest spy prototype with methods being no-op spies. get accessors remain as is, but since they exist, they can be mocked later with spyOn(..., 'get'). A method like takeRecords that is likely specific to an instance can remain without default implementation and be mocked in-place, that it returns undefined may result in cleaner error output than random predefined value when it is called unexpectedly.

jest.spyOn(IntersectionObserver.prototype, 'root', 'get').mockReturnValueOnce(someDocument);
const mockedEntries = [{
  isIntersecting: true,
  boundingClientRect: { x: 10, y: 20, width: 30, height: 40, ... },
  ...
}];
IntersectionObserver.prototype.takeRecords.mockReturnValueOnce(mockedEntries );

// code that instantiates IntersectionObserver and possibly uses mocked values immediately

expect(IntersectionObserver.prototype.observe).toBeCalledWith(...);
expect(IntersectionObserver).toBeCalledWith(expect.any(Function)); // callback arg
let [callback] = IntersectionObserver.mock.calls[0]
callback(mockedEntries); // test a callback 
Related