如何测试开玩笑地使用“requestAnimationFrame”的代码?

How do I test code that uses `requestAnimationFrame` in jest?

我想为使用 requestAnimationFramecancelAnimationFrame 的模块编写一个有趣的单元测试。

我尝试用我自己的 mock 覆盖 window.requestAnimationFrame(如 中所建议),但该模块继续使用 jsdom 提供的实现。

我目前的方法是使用来自 jsdom 的(以某种方式)内置 requestAnimationFrame 实现,它似乎在幕后使用 setTimeout,应该可以通过使用 jest.useFakeTimers() 来模拟。

jest.useFakeTimers();

describe("fakeTimers", () => {
    test.only("setTimeout and trigger", () => {
        const order: number[] = [];
        
        expect(order).toEqual([]);
        setTimeout(t => order.push(1));
        expect(order).toEqual([]);
        jest.runAllTimers();
        expect(order).toEqual([1]);
    });

    test.only("requestAnimationFrame and runAllTimers", () => {
        const order: number[] = [];
        
        expect(order).toEqual([]);
        requestAnimationFrame(t => order.push(1));
        expect(order).toEqual([]);
        jest.runAllTimers();
        expect(order).toEqual([1]);
    });
});

第一次测试成功,第二次失败,因为order是空的。

测试依赖于 requestAnimationFrame() 的代码的正确方法是什么?特别是如果我需要测试帧被取消的情况?

我不确定这个解决方案是否完美,但这适用于我的情况。

这里有两个关键原则。

1) 创建一个基于requestAnimationFrame的延迟:

const waitRAF = () => new Promise(resolve => requestAnimationFrame(resolve));

2) 使我正在测试的动画运行非常快:

在我的例子中,我正在等待的动画有一个可配置的持续时间,在我的道具数据中设置为 1。

另一种解决方案可能是 运行多次调用 waitRaf 方法,但这会减慢测试速度。

您可能还需要模拟 requestAnimationFrame,但这取决于您的设置、测试框架和实现

我的示例测试文件(带有 Jest 的 Vue 应用程序):

import { mount } from '@vue/test-utils';
import AnimatedCount from '@/components/AnimatedCount.vue';

const waitRAF = () => new Promise(resolve => requestAnimationFrame(resolve));

let wrapper;
describe('AnimatedCount.vue', () => {
  beforeEach(() => {
    wrapper = mount(AnimatedCount, {
      propsData: {
        value: 9,
        duration: 1,
        formatDisplayFn: (val) => "£" + val
      }
    });
  });

  it('renders a vue instance', () => {
    expect(wrapper.isVueInstance()).toBe(true);
  });

  describe('When a value is passed in', () => {
    it('should render the correct amount', async () => {
      const valueOutputElement = wrapper.get("span");
      wrapper.setProps({ value: 10 });

      await wrapper.vm.$nextTick();
      await waitRAF();

      expect(valueOutputElement.text()).toBe("£10");
    })
  })
});

所以,我自己找到了解决方案。

我真的需要覆盖 window.requestAnimationFramewindow.cancelAnimationFrame

问题是我没有正确包含模拟模块。

// mock_requestAnimationFrame.js

class RequestAnimationFrameMockSession {
    handleCounter = 0;
    queue = new Map();
    requestAnimationFrame(callback) {
        const handle = this.handleCounter++;
        this.queue.set(handle, callback);
        return handle;
    }
    cancelAnimationFrame(handle) {
        this.queue.delete(handle);
    }
    triggerNextAnimationFrame(time=performance.now()) {
        const nextEntry = this.queue.entries().next().value;
        if(nextEntry === undefined) return;

        const [nextHandle, nextCallback] = nextEntry;

        nextCallback(time);
        this.queue.delete(nextHandle);
    }
    triggerAllAnimationFrames(time=performance.now()) {
        while(this.queue.size > 0) this.triggerNextAnimationFrame(time);
    }
    reset() {
        this.queue.clear();
        this.handleCounter = 0;
    }
};

export const requestAnimationFrameMock = new RequestAnimationFrameMockSession();

