6

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
    });
  }
}
rel1x
  • 2,351
  • 4
  • 34
  • 62
  • 1
    Don’t. You shouldn’t unit test private behavior and certainly shouldn’t mock a private method. – Pace Jan 24 '18 at 13:25
  • @Pace Why? The fact that it's private means that it doesn't belong to public interface. It has nothing to do with testing methodology. – Estus Flask Jan 24 '18 at 20:02
  • It's [admittedly controversial](https://stackoverflow.com/questions/34571/how-do-i-test-a-private-function-or-a-class-that-has-private-methods-fields-or) but I think *Pragmatic Unit Testing* puts it best, "Most of the time, you should be able to test a class by exercising its public methods. If there is significant functionality that is hidden behind private or protected access, that might be a warning sign that there's another class in there struggling to get out." I've yet to find the exception in my own work. – Pace Jan 24 '18 at 20:41
  • *you should be able to test a class by exercising its public method*, yes, that's the point. If you're unable to do that without private members, this means that design went wrong. Accessing private members is supposed to strengthen tests - and also make them fragile, in a good way. Following a breadcrumb of spy calls may speed up problem solving in failed tests a lot. – Estus Flask Jan 24 '18 at 21:56

4 Answers4

3

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');
});
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Hi! thank you, i've tried it but got `Expected spy loadApp to have been called`. – rel1x Dec 29 '17 at 12:50
  • using `toHaveBeenCalledWith ` it throws `Expected spy loadApp to have been called with [ 'myAppName' ] but it was never called.` – rel1x Dec 29 '17 at 13:16
  • TestBed.get(GdlService) should instantiate the service and call the method. If it was instantiated previously (in beforeEach `inject` or else), the test will fail. Given that `GdlService` wasn't instantiated before `TestBed.get(GdlService)`, I would expect the code above to work. – Estus Flask Dec 29 '17 at 13:28
  • I used exactly same order without any changes and I've got `Argument of type '"loadApp"' is not assignable to parameter of type '"sendEvent" | "appName"'.` is another private method of my service and `appName` is a string variable that contains a name of app – rel1x Dec 29 '17 at 13:51
  • Then it should be `expect(gdl['loadApp'])`. – Estus Flask Dec 29 '17 at 13:53
  • https://www.dropbox.com/s/yaff4n9byxv4rgt/Screen%20Shot%202017-12-29%20at%2014.54.30.png?dl=0 – rel1x Dec 29 '17 at 13:55
  • But there is the error, it's highlighted with red line and and in terminal I see `Argument of type '"loadApp"' is not assignable to parameter of type '"sendEvent" | "appName"'.` – rel1x Dec 29 '17 at 14:29
  • I'm not sure what's going on there. There's a possibility that Jasmine is affected by type system in some way I'm not aware of (while it shouldn't), so the problem is specific to your setup. See this for example https://github.com/angular/protractor/issues/4176 , the problem can be specific to @types/jasmine version. If it's really `spyOn` type check that causes the problem, consider disabling type checking, like `spyOn(GdlService['prototype'], 'loadApp')` or `spyOn(GdlService.prototype, 'loadApp')`. Any way, this problem a bit out of the scope of original question. – Estus Flask Dec 29 '17 at 15:04
  • 1
    Use `spyOn(GdlService.prototype, 'loadApp');` to clear the error. The `loadApp` method is private. `spyOn` uses `keyof` to prevent typos, but `keyof` does not work with private members. – Lars Gyrup Brink Nielsen Jan 24 '18 at 12:50
  • @LarsGyrupBrinkNielsen [Jasmine 1 types](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/jasmine/v1/index.d.ts) don't have this problem. This is specific to [Jasmine 2 types](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/jasmine/index.d.ts) (which aren't documented), not to Jasmine itself. I guess the OP came across this problem. Any way, I assume this is essentially same thing as `spyOn(GdlService.prototype, 'loadApp')` which was suggested above. – Estus Flask Jan 24 '18 at 12:59
  • You are probably right, @estus. However, Jasmine version 2 is the one used ever since Angular version 2 was released. – Lars Gyrup Brink Nielsen Jan 24 '18 at 15:15
3

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.

Ben
  • 960
  • 8
  • 17
  • *I don't think you'll be able to directly spy on anything that is private* - it's certainly possible. This is considered *reflection*, there are several acknowledged ways to do that. – Estus Flask Jan 24 '18 at 20:07
  • _anything that is private **comfortably**_. And I amended my answer to reflect that. – Ben Jan 24 '18 at 21:39
  • `gdl['loadApp']` and `(gdl).loadApp` are comfortable and totally valid ways when being used not for hacking but for reflection purposes. `Reflect.get(gdl, 'loadApp')` is [more cumbersome but also descriptive way to do that](https://stackoverflow.com/a/45117687/3731501). – Estus Flask Jan 24 '18 at 21:47
  • Doing something like that generally forfeits all the protection of the typing system and is a bit fragile. Reaching for reflection should be the last choice when all others are exhausted. – Ben Jan 24 '18 at 21:50
  • See [the comment](https://stackoverflow.com/questions/48022326/test-that-angular-service-have-been-initialized/48363303?noredirect=1#comment83855747_48022326). Yes, it's certainly fragile, but I consider this a good thing. A red test that was too rigid is much better than a green one that was too loose. This is an option, not a choice that should be made. The answer is correct on that ngZone should be spied/stubbed. I consider asserting *both* loadApp and runOutsideAngular calls a win-win situation. If something breaks, this can be deduced just from failed assertions with no debugging. – Estus Flask Jan 24 '18 at 22:04
1

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();
  });
bhantol
  • 9,368
  • 7
  • 44
  • 81
  • `GdlService.prototype['loadApp'] = ` is a dangerous way to spy or stub methods. It won't be cleaned up after a test, while `spyOn` will. It requires to save original method and restore it manually in `afterEach` – Estus Flask Jan 24 '18 at 13:03
  • But given private method other variants give errors e.g. `spyOn(GdlService.prototype, 'loadApp');`. The other option is to off course make loadApp public. – bhantol Jan 24 '18 at 14:49
  • `spyOn(GdlService.prototype, 'loadApp')` shouldn't give errors. It's expected to work, but there was no feedback from the OP . – Estus Flask Jan 24 '18 at 14:52
  • Your are right. It does not generate compilation error. – bhantol Jan 24 '18 at 14:57
1

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

Community
  • 1
  • 1
Lars Gyrup Brink Nielsen
  • 3,939
  • 2
  • 34
  • 35