Testing code that uses an IntersectionObserver
Asked Answered
J

9

54

I have a JavaScript component in my application that handles infinite scroll pagination, and i'm trying to rewrite it to use the IntersectionObserver, as described here, however I'm having issues in testing it.

Is there a way to drive the behavior of the observer in a QUnit test, i.e. to trigger the observer callback with some entries described in my tests?

A possible solution I have come up with is to expose the callback function in the component's prototype and to invoke it directly in my test, something like this:

InfiniteScroll.prototype.observerCallback = function(entries) {
    //handle the infinite scroll
}

InfiniteScroll.prototype.initObserver = function() {
    var io = new IntersectionObserver(this.observerCallback);
    io.observe(someElements);
}

//In my test
var component = new InfiniteScroll();
component.observerCallback(someEntries);
//Do some assertions about the state after the callback has been executed

I don't really like this approach since it's exposing the fact that the component uses an IntersectionObserver internally, which is an implementation detail that in my opinion should not be visible to client code, so is there any better way to test this (ideally not using jQuery)?

Jettiejettison answered 29/5, 2017 at 20:34 Comment(0)
T
35

Here's another alternative based on previous answers, you can run it inside the beforeEach methods, or at the beginning of the .test.js file.

You could also pass parameters to the setupIntersectionObserverMock to mock the observe and/or unobserve methods to spy on them with a jest.fn() mock function.

/**
 * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely
 * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`.
 * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty`
 * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only
 * mock the intersection observer, but its methods.
 */
export function setupIntersectionObserverMock({
  root = null,
  rootMargin = '',
  thresholds = [],
  disconnect = () => null,
  observe = () => null,
  takeRecords = () => [],
  unobserve = () => null,
} = {}) {
  class MockIntersectionObserver {
    constructor() {
      this.root = root;
      this.rootMargin = rootMargin;
      this.thresholds = thresholds;
      this.disconnect = disconnect;
      this.observe = observe;
      this.takeRecords = takeRecords;
      this.unobserve = unobserve;
    }
  }

  Object.defineProperty(window, 'IntersectionObserver', {
    writable: true,
    configurable: true,
    value: MockIntersectionObserver
  });

  Object.defineProperty(global, 'IntersectionObserver', {
    writable: true,
    configurable: true,
    value: MockIntersectionObserver
  });
}

And for TypeScript:

/**
 * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely
 * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`.
 * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty`
 * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only
 * mock the intersection observer, but its methods.
 */
export function setupIntersectionObserverMock({
  root = null,
  rootMargin = '',
  thresholds = [],
  disconnect = () => null,
  observe = () => null,
  takeRecords = () => [],
  unobserve = () => null,
} = {}): void {
  class MockIntersectionObserver implements IntersectionObserver {
    readonly root: Element | null = root;
    readonly rootMargin: string = rootMargin;
    readonly thresholds: ReadonlyArray < number > = thresholds;
    disconnect: () => void = disconnect;
    observe: (target: Element) => void = observe;
    takeRecords: () => IntersectionObserverEntry[] = takeRecords;
    unobserve: (target: Element) => void = unobserve;
  }

  Object.defineProperty(
    window,
    'IntersectionObserver', {
      writable: true,
      configurable: true,
      value: MockIntersectionObserver
    }
  );

  Object.defineProperty(
    global,
    'IntersectionObserver', {
      writable: true,
      configurable: true,
      value: MockIntersectionObserver
    }
  );
}
Tlingit answered 31/10, 2019 at 21:57 Comment(5)
Great one, just save my day.Bamberger
Glad it helped!Tlingit
Consider adding the disconnect method as well.Dome
Good idea @sibasishm, just added the methods from the IntersectionObserver API found in the MDN docs: developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverTlingit
how do you test IO callback with this approach?Diva
V
62

None of the posted answered worked for me because of our configuration of TypeScript and React (tsx) we're using. Here's what finally worked:

beforeEach(() => {
  // IntersectionObserver isn't available in test environment
  const mockIntersectionObserver = jest.fn();
  mockIntersectionObserver.mockReturnValue({
    observe: () => null,
    unobserve: () => null,
    disconnect: () => null
  });
  window.IntersectionObserver = mockIntersectionObserver;
});
Vamoose answered 2/6, 2020 at 8:42 Comment(5)
Worked for me too in React Typescript project. Thank you so much!Keturahkeung
Also worked for me in Angular / Jest.Palatinate
Works with React / Typescript / VitestNeper
worked for me as well!Goforth
The only one that works for me too! React / TypeScript / Jest.Sulphonamide
T
35

Here's another alternative based on previous answers, you can run it inside the beforeEach methods, or at the beginning of the .test.js file.

You could also pass parameters to the setupIntersectionObserverMock to mock the observe and/or unobserve methods to spy on them with a jest.fn() mock function.

/**
 * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely
 * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`.
 * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty`
 * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only
 * mock the intersection observer, but its methods.
 */
