Angular testing how to prevent ngOnInit call to test a method directly

BlackHoleGalaxy picture BlackHoleGalaxy · Apr 9, 2017 · Viewed 41.7k times · Source

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');
}));

Answer

Estus Flask picture Estus Flask · Apr 9, 2017

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);