Search code examples
node.jstypescriptjestjsts-jest

Jest throwing timeout error for video element


I have a function which extracts meta data like width and height from video element as following:

export async function getVideoMetadata(
  videoBlobUrl: string,
  videoElement: HTMLVideoElement,
): Promise<{ width: number; height: number }> {
  // Trigger video load
  return new Promise<{ width: number; height: number }>((resolve, reject) => {
    videoElement.onloadedmetadata = () => {
      videoElement.width = videoElement.videoWidth;
      videoElement.height = videoElement.videoHeight;
      videoElement.currentTime = videoElement.duration * 0.25;
    };

    videoElement.onseeked = () => {
      resolve({ width: videoElement.videoWidth, height: videoElement.videoHeight });
    };

    videoElement.onerror = () => {
      reject(`Error loading video`);
    };
    videoElement.src = videoBlobUrl;
  });
}

On a running environment, the code runs perfectly and provides desired result with the correct height and width of video. I am using this function in another function to draw the video on canvas to generate thumbnail of the video on client side.

But when I am trying to write a test in jest for the same as the following function:

  it('should return video metadata', async () => {
    // Mock the videoBlobUrl
    const videoBlobUrl = 'https://example.com/mock-video-url.mp4';

    // Mock the HTMLVideoElement
    const videoElementMock: HTMLVideoElement = {
      onloadedmetadata: null,
      onseeked: null,
      onerror: null,
      width: 0,
      height: 0,
      videoWidth: 640, // Mock video width
      videoHeight: 480, // Mock video height
      duration: 10, // Mock video duration
      currentTime: 0,
      src: '',
      addEventListener: jest.fn(),
      removeEventListener: jest.fn(),
      dispatchEvent: jest.fn(),
      play: jest.fn(),
      pause: jest.fn(),
      load: jest.fn(),
    };

    // Spy on addEventListener to capture the event handlers
    const addEventListenerSpy = jest.spyOn(videoElementMock, 'addEventListener');

    // Call the function with the mocked data
    const result = await getVideoMetadata(videoBlobUrl, videoElementMock);

    // Assertions
    expect(videoElementMock.src).toBe(videoBlobUrl);
    expect(addEventListenerSpy).toHaveBeenCalledWith('loadedmetadata', expect.any(Function));
    expect(addEventListenerSpy).toHaveBeenCalledWith('seeked', expect.any(Function));

    // Trigger the loadedmetadata event
    const loadedMetadataHandler = (addEventListenerSpy as jest.Mock).mock.calls.find(
      (call) => call[0] === 'loadedmetadata',
    )[1] as EventListener;
    loadedMetadataHandler(new Event('loadedmetadata'));

    // Trigger the seeked event
    const seekedHandler = (addEventListenerSpy as jest.Mock).mock.calls.find(
      (call) => call[0] === 'seeked',
    )[1] as EventListener;
    seekedHandler(new Event('seeked'));

    // Validate the result
    expect(result).toEqual({width:640, height:480});
  });

The test case always fails throwing out as soon as I call getVideoMetadata function in the test case without proceeding to the next line.

    thrown: "Exceeded timeout of 5000 ms for a test.
    Use jest.setTimeout(newTimeout) to increase the timeout value, if this is a long-running test."

I have tried increasing the timeout of the test but that did not help. As soon as the test code videoElement.src = videoBlobUrl; reaches and the next step it fails throwing error.


Solution

  • You attached the event handlers to the video element by its properties .onloadedmetadata, .onseeked, and .onerror. They are all anonymous functions rather than jestjs mock functions. So jest.spyOn(videoElementMock, 'addEventListener') and expect(addEventListenerSpy).toHaveBeenCalledWith('loadedmetadata', expect.any(Function)) will not work.

    Since you passed the videoElement to getVideoMetadata function, modify the video element's properties when the event happens, and you can get the properties(event handlers) of the video element in the tests. Then trigger them manually.

    E.g.

    getVideoMetadata.test.ts:

    import { expect, it } from '@jest/globals';
    import { getVideoMetadata } from './getVideoMetadata';
    
    it('should return video metadata', async () => {
      const videoBlobUrl = 'https://example.com/mock-video-url.mp4';
    
      const videoElementMock = {
        width: 0,
        height: 0,
        videoWidth: 640,
        videoHeight: 480,
        duration: 10,
        currentTime: 0,
        src: '',
        onloadedmetadata: () => {},
        onseeked: () => {},
      };
    
      const promise = getVideoMetadata(videoBlobUrl, videoElementMock as unknown as HTMLVideoElement);
    
      expect(videoElementMock.src).toBe(videoBlobUrl);
    
      // Trigger the loadedmetadata event
      videoElementMock.onloadedmetadata();
    
      // Trigger the seeked event
      videoElementMock.onseeked();
    
      // Validate the result
      const result = await promise;
      expect(result).toEqual({ width: 640, height: 480 });
      expect(videoElementMock.src).toBe(videoBlobUrl);
    });
    

    Test result:

     PASS  stackoverflow/77550452/getVideoMetadata.test.ts
      ✓ should return video metadata (1 ms)
    
    ---------------------|---------|----------|---------|---------|-------------------
    File                 | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
    ---------------------|---------|----------|---------|---------|-------------------
    All files            |      90 |      100 |      80 |      90 |                   
     getVideoMetadata.ts |      90 |      100 |      80 |      90 | 15                
    ---------------------|---------|----------|---------|---------|-------------------
    Test Suites: 1 passed, 1 total
    Tests:       1 passed, 1 total
    Snapshots:   0 total
    Time:        0.553 s