2

I'm trying to mock the default DOM Image object for unit testing an Angular service.

The service is simple, it checks the "webP" format support :

import {Injectable} from '@angular/core';
import {Promise} from 'es6-promise';
import {environment} from '../../../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class AppLoadService {

  constructor() {
  }

  initializeApp(): Promise<any> {
    return new Promise((resolve) => {
      console.log(`initializeApp:: inside promise`);
      if (typeof Image === 'undefined') {
        console.log(`initializeApp:: Image undefined`);
        resolve();
        return;
      }
      const webP = new Image();
      webP.onload = () => {
        console.log(`initializeApp:: WebP support: true`);
        environment.webP = true;
        resolve();
      };
      webP.onerror = () => {
        console.log(`initializeApp:: WebP support: false`);
        resolve();
      };
      webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
    });
  }
}

I found a way to check the webP support (default in chromium where Karma is running), and a way to check fallback on Image undefined.

But I cannot find a way to checks the onerror fallback...

Here is my spec file :

import {TestBed} from '@angular/core/testing';
import {AppLoadService} from './app-load.service';
import {environment} from '../../../../environments/environment';

describe('AppLoadService', () => {
  let service: AppLoadService;
  const originalImage = Image;

  beforeEach(() => TestBed.configureTestingModule({}));

  beforeEach(() => {
    service = TestBed.get(AppLoadService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should resolve with webP', (done) => {
    const test = () => {
      Image = originalImage;
      service.initializeApp().then(() => {
        expect(environment.webP).toBe(true);
        done();
      });
    };
    test();
  });

  it('should resolve without webP (A)', (done) => {
    const test = () => {
      Image = undefined;
      service.initializeApp().then(() => {
        expect(environment.webP).toBe(false);
        done();
      });
    };
    test();
  });

  it('should resolve without webP (B)', (done) => {
    // How to force Image to throw "onerror" ?
    const test = () => {
      Image = originalImage;
      service.initializeApp().then(() => {
        expect(environment.webP).toBe(false);
        done();
      });
    };
    test();
  });
});

The question is on should resolve without webP (B) test, at the end of the file..

Moreover, is there a better way to check undefined Image object or onload callback ?

Thanks !


EDIT

Can't get it works as it is, so I change the service constructor to provide "Image" dependency.

constructor(@Inject('Image') image: typeof Image) {
  this.image = image;
}

Have to load the module like that :

providers: [
  AppLoadService,
  // [...]
  {provide: 'Image', useValue: Image},
]

And each resolve() now includes environment.webP result. Otherwise, individual test are a real pain, environment is randomly rewritten before being tested.

With a simple Mock it works like this :

import {TestBed} from '@angular/core/testing';
import {AppLoadService} from './app-load.service';

class MockImageFail {
  public onerror: Function;
  private _src: string;

  set src(src) {
    this._src = src;
    if (this.onerror) {
      this.onerror();
    }
  }
}

describe('AppLoadService', () => {

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [{provide: 'Image', useValue: Image}]
    });
  });

  it('should be created', () => {
    const service = TestBed.get(AppLoadService);
    expect(service).toBeTruthy();
  });

  it('should resolve with webP', (done) => {
    const service = TestBed.get(AppLoadService);
    service.initializeApp().then((supportWebP) => {
      expect(supportWebP).toBe(true);
      done();
    });
  });

  it('should not resolve without webP (A)', (done) => {
    TestBed.overrideProvider('Image', {useValue: undefined});
    const service = TestBed.get(AppLoadService);
    service.initializeApp().then((supportWebP) => {
      expect(supportWebP).toBe(false);
      done();
    });
  });

  it('should not resolve without webP (B)', (done) => {
    TestBed.overrideProvider('Image', {useValue: MockImageFail});
    const service = TestBed.get(AppLoadService);
    service.initializeApp().then((supportWebP) => {
      expect(supportWebP).toBe(false);
      done();
    });
  });
});

I'm not really happy with that and I'm sure there is another better way :/

Gonçalo Peres
  • 11,752
  • 3
  • 54
  • 83
Doubidou
  • 1,573
  • 3
  • 18
  • 35

1 Answers1

0

I had a similar need, but I didn't want to create a provider just for Image as I only had the need for it in one place. But I still needed to mock Image to be able to write my specs.

I do have an existing provider for window so I decided to use that instead. I am writing a web app, so my 'global' is actually window. This is according to MDN: "In a web browser, any code which the script doesn't specifically start up as a background task has a Window as its global object. This is the vast majority of JavaScript code on the Web." I changed new Image() to new this.window.Image() and I inject window in my constructor like so:

constructor(@Inject(WINDOW) private readonly window: any) {}

My check then looked like this:

public canLoadImage(uri: string): Promise<boolean> {
  const image = new this.window.Image();
  this.promise = new Promise((resolve) => {
    image.onload = () => resolve(true);
    image.onerror = () => resolve(false);
  });
  image.src = `${uri}?${Math.random()}`;
  return this.promise;
}

So I was able to test this by mocking and injecting window like so:

beforeEach(() => {
  mockImage = {};
  mockWindow = { Image: () => mockImage };
  service = new MyService(mockWindow);
});
 
it('resolves false when youtube is not reachable', fakeAsync(() => {
  const successSpy = jasmine.createSpyObject('success');
  service.canLoadImage('https://example.com').then(successSpy);
  mockImage.onerror();
  flushMicrotasks();

  expect(successSpy).toHaveBeenCalledWith(false);
}));

it('resolves true when youtube is reachable', fakeAsync(() => {
  const successSpy = jasmine.createSpyObject('success');
  service.canLoadImage('https://example.com').then(successSpy);
  mockImage.onload();
  flushMicrotasks();

  expect(successSpy).toHaveBeenCalledWith(true);
}));

Note that you could easily change the code to reject when their is a failure and test that by calling mockImage.onerror() directly.

Note also that you have to create the promise in the same zone within which you call flushMicrotaskt (or tick). Thus why I set up the success spy, call the canLoadImage, and call the mocked image methods in the same fakeAsync method call.

One reason I did this in this way is to avoid an actual external call to fetch the image. Because my Image instance was just an object on which to accumulate effects, I didn't have to worry about it actually loading the image.

nephiw
  • 1,964
  • 18
  • 38