rel1x
rel1x

Reputation: 2441

Test that Angular service have been initialized

I am trying to test my Angular service using Karma-Jasmine and I need to be sure that after service is initialized loadApp function have been called. What is the best way to test it?

import { Injectable, NgZone } from '@angular/core';

@Injectable()
export class GdlService {
  appName = 'myAppName';

  constructor(
    private ngZone: NgZone,
  ) {
    this.ngZone = ngZone;
    this.loadApp(this.appName);
  }


  private loadApp(appName) {
    this.ngZone.runOutsideAngular(() => {
      // ...some logic
    });
  }
}

Upvotes: 6

Views: 6925

Answers (4)

Lars Gyrup Brink Nielsen
Lars Gyrup Brink Nielsen

Reputation: 4105

Testing the private method call in the constructor

Isolated unit tests are considered best practice when testing a service by the Angular Testing Guide, i.e. there is no need for Angular testing utilities.

We cannot test that a method was called from the constructor by spying on an object instance, as the method has already been called once we have a reference to the instance.

Instead, we need to spy on the prototype of the service (thanks, Dave Newton!). When creating methods on a JavaScript class, we are actually creating the methods on the <ClassName>.prototype.

Given this factory for NgZone spies that is based on MockNgZone from Angular testing internals:

import { EventEmitter, NgZone } from '@angular/core';

export function createNgZoneSpy(): NgZone {
  const spy = jasmine.createSpyObj('ngZoneSpy', {
    onStable: new EventEmitter(false),
    run: (fn: Function) => fn(),
    runOutsideAngular: (fn: Function) => fn(),
    simulateZoneExit: () => { this.onStable.emit(null); },
  });

  return spy;
}

We can mock the NgZone dependency to isolate the service in our tests and even describe the outgoing commands that are run outside the zone.

// Straight Jasmine - no imports from Angular test libraries
import { NgZone } from '@angular/core';

import { createNgZoneSpy } from '../test/ng-zone-spy';
import { GdlService } from './gdl.service';

describe('GdlService (isolated unit tests)', () => {
  describe('loadApp', () => {
    const methodUnderTest: string = 'loadApp';
    let ngZone: NgZone;
    let service: GdlService;

    beforeEach(() => {
      spyOn<any>(GdlService.prototype, methodUnderTest).and.callThrough();
      ngZone = createNgZoneSpy();
      service = new GdlService(ngZone);
    });

    it('loads the app once when initialized', () => {
      expect(GdlService.prototype[methodUnderTest]).toHaveBeenCalledWith(service.appName);
      expect(GdlService.prototype[methodUnderTest]).toHaveBeenCalledTimes(1);
    });

    it('runs logic outside the zone when initialized.', () => {
      expect(ngZone.runOutsideAngular).toHaveBeenCalledTimes(1);
    });
  });
});

Normally we would not want to test private methods, but instead observe the public side effects that it makes.

However, we can use Jasmine Spies to achieve what we want.

See full example on StackBlitz

The Angular Service Lifecycle

See examples that demonstrate the Angular Service Lifecycle on StackBlitz. Do read the comments in the hello.*.ts files and open your JavaScript console to see the messages that are output.

Create an Angular StackBlitz with Jasmine tests

Fork my StackBlitz to test Angular with Jasmine just like in this answer

Upvotes: 1

bhantol
bhantol

Reputation: 9616

Increasing the visibility of a member for testing purpose is fine. So for the sake of elegance you may want to make loadApp public for mocking. However trying to mock a private function will come with some tradeoff. @estus is on the right track in answering it:

I have tweaked it a little to modify the prototype using jasmine.createSpy to overwrite the private function.

  it('try to call loadApp', () => {
    GdlService.prototype['loadApp'] = jasmine.createSpy()
      .and
      .callFake((appName) => {
      console.log('loadApp called with ' , appName );
    });
    // spyOn(IEFUserService.prototype, 'loadAppPrivate'); - this does not work because the test breaks right here trying to access private member
    const service = TestBed.get(GdlService);
    expect(service['loadApp']).toHaveBeenCalled();
  });

Upvotes: 1

Estus Flask
Estus Flask

Reputation: 222760

It can be tested as any other function. Considering that loadApp is prototype method, it can be stubbed or spied on class prototype:

it('', () => {
  spyOn(<any>GdlService.prototype, 'loadApp');
  const gdl = TestBed.get(GdlService);
  expect(gdl['loadApp']).toHaveBeenCalledWith('myAppName');
});

Upvotes: 3

Ben
Ben

Reputation: 979

Try mocking the injection for ngZone (I like ts-mockito for this sort of stuff) and then checking to see if ngZone.outsideOfAngular has been called. Due to the nature of typescript, I don't think you'll be able to directly spy on anything that is private comfortably.

Something like this in the test file:

import { GdlService } from 'place';
import { NgZone } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
    anything,
    instance,
    mock,
    verify
} from 'ts-mockito';

describe('yada yada', () => {
    const mockNgZone = mock(NgZone);
    // Can use when(mockNgZone.whatever)... to mock what you need
    beforeEach(() => {
        TestBed.configureTestModule({
            providers: [{
                provide: NgZone,
                useValue: instance(mockNgZone)
            }]
        });
    });

    it('checks on loadApp', () => {
        verify(mockNgZone.runOutsideAngular(anything())).called();
    });
});

If you would prefer to just use the spyOn method instead, you can just replace the object in the useValue portion of the provider.

Upvotes: 3

Related Questions