angulartypescriptjestjsrxjs

Tap doesn't trigger after debounceTime when using fakeAsync in jest/angular test


I'm trying to test this flow:

Angular component:

export class AppComponent implements OnInit {
    loading = true;

    data$?: Observable<string>;

    readonly control = new FormControl<string>('DATA', {nonNullable: true})
    readonly DEBOUNCE_MS = 500;

    ngOnInit(): void {
        this.data$ = this.control.valueChanges.pipe(
            startWith(this.control.value),
            tap((x) => {
                this.loading = true;
            }),
            debounceTime(this.DEBOUNCE_MS),
            tap((x) => {
                this.loading = false;
            }),
        );
    }
}

Template:

@let data = data$ | async ;

@if (!loading) {
    <div data-testing-id="data">{{ data }}</div>
}

Test:

    it(`with fake async`, fakeAsync(() => { // This will fail
        fixture.detectChanges();
        tick(component.DEBOUNCE_MS + 10);
        fixture.detectChanges();

        expect(pageObject.data).toBeTruthy();
    }));

    it(`with fake async and whenStable`, fakeAsync(() => { // This works
        fixture.detectChanges();

        expect(component.loading).toEqual(true);

        fixture.whenStable().then(() => {
            expect(component.loading).toEqual(false);
            expect(pageObject.data.nativeElement.textContent).toEqual('DATA');
        });
    }));

    it(`with real async`, async () => { // This works too
        fixture.detectChanges();

        await realDelay(component.DEBOUNCE_MS);
        fixture.detectChanges();

        expect(pageObject.data).toBeTruthy();
    });

If I remove debounceTime everythins works, of course. Otherwise I can't reach second tap while debugging and test conditions are always falsy.

UPDATE: I've updated the code according to @naren-murali answer and also created stackblitz example. I thought fakeAsync and timer are intended to solve issues like that, but they don't. We also can simulate real timer by awaiting Promise, but it seems to be "crutch" solution.

I would appreciate if anyone can explain why tick is not enough to handle this.


Solution

  • We can use autoDetectChanges() when testing async pipe.

    beforeEach(() => {
      fixture = TestBed.createComponent(App);
      component = fixture.componentInstance;
      componentDe = fixture.debugElement;
      fixture.autoDetectChanges(); // <- important
    });
    

    We can use fixture.whenStable to wait for the DOM changes to be completed. Then inside the callback place our testing code.

    Note that I am calling component.ngOnInit() manually so that the code is executed inside the test case only.

    it('test', fakeAsync(() => {
      component.ngOnInit();
      expect(component.loading).toBeTruthy();
      fixture.detectChanges();
    
      fixture.whenStable().then(() => {
        expect(component.loading).toBeFalsy(); // this is still true
        expect(
          fixture.debugElement.query(By.css('[data-testing-id="test"]'))
            .nativeElement.textContent
        ).toContain('qwerty');
      });
    }));
    

    Full Code:

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import 'zone.js';
    import { MyInputComponent } from './my-input-component/my-input.component';
    import { FormsModule } from '@angular/forms';
    import { AsyncPipe } from '@angular/common';
    import {
      Observable,
      Subject,
      startWith,
      tap,
      debounceTime,
      switchMap,
      of,
    } from 'rxjs';
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [AsyncPipe, FormsModule],
      template: `
            @let test = test$ | async;
    
            @if (!loading) {
              <div data-testing-id="test">{{ test }}</div>
            }
          `,
    })
    export class App {
      test$?: Observable<string>;
      loading = false;
    
      ngOnInit() {
        this.loading = true;
        this.test$ = new Subject<string>().pipe(
          startWith('1'),
          debounceTime(500),
          // here can be request like
          switchMap((value) => {
            return of('qwerty');
          }),
          tap(() => {
            this.loading = false; // this won't get triggered in test
          })
        );
      }
    }
    
    bootstrapApplication(App);
    

    testing:

    import {
      ComponentFixture,
      fakeAsync,
      TestBed,
      flush,
      tick,
      flushMicrotasks,
    } from '@angular/core/testing';
    
    import { MyInputComponent } from './my-input.component';
    import { Component, DebugElement } from '@angular/core';
    import { By } from '@angular/platform-browser';
    import { FormsModule } from '@angular/forms';
    import { App } from '../main';
    
    describe('MyInputComponent', () => {
      let component: App;
      let fixture: ComponentFixture<App>;
      let componentDe: DebugElement;
    
      beforeEach(async () => {
        await TestBed.configureTestingModule({
          imports: [App],
        });
      });
    
        beforeEach(() => {
          fixture = TestBed.createComponent(App);
          component = fixture.componentInstance;
          componentDe = fixture.debugElement;
          fixture.detectChanges(); // <- important
        });
    
        it('test', fakeAsync(() => {
          fixture = TestBed.createComponent(App);
          component = fixture.componentInstance;
          componentDe = fixture.debugElement;
          fixture.autoDetectChanges(); // <- important
          component.ngOnInit();
          expect(component.loading).toBeTruthy();
          fixture.detectChanges();
    
          fixture.whenStable().then(() => {
            expect(component.loading).toBeFalsy(); // this is still true
            expect(
              fixture.debugElement.query(By.css('[data-testing-id="test"]'))
                .nativeElement.textContent
            ).toContain('qwerty');
          });
        }));
    });
    

    If you do not like calling ngOnInit inside the test case, you can change your code as below.

    beforeEach(() => {
      fixture = TestBed.createComponent(App);
      component = fixture.componentInstance;
      componentDe = fixture.debugElement;
      fixture.detectChanges(); // <- important
    });
    
    it('test', fakeAsync(() => {
      fixture = TestBed.createComponent(App);
      component = fixture.componentInstance;
      componentDe = fixture.debugElement;
      fixture.autoDetectChanges(); // <- important
      component.ngOnInit();
      expect(component.loading).toBeTruthy();
      fixture.detectChanges();
    
      fixture.whenStable().then(() => {
        expect(component.loading).toBeFalsy(); // this is still true
        expect(
          fixture.debugElement.query(By.css('[data-testing-id="test"]'))
            .nativeElement.textContent
        ).toContain('qwerty');
      });
    }));
    

    Stackblitz Demo