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.
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');
});
}));
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);
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');
});
}));