Тестирование Angular2 компонентов, которые используют setInterval или setTimeout

У меня есть довольно типичный простой компонент ng2, который вызывает службу для получения некоторых данных (элементов карусели). Он также использует setInterval для автоматического переключения карусельных слайдов в пользовательском интерфейсе каждые n секунд. Он работает нормально, но при запуске тестов Jasmine я получаю сообщение об ошибке: "Нельзя использовать setInterval из зоны тестирования асинхронного вызова".

Я попытался обернуть вызов setInterval в this.zone.runOutsideAngular(() = > {...}), но ошибка осталась. Я бы подумал, что изменение теста для запуска в fakeAsync-зоне решит проблему, но затем я получаю сообщение об ошибке, когда XHR-вызовы не допускаются из тестовой зоны fakeAsync (что имеет смысл).

Как я могу использовать как вызовы XHR, сделанные службой, так и интервал, при этом все еще можно проверить компонент? Я использую ng2 rc4, проект, созданный angular -cli. Большое спасибо заранее.

Мой код из компонента:

constructor(private carouselService: CarouselService) {
}

ngOnInit() {
    this.carouselService.getItems().subscribe(items => { 
        this.items = items; 
    });
    this.interval = setInterval(() => { 
        this.forward();
    }, this.intervalMs);
}

И из спецификации Жасмин:

it('should display carousel items', async(() => {
    testComponentBuilder
        .overrideProviders(CarouselComponent, [provide(CarouselService, { useClass: CarouselServiceMock })])
        .createAsync(CarouselComponent).then((fixture: ComponentFixture<CarouselComponent>) => {
            fixture.detectChanges();
            let compiled = fixture.debugElement.nativeElement;
            // some expectations here;
    });
}));

Ответ 1

Чистый код - это тестируемый код. setInterval иногда трудно проверить, потому что время никогда не бывает идеальным. Вы должны абстрагировать setTimeout в службу, которую вы можете высмеять для теста. В макете вы можете иметь элементы управления для каждого тика интервала. Например

class IntervalService {
  interval;

  setInterval(time: number, callback: () => void) {
    this.interval = setInterval(callback, time);
  }

  clearInterval() {
    clearInterval(this.interval);
  }
}

class MockIntervalService {
  callback;

  clearInterval = jasmine.createSpy('clearInterval');

  setInterval(time: number, callback: () => void): any {
    this.callback = callback;
    return null;
  }

  tick() {
    this.callback();
  }
}

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

Для вашего CarouselService, так как он также асинхронный, см. этот пост для хорошего решения.

Ниже приведен полный пример (с использованием RC 6) с использованием ранее упомянутых служб.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TestBed } from '@angular/core/testing';

class IntervalService {
  interval;

  setInterval(time: number, callback: () => void) {
    this.interval = setInterval(callback, time);
  }

  clearInterval() {
    clearInterval(this.interval);
  }
}

class MockIntervalService {
  callback;

  clearInterval = jasmine.createSpy('clearInterval');

  setInterval(time: number, callback: () => void): any {
    this.callback = callback;
    return null;
  }

  tick() {
    this.callback();
  }
}

@Component({
  template: '<span *ngIf="value">{{ value }}</span>',
})
class TestComponent implements OnInit, OnDestroy {
  value;

  constructor(private _intervalService: IntervalService) {}

  ngOnInit() {
    let counter = 0;
    this._intervalService.setInterval(1000, () => {
      this.value = ++counter;
    });
  }

  ngOnDestroy() {
    this._intervalService.clearInterval();
  }
}

describe('component: TestComponent', () => {
  let mockIntervalService: MockIntervalService;

  beforeEach(() => {
    mockIntervalService = new MockIntervalService();
    TestBed.configureTestingModule({
      imports: [ CommonModule ],
      declarations: [ TestComponent ],
      providers: [
        { provide: IntervalService, useValue: mockIntervalService }
      ]
    });
  });

  it('should set the value on each tick', () => {
    let fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
    let el = fixture.debugElement.nativeElement;
    expect(el.querySelector('span')).toBeNull();

    mockIntervalService.tick();
    fixture.detectChanges();
    expect(el.innerHTML).toContain('1');

    mockIntervalService.tick();
    fixture.detectChanges();
    expect(el.innerHTML).toContain('2');
  });

  it('should clear the interval when component is destroyed', () => {
    let fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
    fixture.destroy();
    expect(mockIntervalService.clearInterval).toHaveBeenCalled();
  });
});

Ответ 2

У меня была та же проблема: в частности, получение этого обмана, когда служба третьей стороны вызывала setInterval() из теста:

Ошибка: нельзя использовать setInterval из теста асинхронной зоны.

Вы можете высмеивать вызовы, но это не всегда желательно, так как вы действительно можете протестировать взаимодействие с другим модулем.

Я решил это в моем случае, просто используя Jasmine ( >= 2.0) асинхронная поддержка вместо Angulars async():

it('Test MyAsyncService', (done) => {
  var myService = new MyAsyncService()
  myService.find().timeout(1000).toPromise() // find() returns Observable.
    .then((m: any) => { console.warn(m); done(); })
    .catch((e: any) => { console.warn('An error occured: ' + e); done(); })
  console.warn("End of test.")
});