16

I want to write a jest unit test for a module that uses requestAnimationFrame and cancelAnimationFrame.

I tried overriding window.requestAnimationFrame with my own mock (as suggested in this answer), but the module keeps on using the implementation provided by jsdom.

My current approach is to use the (somehow) builtin requestAnimationFrame implementation from jsdom, which seems to use setTimeout under the hood, which should be mockable by using 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]);
    });
});

The first test is successful, while the second fails, because order is empty.

What is the correct way to test code that relies on requestAnimationFrame(). Especially if I need to test conditions where a frame was cancelled?

Jason Aller
  • 3,541
  • 28
  • 38
  • 38
htho
  • 1,549
  • 1
  • 12
  • 33

6 Answers6

25

Here solution from the jest issue:

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

afterEach(() => {
  window.requestAnimationFrame.mockRestore();
});
Pavel
  • 2,602
  • 1
  • 27
  • 34
  • 2
    Any idea how to write this in Typescript ? – Mouna Mar 05 '21 at 22:29
  • 1
    @Mouna jest .spyOn(window, 'requestAnimationFrame') .mockImplementation((callback: FrameRequestCallback): number => { callback(0); return 0; }); – Atif Jul 22 '21 at 18:42
  • One problem with the above mock is that if your callback is calling RAF again so that it runs once per animation frame, the direct call of the callback can potentially both tie up the execution thread and also blow the stack with all the recursive calls. It might be better to use setTimeout in the mock to allow the JS event loop to run, and then Jest's fake timers to allow the mocked RAF to advance. – cvkline Dec 01 '21 at 15:34
9

I'm not sure this solution is perfect but this works for my case.

There are two key principles working here.

1) Create a delay that is based on requestAnimationFrame:

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

2) Make the animation I am testing run very fast:

In my case the animation I was waiting on has a configurable duration which is set to 1 in my props data.

Another solution to this could potentially be running the waitRaf method multiple times but this will slow down tests.

You may also need to mock requestAnimationFrame but that is dependant on your setup, testing framework and implementation

My example test file (Vue app with Jest):

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");
    })
  })
});
Tom Benyon
  • 971
  • 10
  • 15
  • This only worked for me using `waitRAF().then(() => expect()` but it did solve my problem with testing react-modal `onAfterOpen` – Arajay Nov 20 '21 at 00:49
  • 1
    Hey @Arajay, Awesome you got it working :) There should be no difference between `await waitRAF(); expect(...` and `waitRAF().then(() => expect()`, just two different ways to deal with the asynchronous nature of promises. Are you sure you had the `await` in there and the `expect` after it? – Tom Benyon Nov 23 '21 at 08:51
  • yeah i would think the same thing @tom and yet only the newer syntax worked. it should not matter but this is a rush monorepo using heft-jest-config. that is the only thing i can think of. – Arajay Nov 24 '21 at 17:47
  • This worked perfectly for me in a jest/react setup – neiya Jul 01 '22 at 13:48
7

So, I found the solution myself.

I really needed to override window.requestAnimationFrame and window.cancelAnimationFrame.

The problem was, that I did not include the mock module properly.

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

The mock must be imported BEFORE any module is imported that might call 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();
    })
});
htho
  • 1,549
  • 1
  • 12
  • 33
3

Here is my solution inspired by the first answer.

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

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

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

Then in test mock the timer:

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

Directly call cb in mockImplementation will produce infinite call loop. So I make use of the Jest Timer Mocks to get it under control.

Bell
  • 31
  • 1
3

My solution in typescript. I figured by making time go very quickly each frame, it would make the animations go very (basically instant) fast. Might not be the right solution in certain cases but I'd say this will help many.

let requestAnimationFrameSpy: jest.SpyInstance<number, [callback: FrameRequestCallback]>;

beforeEach(() => {
    let time = 0;
    requestAnimationFrameSpy = jest.spyOn(window, 'requestAnimationFrame')
      .mockImplementation((callback: FrameRequestCallback): number => {
        callback(time+=1000000);
        return 0;
      });
});

afterEach(() => {
    requestAnimationFrameSpy.mockRestore();
});
antirealm
  • 408
  • 3
  • 10
  • Doesnt that instantly call the callback? – htho Aug 20 '22 at 18:21
  • We aren't trying to wait for the right time to render a frame, like the browser would, so we are happy to start right away. We are also happy to tell our render callback that time has passed forward very far so hopefully the render function ends up at it's end state. In my case its a moving box that goes from one position to another and my logic to make it quicker was just to skip right ahead as if the animation was very very fast. If the render function is written right it shouldn't cause any problems in fact in some cases it might help you see that the render function is wrong. – antirealm Aug 25 '22 at 12:58
1

The problem with previous version is that callbacks are called directly, which does not reflect the asynchronous nature of requestAnimationFrame.

Here is a mock which uses jest.useFakeTimers() to achieve this while giving you control when the code is executed:


beforeAll(() => {
  jest.useFakeTimers()
  let time = 0
  jest.spyOn(window, 'requestAnimationFrame').mockImplementation(
    // @ts-expect-error
    (cb) => {
    // we can then use fake timers to preserve the async nature of this call

    setTimeout(() => {
      time = time + 16 // 16 ms
      cb(time)
    }, 0)
  })
})
afterAll(() => {
  jest.useRealTimers()

  // @ts-expect-error
  window.requestAnimationFrame.mockRestore()
})

in your test you can then use:

yourFunction() // will schedule a requestAnimation
jest.runAllTimers() // execute the callback
expect(....) // check that it happened

This helps to contain Zalgo.

Maddis
  • 577
  • 1
  • 6
  • 19