export function setupIntersectionObserverMock({
  root = null,
  rootMargin = '',
  thresholds = [],
  disconnect = () => null,
  observe = () => null,
  takeRecords = () => [],
  unobserve = () => null,
} = {}) {
  class MockIntersectionObserver {
    constructor() {
      this.root = root;
      this.rootMargin = rootMargin;
      this.thresholds = thresholds;
      this.disconnect = disconnect;
      this.observe = observe;
      this.takeRecords = takeRecords;
      this.unobserve = unobserve;
    }
  }

  Object.defineProperty(window, 'IntersectionObserver', {
    writable: true,
    configurable: true,
    value: MockIntersectionObserver
  });

  Object.defineProperty(global, 'IntersectionObserver', {
    writable: true,
    configurable: true,
    value: MockIntersectionObserver
  });
}

And for TypeScript:

/**
 * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely
 * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`.
 * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty`
 * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only
 * mock the intersection observer, but its methods.
 */
export function setupIntersectionObserverMock({
  root = null,
  rootMargin = '',
  thresholds = [],
  disconnect = () => null,
  observe = () => null,
  takeRecords = () => [],
  unobserve = () => null,
} = {}): void {
  class MockIntersectionObserver implements IntersectionObserver {
    readonly root: Element | null = root;
    readonly rootMargin: string = rootMargin;
    readonly thresholds: ReadonlyArray < number > = thresholds;
    disconnect: () => void = disconnect;
    observe: (target: Element) => void = observe;
    takeRecords: () => IntersectionObserverEntry[] = takeRecords;
    unobserve: (target: Element) => void = unobserve;
  }

  Object.defineProperty(
    window,
    'IntersectionObserver', {
      writable: true,
      configurable: true,
      value: MockIntersectionObserver
    }
  );

  Object.defineProperty(
    global,
    'IntersectionObserver', {
      writable: true,
      configurable: true,
      value: MockIntersectionObserver
    }
  );
}
Tlingit answered 31/10, 2019 at 21:57 Comment(5)
Great one, just save my day.Bamberger
Glad it helped!Tlingit
Consider adding the disconnect method as well.Dome
Good idea @sibasishm, just added the methods from the IntersectionObserver API found in the MDN docs: developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverTlingit
how do you test IO callback with this approach?Diva
R
32

In your jest.setup.js file, mock the IntersectionObserver with the following implementation:

global.IntersectionObserver = class IntersectionObserver {
  constructor() {}

  disconnect() {
    return null;
  }

  observe() {
    return null;
  }

  takeRecords() {
    return null;
  }

  unobserve() {
    return null;
  }
};

Instead of using the Jest Setup File, you can do this mocking also directly in your tests or in your beforeAll,beforeEach blocks.

Rosalbarosalee answered 30/7, 2019 at 11:42 Comment(1)
Is there a TypeScript-friendly version of this? It works, but I get warnings... i.e. on global.IntersectionObserver: Type 'typeof IntersectionObserver' is not assignable to type '{ new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit | undefined): IntersectionObserver; prototype: IntersectionObserver; }'.Dump
I
15

Same problem in 2019 this is how I solved it:

import ....

describe('IntersectionObserverMokTest', () => {
  ...
  const observeMock = {
    observe: () => null,
    disconnect: () => null // maybe not needed
  };

  beforeEach(async(() => {
    (<any> window).IntersectionObserver = () => observeMock;

    ....
  }));


  it(' should run the Test without throwing an error for the IntersectionObserver', () => {
    ...
  })
});

So I create a mock object, with the observe (and disconnect) method and overwrite the IntersectionObserver on the window object. Depending on your usage, you might have to overwrite other functions (see: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Browser_compatibility )

The code is inspired by https://gist.github.com/ianmcnally/4b68c56900a20840b6ca840e2403771c but doesn't use jest

Indoiranian answered 19/2, 2019 at 16:13 Comment(0)
B
4

I tested it like this for Jest+Typescript

         type CB = (arg1: IntersectionObserverEntry[]) => void;
            class MockedObserver {
              cb: CB;
              options: IntersectionObserverInit;
              elements: HTMLElement[];
            
              constructor(cb: CB, options: IntersectionObserverInit) {
                this.cb = cb;
                this.options = options;
                this.elements = [];
              }
            
              unobserve(elem: HTMLElement): void {
                this.elements = this.elements.filter((en) => en !== elem);
              }
            
              observe(elem: HTMLElement): void {
                this.elements = [...new Set(this.elements.concat(elem))];
              }
            
              disconnect(): void {
                this.elements = [];
              }
            
              fire(arr: IntersectionObserverEntry[]): void {
                this.cb(arr);
              }
            }
        
        function traceMethodCalls(obj: object | Function, calls: any = {}) {
          const handler: ProxyHandler<object | Function> = {
            get(target, propKey, receiver) {
              const targetValue = Reflect.get(target, propKey, receiver);
              if (typeof targetValue === 'function') {
                return function (...args: any[]) {
                  calls[propKey] = (calls[propKey] || []).concat(args);
                  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                  // @ts-ignore
                  return targetValue.apply(this, args);
                };
              } else {
                return targetValue;
              }
            },
          };
          return new Proxy(obj, handler);
        }

