36

Context

I have a component. Inside of it, the ngOnInit function calls another function of component to retrieve user List. I want to make two series of tets:

  • First test the ngOnInit is triggered properly and populate the user list
  • In a second time I want to test my refresh function which also call getUserList()

The first test, with ngOnInit trigger, when I call fixture.detectChanges() works properly.

Problem

My problem is when testing the refresh function: as soon as I call fixture.detectChanges(), ngOnInit is triggered and then I am unable to know where my results come from and if my refresh() function will be tested properly.

Is there any way, before my second series of tests on refresh() method, to "delete" or "block" the ngOnInit() so it's not called on fixture.detectChanges()?

I tried to look at overrideComponent but it seems it doesn't allow to delete ngOnInit().

Or is there any way to detect changes other than using fixture.detectChanges in my case?

Code

Here is the code for component, stub service and my spec files.

Component

import { Component, OnInit, ViewContainerRef } from '@angular/core';

import { UserManagementService } from '../../shared/services/global.api';
import { UserListItemComponent } from './user-list-item.component';

@Component({
  selector: 'app-user-list',
  templateUrl: './user-list.component.html'
})
export class UserListComponent implements OnInit {
  public userList = [];

  constructor(
    private _userManagementService: UserManagementService,    
  ) { }

  ngOnInit() {
    this.getUserList();
  }

  onRefreshUserList() {
    this.getUserList();
  }

  getUserList(notifyWhenComplete = false) {
    this._userManagementService.getListUsers().subscribe(
      result => {
        this.userList = result.objects;
      },
      error => {
        console.error(error);        
      },
      () => {
        if (notifyWhenComplete) {
          console.info('Notification');
        }
      }
    );
  }
}

Component spec file

import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
  async,
  fakeAsync,
  ComponentFixture,
  TestBed,
  tick,
  inject
} from '@angular/core/testing';

import { Observable } from 'rxjs/Observable';

// Components
import { UserListComponent } from './user-list.component';

// Services
import { UserManagementService } from '../../shared/services/global.api';
import { UserManagementServiceStub } from '../../testing/services/global.api.stub';

let comp:    UserListComponent;
let fixture: ComponentFixture<UserListComponent>;
let service: UserManagementService;

describe('UserListComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [UserListComponent],
      imports: [],
      providers: [
        {
          provide: UserManagementService,
          useClass: UserManagementServiceStub
        }
      ],
      schemas: [ NO_ERRORS_SCHEMA ]
    })
    .compileComponents();
  }));

  tests();
});

function tests() {
  beforeEach(() => {
    fixture = TestBed.createComponent(UserListComponent);
    comp = fixture.componentInstance;

    service = TestBed.get(UserManagementService);
  });

  it(`should be initialized`, () => {
    expect(fixture).toBeDefined();
    expect(comp).toBeDefined();
  });

  it(`should NOT have any user in list before ngOnInit`, () => {
    expect(comp.userList.length).toBe(0, 'user list is empty before init');
  });

  it(`should get the user List after ngOnInit`, async(() => {
    fixture.detectChanges(); // This triggers the ngOnInit and thus the getUserList() method

    // Works perfectly. ngOnInit was triggered and my list is OK
    expect(comp.userList.length).toBe(3, 'user list exists after init');
  }));

  it(`should get the user List via refresh function`, fakeAsync(() => {
    comp.onRefreshUserList(); // Can be commented, the test will pass because of ngOnInit trigger
    tick();

    // This triggers the ngOnInit which ALSO call getUserList()
    // so my result can come from getUserList() method called from both source: onRefreshUserList() AND through ngOnInit().
    fixture.detectChanges(); 

    // If I comment the first line, the expectation is met because ngOnInit was triggered!    
    expect(comp.userList.length).toBe(3, 'user list after function call');
  }));
}

Stub service (if needed)

import { Observable } from 'rxjs/Observable';

export class UserManagementServiceStub {
  getListUsers() {
    return Observable.from([      
      {
        count: 3, 
        objects: 
        [
          {
            id: "7f5a6610-f59b-4cd7-b649-1ea3cf72347f",
            name: "user 1",
            group: "any"
          },
          {
            id: "d6f54c29-810e-43d8-8083-0712d1c412a3",
            name: "user 2",
            group: "any"
          },
          {
            id: "2874f506-009a-4af8-8ca5-f6e6ba1824cb", 
            name: "user 3",
            group: "any"
          }
        ]
      }
    ]);
  }
}

My trials

I tried some "workaround" but I found it to be a little.... verbose and maybe overkill!

For example:

