6

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.

skyboyer
  • 22,209
  • 7
  • 57
  • 64
Asim K T
  • 16,864
  • 10
  • 77
  • 99
  • You don't necessarily need to preserve type safety in mocks if this makes things much more complicated. But in this case it shows a mistake in the mock. Check https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry . Some properties like boundingClientRect aren't boolean. Also it's makes more sense to make IntersectionObserver a spy that returns spy methods than a class with bogus method implementations. – Estus Flask Sep 04 '20 at 13:19
  • Could you please elaborate this as an answer, so that future devs can understand easily. Please write for TS newbies. – Asim K T Sep 08 '20 at 15:27

2 Answers2

7

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;
cburgmer
  • 2,150
  • 1
  • 24
  • 18
4

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 
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • I can't spy on any of the stub's methods. "Cannot spy the observe property because it is not a function; undefined given instead." When I do this: `const observe = jest.spyOn(IntersectionObserver, 'observe').mockImplementation(() => {});` – Aleksandr Hovhannisyan Oct 01 '20 at 13:38
  • Fixed the code, for non-static methods it should be `jest.spyOn(IntersectionObserver.prototype, ...` everywhere. – Estus Flask Oct 01 '20 at 15:13