Why do I need to call detectChanges / whenStable twice?
Asked Answered
L

4

29

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?

Lolly answered 16/3, 2019 at 10:28 Comment(2)
I had the same thing in many cases in my app and gave up, just added it twice :) Would be interesting to see if anybody solves it here!Burgoo
It seems that it's got something to do with the promise and how the resolve works. Interestingly using a observable rather than the promise you wouldn't need to trigger the detectChanges twice. It would be interesting to know why though. stackblitz.com/edit/directive-testing-1bdxlzTumular
T
26

I believe you are experiencing Delayed change detection.

Delayed change detection is intentional and useful. It gives the tester an opportunity to inspect and change the state of the component before Angular initiates data binding and calls lifecycle hooks.

detectChanges()


Implementing Automatic Change Detection allows you to only call fixture.detectChanges() once in both test.

 beforeEach(async(() => {
            TestBed.configureTestingModule({
                declarations: [TestComponent],
                providers:[{ provide: ComponentFixtureAutoDetect, useValue: true }] //<= SET AUTO HERE
            })
                .compileComponents();
        }));

Stackblitz

https://stackblitz.com/edit/directive-testing-fnjjqj?embed=1&file=app/app.component.spec.ts

This comment in Automatic Change Detection example is important, and why your tests still need to call fixture.detectChanges(), even with AutoDetect.

The second and third test reveal an important limitation. The Angular testing environment does not know that the test changed the component's title. The ComponentFixtureAutoDetect service responds to asynchronous activities such as promise resolution, timers, and DOM events. But a direct, synchronous update of the component property is invisible. The test must call fixture.detectChanges() manually to trigger another cycle of change detection.

Because of the way you are resolving the Promise as you are setting it, I suspect it is being treated as a synchronous update and the Auto Detection Service will not respond to it.

component.values = Promise.resolve(['A', 'B']);

Automatic Change Detection


Inspecting the various examples given provides a clue as to why you need to call fixture.detectChanges() twice without AutoDetect. The first time triggers ngOnInit in the Delayed change detection model... calling it the second time updates the view.

You can see this based on the comments to the right of fixture.detectChanges() in the code example below

it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
  fixture.detectChanges(); // ngOnInit()
  expect(quoteEl.textContent).toBe('...', 'should show placeholder');

  tick(); // flush the observable to get the quote
  fixture.detectChanges(); // update view

  expect(quoteEl.textContent).toBe(testQuote, 'should show quote');
  expect(errorMessage()).toBeNull('should not show error');
}));

More async tests Example


In Summary: When not leveraging Automatic change detection, calling fixture.detectChanges() will "step" through the Delayed Change Detection model... allowing you the opportunity to inspect and change the state of the component before Angular initiates data binding and calls lifecycle hooks.

Also please note the following comment from the provided links:

Rather than wonder when the test fixture will or won't perform change detection, the samples in this guide always call detectChanges() explicitly. There is no harm in calling detectChanges() more often than is strictly necessary.


Second Example Stackblitz

Second example stackblitz showing that commenting out line 53 detectChanges() results in the same console.log output. Calling detectChanges() twice before whenStable() is not necessary. You are calling detectChanges() three times but the second call before whenStable() is not having any impact. You are only truly gaining anything from two of the detectChanges() in your new example.

There is no harm in calling detectChanges() more often than is strictly necessary.

https://stackblitz.com/edit/directive-testing-cwyzrq?embed=1&file=app/app.component.spec.ts


UPDATE: Second example (updated again on 2019/03/21)

Providing stackblitz to demonstrate the different output from the following variants for your review.

  • await fixture.whenStable();
  • fixture.whenStable().then(()=>{})
  • await fixture.whenStable().then(()=>{})

Stackblitz

https://stackblitz.com/edit/directive-testing-b3p5kg?embed=1&file=app/app.component.spec.ts

