2

I am having the following error while writing a test: "Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error: Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout."

This is the test:

beforeEach(
    waitForAsync(() => {
      TestBed.configureTestingModule({
        declarations: [DashboardTimerComponent, FormatTimePipe],
        imports: [BrowserAnimationsModule, ReactiveFormsModule],
        providers: [FormBuilder],
      }).compileComponents();
    }),
  );

  beforeEach(() => {
    fixture = TestBed.createComponent(DashboardTimerComponent);
    component = fixture.componentInstance;
  });

  it('should stop counter and emit event', fakeAsync(() => {
    spyOn(component.stopped, 'emit');

    component.stopRequested = true;
    component.runningTimer = { timer: 19 };
    fixture.detectChanges();
    

    const button = fixture.debugElement.nativeElement.querySelector('#stop');
    button.click();

    expect(component.timer).toBeNull();
    expect(component.stopped.emit).toHaveBeenCalled();
  }));

This is the component:

@Component({
  selector: 'dashboard-timer',
  templateUrl: './dashboard-timer.component.html',
  providers: [DashboardTimerService],
  animations: [fadeInAnimation],
})
export class DashboardTimerComponent {
  @Input() projects: any;
  @Input() runningTimer: any = null;

  @Output() started = new EventEmitter();
  @Output() stopped = new EventEmitter();

  public form: FormGroup;

  public timer: number = null;

  public stopRequested: boolean = false;

  public counter: Subscription;

  private project: FormControl = new FormControl('');

  private note: FormControl = new FormControl('');

  constructor(
    private dashboardTimerService: DashboardTimerService,
    private fb: FormBuilder,
  ) {}

  ngOnInit() {
    // initialize form
    this.form = this.fb.group({
      project: this.project,
      note: this.note,
    });

    if (this.runningTimer) {
      this.timer = this.runningTimer.timer;
      this.form.controls['project'].setValue(this.runningTimer.project || '');
      this.form.controls['note'].setValue(this.runningTimer.note || '');

      this.counter = this.dashboardTimerService
        .getCounter()
        .subscribe(() => this.timer++);
    }
  }

  /**
   * check if stop requested, stop counter, emit stop to parent component
   */
  stop(): void {
    if (this.stopRequested === false) {
      this.stopRequested = true;

      setTimeout(() => {
        this.stopRequested = false;
      }, 5000);
      return;
    }
    this.stopRequested = false;

    this.counter.unsubscribe();
    this.stopped.emit();
    this.timer = null;
  }
}

The error seems to be resulting from this service:

import { Injectable } from '@angular/core';
import { timer } from 'rxjs';

@Injectable()
export class DashboardTimerService {
  getCounter() {
    return timer(0, 1000);
  }
}

I suppose the timer is still running, even though I unsubscribe from it in the component.

Any ideas how to solve this are very much appreciated!

Thank you!

Luke
  • 185
  • 1
  • 3
  • 16
  • you saw this? https://stackoverflow.com/a/25273095/3470148 – Raz Ronen Jan 28 '21 at 13:15
  • Thank you. I've been trying to get it running but somehow adding only it('should stop counter and emit event', function(done) { and calling done() in the end doesn't do the trick. – Luke Jan 28 '21 at 13:26
  • How about changing the default 5000 with `, async (done) => { ... }, 100000);` – Raz Ronen Jan 28 '21 at 13:33
  • Unfortunately this also doesn't work: it('should stop counter and emit event', async (done) => { spyOn(component.stopped, 'emit'); component.stopRequested = true; component.runningTimer = { timer: 19 }; fixture.detectChanges(); const button = fixture.debugElement.nativeElement.querySelector('#stop'); button.click(); expect(component.timer).toBeNull(); expect(component.stopped.emit).toHaveBeenCalled(); done(); }, 100000); – Luke Jan 28 '21 at 13:36
  • I would say a much better way to do such tests is to provide a mocked instances of services which component is using. Therefore you could provide an implementation of the service which will return and complete the observable at the same moment, to not wait for anything. Unit tests of the component shouldn't rely on a specific implementation of the service – Maciej Wojcik Jan 31 '21 at 19:12
  • Thank you. I will try to implement my unit tests more "unit based" in future. I have tried to implement this via creating a Stub ``export class DashboardTimerServiceStub { public getCounter() { return 0; } }`` which I use to override in the testbed configuration ``{ provide: DashboardTimerService, useClass: DashboardTimerServiceStub, }``. Unfortunately I still get the time out. – Luke Feb 01 '21 at 09:14
  • Could you provide a stackblitz with your current test case. And I completely agree with @MaciejWójcik that it's better to mock dependend services. If you use `fakeAsync` and you know that there is a fixed timer, you would need to call `tick(1000)` to wait the amount of time and then continue with your test case – Erbsenkoenig Feb 01 '21 at 11:57
  • Hi. I have created a very much stripped down stackblitz here: https://stackblitz.com/edit/angular11-timer-stub-issue?file=src/main.ts Thank you! – Luke Feb 02 '21 at 11:46

1 Answers1

3

Looking at your stackblitz, the component actually provides the service, which means, that the component creates its own instance of the service and does not use the mock value you provided inside the TestBed providers.

So my first question would be, does this service really needs to be provided on the component itself?

If so I see two options:

Since your dataservice uses a timer with 1000 you would actually need to wait exactly that amount of time. I would use a fakeAsync in combination with tick for that.

it("should stop counter and emit event", fakeAsync(() => {
    spyOn(component.stopped, "emit");

    fixture.detectChanges();

    const button = fixture.debugElement.nativeElement.querySelector("#stop");
    button.click();

    tick(1000); // -> wait till you know that the promise will be resolved
    fixture.detectChanges();

    expect(component.timer).toBeNull();
    expect(component.stopped.emit).toHaveBeenCalled();
  }));

The other option would be to actually override the injected service after the component was created and with it the instance of the service.

it("should stop counter and emit event", fakeAsync(() => {
    spyOn(component.stopped, "emit");

    // override the actual injected service with your mock values here
    (<any>TestBed.inject(DashboardTimerService)).getCounter = jest.fn().mockReturnValue(of(0))

    fixture.detectChanges();

    const button = fixture.debugElement.nativeElement.querySelector("#stop");
    button.click();

    tick(); // i would still use fakeAsync and tick, since you are handling 
            // observables, which are still async even if they emit directly
    fixture.detectChanges();

    expect(component.timer).toBeNull();
    expect(component.stopped.emit).toHaveBeenCalled();
  }));
Erbsenkoenig
  • 1,584
  • 14
  • 18
  • Thank you. Your comment and support really helped me to understand better how testing rxjs works. Actually also my solution worked, but I had a test running before the mentioned test. In this test the subscription was never unsubscribed. Therefore I added to get everything running nicely: ``ngOnDestroy() { if (this.counter) { this.counter.unsubscribe(); } }`` – Luke Feb 03 '21 at 13:44
  • Ah ok. Glad to help. Even if it wasn't the actual issue. Yeah unsubscribing is something quite important. I used to have an abstract component which contained a subscription array and handled the unsubscribe for each subscription and just extended every component that handled observables so i didn't forget. – Erbsenkoenig Feb 03 '21 at 14:32