18

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;
    });
}));
Siimo Raba
  • 183
  • 1
  • 1
  • 5

3 Answers3

14

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();
  });
});
Community
  • 1
  • 1
Paul Samsotha
  • 205,037
  • 37
  • 486
  • 720
  • 1
    Brilliant, this is exactly what I needed to know. One thing that I needed to change was in MockIntervalService: "callback;" to "callback() { }". Otherwise it'll result in "TypeError: this.callback is not a function". Many thanks! – Siimo Raba Oct 21 '16 at 11:54
  • 1
    This is a great idea and works great. It would make more sense to make a service that matches setTimeout and setInterval arguments instead of having them swapped. – jsgoupil Dec 03 '16 at 00:02
  • Also you cannot have two intervals with that code since you may lose track of their IDs if you need to clear them later. For that I would let the IntervalService just to be a copy of the real methods. – Dunos Apr 20 '17 at 10:46
3

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.")
});
spinkus
  • 7,694
  • 4
  • 38
  • 62
  • This is only a solution if you're not also using third-party test helpers (like [Angular Material testbed harness](https://material.angular.io/guide/using-component-harnesses)) that rely on `ComponentFixture#whenStable`. – Coderer Oct 14 '20 at 12:02
1

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

Jacopo Beschi
  • 178
  • 2
  • 6
  • 1
    I'd like to confirm indeed for those who are writing new code, Observable.timer() / Observable.interval() can be the preferred option. In any case there will still be situations where setTimeout/setInterval need to be dealt with e.g. imported libraries. – Siimo Raba Dec 15 '16 at 10:46
  • I'm seeing a very similar problem -- `fixture.whenStable()` never resolves -- regardless of whether I use `Observable.interval` or `setInterval`. The fixture should be stable between intervals, right? But it's never considered stable at all. – Coderer Oct 12 '20 at 13:04