Why do I need to call detectChanges / whenStable twice?

yan*_*kee 16 testbed angular angular-test

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']);
    });
});
Run Code Online (Sandbox Code Playgroud)

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

Run Code Online (Sandbox Code Playgroud)

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()
Run Code Online (Sandbox Code Playgroud)

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()
Run Code Online (Sandbox Code Playgroud)

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?

Mar*_*hal 11

我相信你正在经历Delayed change detection

延迟更改检测是有意且有用的。它使测试人员有机会在Angular启动数据绑定和调用生命周期挂钩之前检查和更改组件的状态。

detectChanges()


实施Automatic Change Detection允许您fixture.detectChanges()在两个测试中仅调用一次。

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

Stackblitz

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

这个Automatic Change Detection示例中的注释很重要,并且说明为什么fixture.detectChanges()即使使用,仍然需要调用测试AutoDetect

第二和第三次测试揭示了一个重要的局限性。Angular测试环境不知道该测试更改了组件的标题。ComponentFixtureAutoDetect服务可响应异步活动,例如承诺解析,计时器和DOM事件。但是,组件属性的直接,同步更新是不可见的。该测试必须手动调用夹具.detectChanges()来触发另一个变更检测周期。

由于您在设置Promise时解决它的方式,我怀疑它被视为同步更新,并且Auto Detection Service不会响应它。

component.values = Promise.resolve(['A', 'B']);
Run Code Online (Sandbox Code Playgroud)

自动变化检测


检查给出的各种示例可以提供一个线索,说明为什么您需要fixture.detectChanges()两次调用不带AutoDetect。第一次ngOnInitDelayed change detection模型中触发...第二次调用它会更新视图。

您可以根据fixture.detectChanges()下面的代码示例右侧的注释看到此内容

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');
}));
Run Code Online (Sandbox Code Playgroud)

更多异步测试示例


简介: 当不利用时Automatic change detection,调用fixture.detectChanges()将“逐步”遍历Delayed Change Detection模型...使您有机会在Angular启动数据绑定和调用生命周期挂钩之前检查并更改组件的状态。

另外,请注意提供的链接中的以下注释:

而不是怀疑测试夹具何时将执行或不执行更改检测,本指南中的示例始终始终显式调用detectChanges()。频繁地调用detectChanges()并没有多大危害。


第二个示例Stackblitz

第二个示例stackblitz显示,注释掉第53行会detectChanges()产生相同的console.log输出。detectChanges()之前whenStable()不需要两次调用。您打detectChanges()了三次电话,但是之前的第二个电话whenStable()没有任何影响。detectChanges()在新示例中,您实际上只能从其中的两个中获得任何收益。

频繁地调用detectChanges()并没有多大危害。

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


更新:第二个示例(于2019/03/21再次更新)

提供stackblitz来演示以下变体的不同输出,以供您查看。

  • 等待Fixture.whenStable();
  • Fixture.whenStable()。then(()=> {})
  • 等待Fixture.whenStable()。then(()=> {})

Stackblitz

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


Mac*_*c_W 0

在我看来,第二个测试似乎是错误的,应该按照以下模式编写:

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

请参阅:当使用稳定时

你应该detectChangeswhenStable()as内调用

Fixture.whenStable() 返回一个 Promise,该 Promise 在 JavaScript 引擎的任务队列变空时解析。