First example
I have got the following test:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
@Component({
template: '<ul><li *ngFor="let state of values | async">{{state}}</li></ul>'
})
export class TestComponent {
values: Promise<string[]>;
}
describe('TestComponent', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let element: HTMLElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TestComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
element = (<HTMLElement>fixture.nativeElement);
});
it('this test fails', async() => {
// execution
component.values = Promise.resolve(['A', 'B']);
fixture.detectChanges();
await fixture.whenStable();
// evaluation
expect(Array.from(element.querySelectorAll('li')).map(elem => elem.textContent)).toEqual(['A', 'B']);
});
it('this test works', async() => {
// execution
component.values = Promise.resolve(['A', 'B']);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
// evaluation
expect(Array.from(element.querySelectorAll('li')).map(elem => elem.textContent)).toEqual(['A', 'B']);
});
});
As you can see there is a super simple component, which just displays a list of items that are provided by a Promise
. There are two tests, one which fails and one which passes. The only difference between those tests is that the test that passed calls fixture.detectChanges(); await fixture.whenStable();
twice.
UPDATE: Second example (updated again on 2019/03/21)
This example attempts to investigate into possible relations with ngZone:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { Component, NgZone } from '@angular/core';
@Component({
template: '{{value}}'
})
export class TestComponent {
valuePromise: Promise<ReadonlyArray<string>>;
value: string = '-';
set valueIndex(id: number) {
this.valuePromise.then(x => x).then(x => x).then(states => {
this.value = states[id];
console.log(`value set ${this.value}. In angular zone? ${NgZone.isInAngularZone()}`);
});
}
}
describe('TestComponent', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [TestComponent],
providers: [
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
function diagnoseState(msg) {
console.log(`Content: ${(fixture.nativeElement as HTMLElement).textContent}, value: ${component.value}, isStable: ${fixture.isStable()} # ${msg}`);
}
it('using ngZone', async() => {
// setup
diagnoseState('Before test');
fixture.ngZone.run(() => {
component.valuePromise = Promise.resolve(['a', 'b']);
// execution
component.valueIndex = 1;
});
diagnoseState('After ngZone.run()');
await fixture.whenStable();
diagnoseState('After first whenStable()');
fixture.detectChanges();
diagnoseState('After first detectChanges()');
});
it('not using ngZone', async(async() => {
// setup
diagnoseState('Before setup');
component.valuePromise = Promise.resolve(['a', 'b']);
// execution
component.valueIndex = 1;
await fixture.whenStable();
diagnoseState('After first whenStable()');
fixture.detectChanges();
diagnoseState('After first detectChanges()');
await fixture.whenStable();
diagnoseState('After second whenStable()');
fixture.detectChanges();
diagnoseState('After second detectChanges()');
await fixture.whenStable();
diagnoseState('After third whenStable()');
fixture.detectChanges();
diagnoseState('After third detectChanges()');
}));
});
This first of these tests (explicitly using ngZone) results in:
Content: -, value: -, isStable: true # Before test
Content: -, value: -, isStable: false # After ngZone.run()
value set b. In angular zone? true
Content: -, value: b, isStable: true # After first whenStable()
Content: b, value: b, isStable: true # After first detectChanges()
The second test logs:
Content: -, value: -, isStable: true # Before setup
Content: -, value: -, isStable: true # After first whenStable()
Content: -, value: -, isStable: true # After first detectChanges()
Content: -, value: -, isStable: true # After second whenStable()
Content: -, value: -, isStable: true # After second detectChanges()
value set b. In angular zone? false
Content: -, value: b, isStable: true # After third whenStable()
Content: b, value: b, isStable: true # After third detectChanges()
I kind of expected that the test runs in the angular zone, but it does not. The problem seems to come from the fact that
To avoid surprises, functions passed to then() will never be called synchronously, even with an already-resolved promise. (Source)
In this second example I provoked the problem by calling .then(x => x)
multiple times, which will do no more than putting the progress again into the browser's event loop and thus delaying the result. In my understanding so far the call to await fixture.whenStable()
should basically say "wait until that queue is empty". As we can see this actually works if I execute the code in ngZone explicitly. However this is not the default and I cannot find anywhere in the manual that it is intended that I write my tests that way, so this feels awkward.
What does await fixture.whenStable()
actually do in the second test?. The source code shows that in this case fixture.whenStable()
will just return Promise.resolve(false);
. So I actually tried to replace await fixture.whenStable()
with await Promise.resolve()
and indeed it has the same effect: This does have an effect of suspending the test and commence with the event queue and thus the callback passed to valuePromise.then(...)
is actually executed, if I just call await
on any promise at all often enough.
Why do I need to call await fixture.whenStable();
multiple times? Am I using it wrong? Is it this intended behaviour? Is there any "official" documentation about how it is intended to work/how to deal with this?