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

6

18

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?

Orcein answered 4/5, 2020 at 13:47 Comment(0)
O
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();
    })
});
Orcein answered 9/6, 2020 at 12:34 Comment(0)
B
28

Here solution from the jest issue:

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

afterEach(() => {
  window.requestAnimationFrame.mockRestore();
});
Barragan answered 1/12, 2020 at 10:44 Comment(3)
Any idea how to write this in Typescript ?Contradictory
@Contradictory jest .spyOn(window, 'requestAnimationFrame') .mockImplementation((callback: FrameRequestCallback): number => { callback(0); return 0; });Swastika
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.Nikolai
T
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");
    })
  })
});
Tortious answered 11/5, 2020 at 8:15 Comment(4)
This only worked for me using waitRAF().then(() => expect() but it did solve my problem with testing react-modal onAfterOpenGallicize
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?Tortious
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.Gallicize
This worked perfectly for me in a jest/react setupCulvert
O
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();
    })
});
Orcein answered 9/6, 2020 at 12:34 Comment(0)
H
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.

Humism answered 19/2, 2021 at 3:24 Comment(0)
C
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();
});
Condition answered 14/8, 2022 at 4:0 Comment(2)
Doesnt that instantly call the callback?Orcein
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.Condition
F
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.

Furnishing answered 21/11, 2022 at 12:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.