window.requestAnimationFrame = requestAnimationFrameMock.requestAnimationFrame.bind(requestAnimationFrameMock);
window.cancelAnimationFrame = requestAnimationFrameMock.cancelAnimationFrame.bind(requestAnimationFrameMock);

必须先导入 mock BEFORE 导入任何可能调用 requestAnimationFrame.

的模块
// mock_requestAnimationFrame.test.js

import { requestAnimationFrameMock } from "./mock_requestAnimationFrame";

describe("mock_requestAnimationFrame", () => {
    beforeEach(() => {
        requestAnimationFrameMock.reset();
    })
    test("reqest -> trigger", () => {
        const order = [];
        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);

        requestAnimationFrame(t => order.push(1));

        expect(requestAnimationFrameMock.queue.size).toBe(1);
        expect(order).toEqual([]);

        requestAnimationFrameMock.triggerNextAnimationFrame();

        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([1]);
    });

    test("reqest -> request -> trigger -> trigger", () => {
        const order = [];
        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);

        requestAnimationFrame(t => order.push(1));
        requestAnimationFrame(t => order.push(2));

        expect(requestAnimationFrameMock.queue.size).toBe(2);
        expect(order).toEqual([]);

        requestAnimationFrameMock.triggerNextAnimationFrame();

        expect(requestAnimationFrameMock.queue.size).toBe(1);
        expect(order).toEqual([1]);

        requestAnimationFrameMock.triggerNextAnimationFrame();

        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([1, 2]);
    });

    test("reqest -> cancel", () => {
        const order = [];
        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);

        const handle = requestAnimationFrame(t => order.push(1));

        expect(requestAnimationFrameMock.queue.size).toBe(1);
        expect(order).toEqual([]);

        cancelAnimationFrame(handle);

        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);
    });

    test("reqest -> request -> cancel(1) -> trigger", () => {
        const order = [];
        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);

        const handle = requestAnimationFrame(t => order.push(1));
        requestAnimationFrame(t => order.push(2));

        expect(requestAnimationFrameMock.queue.size).toBe(2);
        expect(order).toEqual([]);

        cancelAnimationFrame(handle);

        expect(requestAnimationFrameMock.queue.size).toBe(1);
        expect(order).toEqual([]);

        requestAnimationFrameMock.triggerNextAnimationFrame();

        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([2]);
    });

    test("reqest -> request -> cancel(2) -> trigger", () => {
        const order = [];
        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);

        requestAnimationFrame(t => order.push(1));
        const handle = requestAnimationFrame(t => order.push(2));

        expect(requestAnimationFrameMock.queue.size).toBe(2);
        expect(order).toEqual([]);

        cancelAnimationFrame(handle);

        expect(requestAnimationFrameMock.queue.size).toBe(1);
        expect(order).toEqual([]);

        requestAnimationFrameMock.triggerNextAnimationFrame();

        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([1]);
    });

    test("triggerAllAnimationFrames", () => {
        const order = [];
        expect(requestAnimationFrameMock.queue.size).toBe(0);
        expect(order).toEqual([]);

        requestAnimationFrame(t => order.push(1));
        requestAnimationFrame(t => order.push(2));

        requestAnimationFrameMock.triggerAllAnimationFrames();

        expect(order).toEqual([1,2]);

    });

    test("does not fail if triggerNextAnimationFrame() is called with an empty queue.", () => {
        requestAnimationFrameMock.triggerNextAnimationFrame();
    })
});

这里是开玩笑的解决方案issue:

beforeEach(() => {
  jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb());
});

afterEach(() => {
  window.requestAnimationFrame.mockRestore();
});

这是我的解决方案,灵感来自第一个答案。

beforeEach(() => {
  jest.useFakeTimers();

  let count = 0;
  jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => setTimeout(() => cb(100*(++count)), 100));
});

afterEach(() => {
  window.requestAnimationFrame.mockRestore();
  jest.clearAllTimers();
});

然后在测试中模拟计时器:

act(() => {
  jest.advanceTimersByTime(200);
});

mockImplementation中直接调用cb会产生死循环。所以我使用 Jest Timer Mocks 来控制它。