Taskwork answered 19/3, 2019 at 20:17 Comment(16)
While these information are very good, they don't really answer the question why I need to call these functions TWICE. Of course automatic change detection cannot detect things like property changes, so I need to call detectChanges(), but why twice? In the meantime I did some more research and added a second example to my question.Lolly
Your first call of detectChanges() is NgOninit, your second call of detectChanges() updates the view and initiates data binding. Your additional example illustrates this fact.Taskwork
The first call to detectChanges() which does call NgOnInit is in line 27 (first example) and line 37 (second example) in the beforeEach block. If you are counting that one in, then the question is why I need to call that function three times.Lolly
Comment out detectChanges() in line 53 after "After setting index" line, you will notice your output is the same with or without this detectChanges() line. Calling detectChanges() twice before whenStable() is not doing anything additional, and is unnecessary. You are only truly using detectChanges() twice in your example even though you are calling it three times.Taskwork
Provided second stackblitz example to illustrate commenting out line 53 detectChanges() results in the same console.log output.Taskwork
Thanks for the example. That is correct and actually I can also remove line 61, because it is also not doing anything (I already wrote this in the question). I thought that the second examples helps to figure out what is wrong with the first example. I should clarify that.Lolly
No worries. I definitely agree this is not very intuitive, simply stating in the official docs there is no harm calling detectChanges() as much as you like leaves a lot to be desired. This was my best attempt at providing clarity to your question. I hope it was somewhat helpful, or at a minimum helpful to others in the future. I think the key here is when not using Auto detect, call detectChanges() once for NgOninit, once to update the view and initiate data binding, and anytime after that when you make an explicit synchronous change to a component property required for the view.Taskwork
I got a little further with my own research and revised my second example again. In case that gives you any new ideas...Lolly
Still no change. All this does is change order of execution in a confusion fashion. After all await somePromise; x(); is the same as somePromise.then(() => x());. The only reason why this stackblitz works is because you removed the then(x => x).then(x => x) from the component.Lolly
Line 13 in the function set valueIndexLolly
I see what you mean, I did in fact miss this piece and only copied the it. I have removed my revision and additional comments.Taskwork
Please note: with your complete example in a stackblitz, await somePromise; x(); does not behave the same as somePromise.then(() => x());Taskwork
await fixture.whenStable();, fixture.whenStable().then(()=>{}) and await fixture.whenStable().then(()=>{}) all have a different outcome in your console.log when applied to the diagnoseState('After first whenStable()'); fixture.detectChanges(); does the third variant look correct in the output?Taskwork
provided revised stackblitz for your review of the three variants.Taskwork
await fixture.whenStable().then(() => fixture.detectChanges()}); is in this case equal to await Promise.resolve().then(() => fixture.detectChanges()); which again is equal to await Promise.resolve().then(() => null); fixture.detectChanges(), but is different to await Promise.resolve(); fixture.detectChanges() even if it looks the same. You can use await or you can use then to do something on Promise resolution, but mixing both together will end up in a headache.Lolly
So in reply to "Please note: with your complete example in a stackblitz, await somePromise; x(); does not behave the same as somePromise.then(() => x());", that is correct, because it is not the same. However await somePromise.then(x => null); x(); and somePromise.then(() => x()); are equal.Lolly
C
2

I found this question due to the fact that I spent hours debugging why I needed to write detectChanges / whenStable multiple times in my test cases.

I learnt about "Automatic change detection" (with ComponentFixtureAutoDetect ) from @Marshal 's answer and tried it out. But still couldn't use it because my component has some @Input that are required and used in ngOnInit. Enabling ComponentFixtureAutoDetect causes an error there because as soona s I do TestBed.createComponent() - it ran ngOnInit() causing an error.

I eventually made a helper function to temporarily enable this for a single call using .autoDetectChanges (reference):

async autoWhenStable<C>(fixture: ComponentFixture<C>) {
    fixture.autoDetectChanges(true);
    await fixture.whenStable();
    fixture.autoDetectChanges(false);
}

This seems to help my testcase writing as I did not need to worry about how many times I had to call the whenStable.
And felt a lot better than the hack proposed in most places which just said do it lots of times (as there is no harm in calling it a few extra times):

async robustWhenStable<C>(fixture: ComponentFixture<C>) {
    for (let i = 0; i < 10; i++) {
        fixture.detectChanges();
        await fixture.whenStable();
    }
}
Calipash answered 27/1, 2022 at 5:35 Comment(0)
P
0

In my opinion the second test seems wrong, it should be written following this pattern:

component.values = Promise.resolve(['A', 'B']);
fixture.whenStable().then(() => {
  fixture.detectChanges();       
  expect(Array.from(element.querySelectorAll('li')).map(elem => elem.textContent)).toEqual(['A', 'B']);
});

Please see: When Stable Usage

You should call detectChanges within whenStable() as

The fixture.whenStable() returns a promise that resolves when the JavaScript engine's task queue becomes empty.

Paloma answered 18/3, 2019 at 15:12 Comment(4)
This basically is the same as writing await fixture.whenStable(); fixture.detectChanges(); rather than fixture.detectChanges(); await fixture.whenStable(); and this does not change the outcome of the test.Lolly
So the above example still fails?Paloma
Yes, it does still failLolly
I think your "When Stable Usage" link might have been moved or changed. I can't find anything about whenStable() on that page any longer.Sherri
C
0

I faced this problem when my event handler, which I called from a template, used an async function with more than one await construction. I called fixture.autoDetectChanges() before fixture.whenStable(), and the problem disappeared. To be clear, here's some pseudocode:

component.html:
<button (click)="onClick()"></button>

component.ts:

async onClick() {
const someResult = await firstValueFrom(this.getSomeResult());
const anotherResult = await firstValueFrom(this.getAnotherResult(someResult));

alert(anotherResult);
...
}

component.spec.ts:

it('should ...', () => {
  fixture.autoDetectChanges();

  fixture.nativeElement.querySelector('button').click();

  await fixture.whenStable();
  expect(...);
});

I hope it will help someone, because other solutions haven't helped me

Cockburn answered 5/3 at 9:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.