Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why do I need to call detectChanges / whenStable twice?

Tags:

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?

like image 228
yankee Avatar asked Mar 16 '19 10:03

yankee


1 Answers

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

like image 60
Marshal Avatar answered Oct 19 '22 11:10

Marshal