Search code examples
jestjssettimeoutrequestanimationframejsdom

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


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?


Solution

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