Testing Angular2 components that use setInterval o

2020-03-01 07:19发布

问题:

I have a fairly typical, simple ng2 component that calls a service to get some data (carousel items). It also uses setInterval to auto-switch carousel slides in the UI every n seconds. It works just fine, but when running Jasmine tests I get the error: "Cannot use setInterval from within an async test zone".

I tried wrapping the setInterval call in this.zone.runOutsideAngular(() => {...}), but the error remained. I would've thought changing the test to run in fakeAsync zone would solve the problem, but then I get an error saying XHR calls are not allowed from within fakeAsync test zone (which does make sense).

How can I use both the XHR calls made by the service and the interval, while still being able to test the component? I'm using ng2 rc4, project generated by angular-cli. Many thanks in advance.

My code from the component:

constructor(private carouselService: CarouselService) {
}

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

And from the Jasmine spec:

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:

Clean code is testable code. setInterval is sometimes difficult to test because the timing is never perfect. You should abstract the setTimeout into a service that you can mock out for the test. In the mock you can have controls to handle each tick of the interval. For example

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

With the MockIntervalService you can now control each tick, which is so much more easy to reason about during testing. There's also a spy to check that the clearInterval method is called when the component is destroyed.

For your CarouselService, since it is also asynchronous, please see this post for a good solution.

Below is a complete example (using RC 6) using the previously mentioned services.

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:

I had the same problem: specifically, getting this errror when a third party service was calling setInterval() from a test:

Error: Cannot use setInterval from within an async zone test.

You can mock out the calls, but that is not always desirable, since you may actually want to test the interaction with another module.

I solved it in my case by just using Jasmine's (>=2.0) async support instead of Angulars's 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.")
});


回答3:

What about using the Observable? https://github.com/angular/angular/issues/6539 To test them you should use the .toPromise() method