And in test

describe('useIntersectionObserver', () => {
  let observer: any;
  let mockedObserverCalls: { [k: string]: any } = {};

  beforeEach(() => {
    Object.defineProperty(window, 'IntersectionObserver', {
      writable: true,
      value: jest
        .fn()
        .mockImplementation(function TrackMock(
          cb: CB,
          options: IntersectionObserverInit
        ) {
          observer = traceMethodCalls(
            new MockedObserver(cb, options),
            mockedObserverCalls
          );

          return observer;
        }),
    });
  });
  afterEach(() => {
    observer = null;
    mockedObserverCalls = {};
  });

    test('should do something', () => {
      const mockedObserver = observer as unknown as MockedObserver;
    
      const entry1 = {
        target: new HTMLElement(),
        intersectionRatio: 0.7,
      };
      // fire CB
      mockedObserver.fire([entry1 as unknown as IntersectionObserverEntry]);

      // possibly need to make test async/wait for see changes
      //  await waitForNextUpdate();
      //  await waitForDomChanges();
      //  await new Promise((resolve) => setTimeout(resolve, 0));


      // Check calls to observer
      expect(mockedObserverCalls.disconnect).toEqual([]);
      expect(mockedObserverCalls.observe).toEqual([]);
    });
});
Baleen answered 10/7, 2021 at 9:35 Comment(1)
Ingenious!! This should be the answer! Thank you so much. I was looking for solution for a week already! My savior! observer.fire() feature is so much elegant.Barthel
T
2

I had this problem with a setup based on vue-cli. I ended up using a mix of the answers I saw above:

   const mockIntersectionObserver = class {
    constructor() {}
    observe() {}
    unobserve() {}
    disconnect() {}
  };

  beforeEach(() => {
    window.IntersectionObserver = mockIntersectionObserver;
  });
Tweed answered 4/6, 2020 at 15:50 Comment(0)
L
2

Had a similar stack issue as @Kevin Brotcke, except using their solution resulted in a further TypeScript error:

Function expression, which lacks return-type annotation, implicitly has an 'any' return type.

Here's a tweaked solution that worked for me:

beforeEach(() => {
    // IntersectionObserver isn't available in test environment
    const mockIntersectionObserver = jest.fn()
    mockIntersectionObserver.mockReturnValue({
      observe: jest.fn().mockReturnValue(null),
      unobserve: jest.fn().mockReturnValue(null),
      disconnect: jest.fn().mockReturnValue(null)
    })
    window.IntersectionObserver = mockIntersectionObserver
  })
Larena answered 1/9, 2020 at 9:16 Comment(4)
Hi, how can I change this if I want to see what triggered my mockIntersectionObserver? Thanks in advance!Faveolate
@MadhavThakker I'm not sure I understand the question. You should be testing the trigger implicitly, you shouldn't need to test what it is? As in, your test should have an action that calls the intersection obeserver, and then you would test to ensure that the mock has been called once (or however many times you would expect). (might require a new SO question)Larena
Makes sense. I am able to verify that my mock observe jest function is being called once. Can I also rerender the GUI component accordingly as if it was being rendered in the browser? Basically, check if the component that the intersection observer is observing is not on screen, if so, then rerender that component back on screen?Faveolate
Honestly, I'm not sure, you'd need to test it to find out. But remember that the JSDOM is not a 1:1 representation of the browser DOM. My best guess is that this kind of trigger may not be supported.Larena
L
1

I would simply use the polyfill that is provided by this package https://www.npmjs.com/package/intersection-observer

A simple import in your setupFiles would do

import 'intersection-observer';

If you still use commonjs

require('intersection-observer');
Lie answered 24/1 at 8:40 Comment(0)
I
0

In my test I actually wanted to trigger the callback function passed to new IntersectionObserver. Extending above examples, I would first write my own class that would store callback in a static variable:

class MockIntersectionObserver implements IntersectionObserver {
  static callback: IntersectionObserverCallback;
  options: IntersectionObserverInit | undefined;
  constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
    MockIntersectionObserver.callback = callback;
    this.options = options;
  }

  root: Document | Element | null = null;
  rootMargin: string = '';
  thresholds: readonly number[] = [];
  disconnect = vi.fn();
  observe = vi.fn();
  takeRecords = vi.fn();
  unobserve = vi.fn();

  static triggerCallback(entries: any) {
    MockIntersectionObserver.callback(entries, {
      disconnect: vi.fn(),
      observe: vi.fn(),
      takeRecords: vi.fn(),
      unobserve: vi.fn(),
      root: null,
      rootMargin: '',
      thresholds: [],
    });
  }
}
window.IntersectionObserver = MockIntersectionObserver;

Inside the test I would manually trigger the callback like so:

MockIntersectionObserver.triggerCallback([{ isIntersecting: true }]);
Imponderable answered 16/1 at 15:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.