javascriptangularjestjsrxjs

Angular - How to unit test an RxJS timer used with AsyncPipe


I am unsuccessful in writing a unit test for an RxJS timer that is displayed in the template with an AsyncPipe.

Component:

import { Component } from '@angular/core';
import { AsyncPipe, NgIf } from '@angular/common';
import { map, timer } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    Timer should fire after {{ delay }} seconds:
    <ng-container *ngIf="timer$ | async as timer; else waiting">Timer fired {{ timer }} times</ng-container>
    <ng-template #waiting>Waiting for Timer…</ng-template>
  `,
  imports: [AsyncPipe, NgIf],
})
export class AppComponent {
  delay = 3;
  timer$ = timer(this.delay * 1000, 1000).pipe(map((n) => n + 1));
}

Test:

import { AppComponent } from './main';
import {
  ComponentFixture,
  TestBed,
} from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';

describe('AppComponent', () => {
  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should initially render waiting', () => {
    expect(fixture.nativeElement.textContent).toContain('Waiting'); //passes
  });

  it('should emit timer', async () => {
    expect(await firstValueFrom(component.timer$)).toBe(1); //passes
  });

  it('should show "Timer fired"', () => {
    jest.useFakeTimers();
    jest.advanceTimersByTime(5000);

    expect(fixture.nativeElement.textContent).toContain('Timer fired'); //fails
  });
});

Stackblitz: https://stackblitz.com/edit/angular-rxjs-timer-asyncpipe-unit-test?file=src%2Fmain.ts,src%2Ftest.spec.ts

The most similar question here on StackOverflow is this one: Angular testing async pipe does not trigger the observable Unfortunately, the solution described in the most upvoted answer does not solve the problem.


Solution

  • jest.useFakeTimers() with jest.advanceTimersByTime() seems to be the only solution. (Or jasmine.clock().install() with jasmine.clock().tick()).

    The test setup has to include jest.useFakeTimers() before the first fixture.detectChanges() call (because this is the moment that the async pipe subscribes to the timer - the underlying setInterval has to already be mocked at that point):

    beforeEach(async () => {
      await TestBed.configureTestingModule({
        imports: [AppComponent],
      }).compileComponents();
    
      jest.useFakeTimers();
    
      fixture = TestBed.createComponent(AppComponent);
      component = fixture.componentInstance;
      fixture.detectChanges();
    });
    

    The test then looks like this:

    it('should show "Timer fired"', () => {
      jest.advanceTimersByTime(3000);
      fixture.detectChanges();
      expect(fixture.nativeElement.textContent).toContain('Timer fired');
      expect(fixture.nativeElement.textContent).not.toContain('Waiting');
    });
    

    My experiences with fakeAsync and the async pipe match those Barry McNamara describes in https://stackoverflow.com/a/59164109/4715712

    I updated my original StackBlitz with more tests. They also include a test that unfortunately disproves Naren Muralis original answer (which I at first was convinced is correct even though my gut instinct told me it doesn't work).

    Stackblitz Demo: https://stackblitz.com/edit/angular-rxjs-timer-asyncpipe-unit-test?file=src%2Fmain.ts,src%2Ftest.spec.ts