17
votes

I'm stuck with img.onload event testing. I know that this is an async operation and it should be maybe mocked, but I still couldn't figure out how to solve the problem. I've also seen a couple of similar cases, but they are different from this one.

Previously visited:

Code to test:

  function funcToTest(img, callback) {
    const img = new Image()
    img.src = img

    img.onload = () => {
      callback(true) // should return callback with true on finish
    }

    img.onerror = (e) => {
      callback(false) // should return callback with false on error
      console.log(e) 
    }
  }

  funcToTest()

Testing environment:

describe('tet it', () => {
  it('test', done => {
    const callback = status => {
      expect(status).toEqual(true) // but nothing happen
      done()
    }

    funcToTest('some_image', callback)
  })
})

Also I get an error on the finish:

    Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.
        > 2 |   it('test', done => {...

Thanks in advance!

4
Might seem silly, but have you tried moving img.src = img to the end of funcToTest, i.e. after you've defined the onload / onerror functions?user7290573
Why are you testing img.onload at all? Is that code that you're maintaining? It seems like a mistake to test code that you don't maintain.Dov Rine
@DovRine because I need to test the callback at the end of its work. That's why.Max Travis
@MaxTravis: Why can't you just test the callback? Does img.onload provide arguments necessary to the callback? Not only that, but doing it this way will probably slow your tests down a lot.Dov Rine
@MaxTravis: is Image the class that you're testing? It seems like the function under test is FuncToTest. I think you should mock Image to have onload and onerror events in order to test FuncToTest.Dov Rine

4 Answers

18
votes

While this was answered, I disagree with the purpose of the solution.

We shouldn't write tests to pass coverage, we should write tests that prove a point, and ensure pieces of code behave as expected.

The way to test it is to mock the Image constructor and replace it with something that will invoke the onload function.

describe('tet it', () => {

  it('test', done => {
    global.Image = class {
      constructor() {
        setTimeout(() => {
          this.onload(); // simulate success
        }, 100);
      }
    }

    const callback = status => {
      done()
    }

    funcToTest('some_image', callback)
  })
})

The method just assumes that the 'browser' will download the image within 100 ms, you can tweak the code for failure, or move parts of it to beforeEach if you need to share this behavior between tests.

2
votes

The function to be tested:

index.ts:

function funcToTest(imgUrl: string, callback: Function) {
  const img = new Image();
  img.src = imgUrl;

  img.onload = () => {
    callback(true);
  };

  img.onerror = e => {
    callback(false);
    console.log(e);
  };

  return img;
}

export { funcToTest };

  • Unit test solution 1:
/**
 * @jest-environment jsdom
 */

import { funcToTest } from './';

describe('test suites', () => {
  it('onload', done => {
    const callback = jest.fn(status => {
      expect(status).toBe(true);
      done();
    });

    const imageUrl = 'https://github.com/mrdulin';
    const img = funcToTest(imageUrl, callback);
    if (img.onload) {
      const event: any = {};
      img.onload(event);
    }
  });

  it('onerror', done => {
    const consoleLogSpyOn = jest.spyOn(console, 'log');
    const callback = jest.fn(status => {
      expect(status).toBe(false);
      done();
    });

    const imageUrl = 'https://github.com/mrdulin';
    const img = funcToTest(imageUrl, callback);
    if (img.onerror) {
      const event: any = { message: 'some error' };
      img.onerror(event);
      expect(consoleLogSpyOn).toBeCalledWith(event);
    }
    consoleLogSpyOn.mockRestore();
  });
});

Unit test result and coverage:

 PASS  src/stackoverflow/57092154/index.spec.ts
  test suites
    ✓ onload (8ms)
    ✓ onerror (8ms)

  console.log node_modules/jest-mock/build/index.js:860
    { message: 'some error' }

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 index.ts |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.821s

Here is the completed demo: https://github.com/mrdulin/jest-codelab/tree/master/src/stackoverflow/57092154

UPDATE: undeleted answer, unit test solution 2

You can use Object.defineProperty() method to create getter and setter for Image.prototype.onload method. Then, you can get the onload function in your test cases and execute it manually.

index.test.ts:

import { funcToTest } from './';

describe('57092154', () => {
  let onloadRef: Function | undefined;
  let onerrorRef: Function | undefined;
  beforeAll(() => {
    Object.defineProperty(Image.prototype, 'onload', {
      get() {
        return this._onload;
      },
      set(onload: Function) {
        onloadRef = onload;
        this._onload = onload;
      },
    });
    Object.defineProperty(Image.prototype, 'onerror', {
      get() {
        return this._onerror;
      },
      set(onerror: Function) {
        onerrorRef = onerror;
        this._onerror = onerror;
      },
    });
  });
  it('should handle onload event', () => {
    const callback = jest.fn();
    funcToTest('./test.png', callback);
    onloadRef!();
    expect(callback).toBeCalledWith(true);
  });

  it('should handle onerror event', () => {
    const callback = jest.fn();
    const logSpy = jest.spyOn(console, 'log').mockImplementation(() => 'suppress error');
    funcToTest('./test.png', callback);
    const mErr = new Error('network');
    onerrorRef!(mErr);
    expect(callback).toBeCalledWith(false);
    expect(logSpy).toBeCalledWith(mErr);
  });
});

unit test result:

 PASS  examples/57092154/index.test.ts
  57092154
    ✓ should handle onload event (4 ms)
    ✓ should handle onerror event

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 index.ts |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.741 s
1
votes

If you have some logic in Image's onload event handler and want to test if it was applied correctly, you can actually create an image blob programmatically using Canvas.

Consider this function:

const imageDimensions = (
  file: File
): Promise<{ width: number; height: number }> =>
  new Promise((resolve, reject) => {
    const img = new Image()

    img.onload = () => {
      const { naturalWidth: width, naturalHeight: height } = img
      resolve({ width, height })
    }

    img.onerror = () => {
      reject('There is something wrong with this image')
    }

    img.src = URL.createObjectURL(file)
  })`

So given a File (blob) it returns a Promise which resolves to an object with dimensions. Or rejects with an error.

How do we test onload part? You can programmatically create a blob in your tests, but new Blob(['a'.repeat(10)], { type: 'image/jpeg' }) will not trigger onload, since this is not really an image.

No need to mock anything, use JSDOM or anything like that. Canvas to the rescue! This could be tested as simple as that:

describe('imageDimensions', () => {
  it('should resolve with correct dimensions given an IMAGE blob', done => {
    // although this canvas has no actual graphics, it is still an image and it contains image metadata, thus onload() will be fired
    const canvas = document.createElement('canvas')
    canvas.width = 10
    canvas.height = 10

    canvas.toBlob(
      async blob => {
        const { width, height } = await component.imageDimensions(blob)
        expect(width).toBe(10)
        expect(height).toBe(10)
        done()
      },
      'image/jpeg',
      0.1
    )
  })

  it('should reject with an error if the file provided does NOT seem to be an image', async () => {
    const file = new Blob(['a'.repeat(10)], { type: 'application/pdf' })

    try {
      await component.imageDimensions(file)
    } catch (error) {
      expect(error).toBe('There is something wrong with this image')
    }
  })
})

In this example the Canvas is being created and then converted to a blob, which is similar to that being generated by a browser when user has selected some image file.

P.S. this is both Jest and Jasmine compatible.

0
votes

Typescript version of the @Patrick answer:

type ImageConstructor = new (
  width?: number | undefined,
  height?: number | undefined
) => HTMLImageElement;

describe('1234', () => {
  global.Image = class {
    onload: () => void;

    constructor() {
      this.onload = jest.fn();
      setTimeout(() => {
        this.onload();
      }, 50);
    }
  } as unknown as ImageConstructor;

 it("should call onload", () => {})

)};