测试使用IntersectionObserver的代码

52
我在应用程序中有一个JavaScript组件,处理无限滚动分页,并试图重写它以使用IntersectionObserver,如此处所述,但是在测试时遇到了问题。
有没有一种方法可以在QUnit测试中驱动观察器的行为,例如使用在我的测试中描述的某些条目触发观察器回调函数?
我想到的一个可能的解决方案是在组件的原型中公开回调函数,并在我的测试中直接调用它,类似于以下内容:
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

我不太喜欢这种方法,因为它暴露了组件内部使用 IntersectionObserver 的事实,而在我看来,这是一个不应该对客户端代码可见的实现细节,所以有没有更好的方法来测试它(最好不使用jQuery)?
7个回答

55

由于我们使用的是TypeScript和React (tsx)的配置,所有发布的答案都对我没有用。以下是最终有效的解决方法:

beforeEach(() => {
  // IntersectionObserver isn't available in test environment
  const mockIntersectionObserver = jest.fn();
  mockIntersectionObserver.mockReturnValue({
    observe: () => null,
    unobserve: () => null,
    disconnect: () => null
  });
  window.IntersectionObserver = mockIntersectionObserver;
});

3
在我的React TypeScript项目中也起作用了。非常感谢! - ann.piv
在 Angular / Jest 中对我也起作用。 - jezmck
适用于React / Typescript / Vitest。 - Yoltic Cruz Tello
也适用于我! - sohammondal
对我来说也是唯一可行的选择!React / TypeScript / Jest。 - Jozias Martini

31

这里是基于之前答案的另一种解决方案,你可以在beforeEach方法内运行它,或者在.test.js文件的开头运行它。

你也可以向 setupIntersectionObserverMock 传递参数来模拟 observe 和/或 unobserve 方法,并使用 jest.fn() 模拟函数对它们进行监视。

/**
 * 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
  });
}

并且对于 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
    }
  );
}

1
太棒了,你救了我的一天。 - Firmino Changani
很高兴能帮上忙! - rmolinamir
1
考虑添加断开连接方法。 - sibasishm
不错的想法@sibasishm,我刚刚添加了在MDN文档中找到的IntersectionObserver API的方法:https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver - rmolinamir
你如何使用这种方法测试IO回调? - Dion Dirza

30
在你的 jest.setup.js 文件中,使用以下实现来模拟 IntersectionObserver:
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}

  disconnect() {
    return null;
  }

  observe() {
    return null;
  }

  takeRecords() {
    return null;
  }

  unobserve() {
    return null;
  }
};

不必使用Jest设置文件,您也可以在测试中直接进行模拟,或者在beforeAll、beforeEach块中进行。


2
有没有 TypeScript 友好版本的这个库?它可以工作,但是我收到了警告...例如在 global.IntersectionObserver 上:Type 'typeof IntersectionObserver' is not assignable to type '{ new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit | undefined): IntersectionObserver; prototype: IntersectionObserver; }'. - Paul

15

2019年出现相同问题,我是这样解决的:

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', () => {
    ...
  })
});

因此,我创建了一个模拟对象,带有 observe (和 disconnect) 方法,并覆盖了 window 对象上的 IntersectionObserver。 根据您的使用情况,您可能需要覆盖其他函数(请参见:https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Browser_compatibility

这段代码的灵感来自于https://gist.github.com/ianmcnally/4b68c56900a20840b6ca840e2403771c,但不使用jest


4
我以这种方式测试了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);
        }

在测试中

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([]);
    });
});

聪明!这应该就是答案!非常感谢。我已经寻找解决方案一个星期了!你是我的救星! observer.fire() 特性如此优雅。 - AndrewK

2

我在基于vue-cli的设置中遇到了这个问题。最终,我使用了上面看到的答案的混合方法:

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

  beforeEach(() => {
    window.IntersectionObserver = mockIntersectionObserver;
  });

2

我有一个类似于@Kevin Brotcke的堆栈问题,但使用他们的解决方案导致了进一步的TypeScript错误:

函数表达式缺少返回类型注释,隐式具有“任何”返回类型。

这是一个适合我的微调解决方案:

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
  })

你好,如果我想查看是什么触发了我的mockIntersectionObserver,我该如何更改呢?先感谢您! - Madhav Thakker
@MadhavThakker,我不确定我理解这个问题。您应该隐式测试触发器,而不需要测试它是什么?也就是说,您的测试应该有一个调用交集观察器的动作,然后您将测试以确保模拟已被调用一次(或者您期望的次数)。 (可能需要一个新的SO问题) - theAdhocracy
有道理。我能够验证我的模拟观察 jest 函数被调用了一次。我是否也可以相应地重新渲染 GUI 组件,就像在浏览器中呈现一样?基本上,检查交叉观察器正在观察的组件是否不在屏幕上,如果是,则将该组件重新呈现在屏幕上? - Madhav Thakker
1
老实说,我不确定,你需要测试一下才能知道。但请记住,JSDOM并不是浏览器DOM的1:1表示。我最好的猜测是这种触发可能不被支持。 - theAdhocracy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接