Angular тестирование, как предотвратить вызов ngOnInit для непосредственного тестирования метода

Контекст

У меня есть компонент. Внутри него функция ngOnInit вызывает другую функцию компонента для извлечения списка пользователей. Я хочу сделать две серии тэгов:

  • Первый тест: ngOnInit запускается правильно и заполняет список пользователей
  • Во второй раз я хочу проверить свою функцию обновления, которая также вызывает getUserList()

Первый тест с триггером ngOnInit при вызове fixture.detectChanges() работает правильно.

Проблема

Моя проблема заключается в тестировании функции обновления: как только я вызываю fixture.detectChanges(), запускается ngOnInit, а затем я не могу узнать, откуда пришли мои результаты, и если моя функция refresh() будет проверена правильно.

Есть ли какой-либо путь перед моей второй серией тестов по методу refresh(), чтобы "удалить" или "заблокировать" ngOnInit(), чтобы он не вызывал fixture.detectChanges()?

Я попытался посмотреть overrideComponent, но, похоже, он не позволяет удалить ngOnInit().

Или есть способ обнаружить изменения, кроме использования fixture.detectChanges в моем случае?

код

Вот код для компонента, службы-заглушки и моих файлов-спецификаций.

Компонент

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

Файл спецификации компонента

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

Сервис-заглушка (при необходимости)

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"
          }
        ]
      }
    ]);
  }
}

Мои испытания

Я попробовал некоторое "обходное решение", но я обнаружил, что это немного... многословный и, возможно, перебор!

Например:

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

Ответ 1

Предотвращение вызова крюка жизненного цикла (ngOnInit) является неправильным направлением. Проблема состоит из двух возможных причин. Либо тест недостаточно изолирован, либо стратегия тестирования неверна.

Angular руководство довольно конкретное и упрямое в тестовой изоляции:

Однако часто более полезно исследовать внутреннюю логику классов приложений с изолированными модульными тестами, которые не зависят от Angular. Такие тесты часто меньше и легче читать, писать и поддерживать.

Таким образом, отдельные тесты просто должны создать экземпляр класса и протестировать его методы

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

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

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

У изолированных тестов есть недостаток - они не тестируют DI, а тесты TestBed. В зависимости от точки зрения и стратегии тестирования отдельные тесты можно рассматривать как единичные тесты, а тесты TestBed можно считать функциональными. И хороший набор тестов может содержать оба.

В приведенном выше коде should get the user List via refresh function тест, очевидно, является функциональным тестом, он рассматривает экземпляр компонента как черный ящик.

Для заполнения пробела можно добавить пару тестов TestBed, они, вероятно, будут достаточно прочными, чтобы не беспокоиться об изолированных тестах (хотя последние, безусловно, более точны):

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

Ответ 2

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

Пример плунжера