it(`should get the user List via refresh function`, fakeAsync(() => {
    expect(comp.userList.length).toBe(0, 'user list must be empty');

    // Here ngOnInit is called, so I override the result from onInit
    fixture.detectChanges();
    expect(comp.userList.length).toBe(3, 'ngOnInit');

    comp.userList = [];
    fixture.detectChanges();
    expect(comp.userList.length).toBe(0, 'ngOnInit');

    // Then call the refresh function
    comp.onRefreshUserList(true);
    tick();
    fixture.detectChanges();

    expect(comp.userList.length).toBe(3, 'user list after function call');
}));
BlackHoleGalaxy
  • 9,160
  • 17
  • 59
  • 103
  • you cannot prevent ngOnInit becauase the moment you create a component instance, this is triggered and you need to create a component instance to write test cases – Aravind Apr 09 '17 at 12:51
  • It would be better to have a more controllable stub; that way you can control what data it returns each time it's called, so that you know the data should be different the second time. You could use a `Subject` to allow you to push new data for the subscribers, either locally or with additional test methods on the stub, or spy on the method `.and.returnValue` whatever you like. – jonrsharpe Apr 09 '17 at 12:57
  • I'm quite uncomfortable with spy but it seems a good solution to inject a different returnValue on my two series of test, maybe by setting the spy in two differents beforeEach. Do you have any example on how to achieve this? – BlackHoleGalaxy Apr 09 '17 at 12:59

3 Answers3

38

Preventing lifecycle hook (ngOnInit) from being called is a wrong direction. The problem has two possible causes. Either the test isn't isolated enough, or testing strategy is wrong.

Angular guide is quite specific and opinionated on test isolation:

However, it's often more productive to explore the inner logic of application classes with isolated unit tests that don't depend upon Angular. Such tests are often smaller and easier to read, write, and maintain.

So isolated tests just should instantiate a class and test its methods

userManagementService = new UserManagementServiceStub;
comp = new UserListComponent(userManagementService);
spyOn(comp, 'getUserList');

...
comp.ngOnInit();
expect(comp.getUserList).toHaveBeenCalled();

...
comp.onRefreshUserList();
expect(comp.getUserList).toHaveBeenCalled();

Isolated tests have a shortcoming - they don't test DI, while TestBed tests do. Depending on the point of view and testing strategy, isolated tests can be considered unit tests, and TestBed tests can be considered functional tests. And a good test suite can contain both.

In the code above should get the user List via refresh function test is obviously a functional test, it treats component instance as a blackbox.

A couple of TestBed unit tests can be added to fill the gap, they probably will be solid enough to not bother with isolated tests (although the latter are surely more precise):

spyOn(comp, 'getUserList');

comp.onRefreshUserList();
expect(comp.getUserList).toHaveBeenCalledTimes(1);

...

spyOn(comp, 'getUserList');
spyOn(comp, 'ngOnInit').and.callThrough();

tick();
fixture.detectChanges(); 

expect(comp.ngOnInit).toHaveBeenCalled();
expect(comp.getUserList).toHaveBeenCalledTimes(1);
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • 1
    This isn't really a satisfactory answer because trying to test his code in isolation is exactly what he's trying to do. What he's saying is that ngOnInit is causing action-at-a-distance when he is trying to test other functions of his component independently. – Kirk Sefchik Aug 25 '21 at 20:55
  • 1
    @KirkSefchik Thanks for the argumented downvote, but the answer actually covers testing in isolation, see *So isolated tests just should instantiate a class and test its methods*. The question doesn't try to test functions independently, it still tests behaviour and relies on component lifecycle in *should get the user List via refresh function*, and mocking arbitrary part of a lifecycle is wrong test strategy. Unless `getUserList` belongs public API, it shouldn't necessarily be distinguished from other private methods and can be considered a part of a component as a single unit. – Estus Flask Aug 26 '21 at 06:10
15
it(`should get the user List via refresh function`, fakeAsync(() => {
  let ngOnInitFn = UserListComponent.prototype.ngOnInit;
  UserListComponent.prototype.ngOnInit = () => {} // override ngOnInit
  comp.onRefreshUserList();
  tick();

  fixture.detectChanges(); 
  UserListComponent.prototype.ngOnInit = ngOnInitFn; // revert ngOnInit

  expect(comp.userList.length).toBe(3, 'user list after function call');
}));

Plunker Example

yurzui
  • 205,937
  • 32
  • 433
  • 399
10

I personally prefer cancelling the component ngOnInit for every test.

beforeEach(() => {
    UserListComponent.prototype.ngOnInit = () => {} ;
   ....
  });
Joundill
  • 6,828
  • 12
  • 36
  • 50
Arco Voltaico
  • 860
  • 13
  • 29