为什么我需要两次调用detectChanges / whenStable?

26

第一个例子

我有以下测试:

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

正如您所看到的,这里有一个非常简单的组件,它只展示了由Promise提供的项目列表。其中有两个测试,一个失败了,一个通过了。唯一的区别是通过测试调用了fixture.detectChanges(); await fixture.whenStable();两次。

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

此示例旨在调查与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()');
    }));
});

使用ngZone显式地运行第一个测试的结果为:

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()

第二个测试日志:

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()

我本以为测试在 Angular 区域运行,但实际上并不是。问题似乎来自于以下事实:

为了避免出现意外情况,即使使用已解析的 Promise,也永远不会同步调用传递给 then() 的函数。(来源

在这个第二个示例中,我通过多次调用.then(x => x)来引发问题,这不会造成太大影响,只会将进度再次放入浏览器的事件循环中,从而延迟结果。据我目前的理解,对await fixture.whenStable()的调用应该基本上是“等待直到队列为空”。正如我们所看到的那样,如果我显式地在 ngZone 中执行代码,那么它确实起作用。然而,这不是默认值,我无法在手册中找到任何关于写测试的方式的说明,因此这感觉很奇怪。

在第二个测试中,await fixture.whenStable()实际上是做什么的?源代码(source code)显示,在这种情况下,fixture.whenStable()只会返回Promise.resolve(false);。因此,我实际上尝试用 await Promise.resolve() 替换了await fixture.whenStable(),确实具有相同的效果:如果我只是对任何 promise 调用足够多次的await,则这会挂起测试并开始处理事件队列,从而执行传递给valuePromise.then(...)的回调函数。

为什么需要多次调用await fixture.whenStable();?我的使用方式是否错误?这是预期的行为吗?有没有关于它如何工作/如何处理这个问题的“官方”文档?


1
我在我的应用程序中有很多类似的情况,最终放弃了,只是添加了两次 :) 很有趣看看是否有人能在这里解决它! - waterplea
似乎与Promise和resolve的工作方式有关。有趣的是,如果使用observable而不是promise,您就不需要触发两次detectChanges。不过为什么会这样还是很有趣的。https://stackblitz.com/edit/directive-testing-1bdxlz - Erbsenkoenig
3个回答

25

我相信你正在经历延迟变更检测。

延迟变更检测是有意而有用的。它为测试人员提供了一个机会,在Angular启动数据绑定和调用生命周期钩子之前,检查和更改组件的状态。

detectChanges()


实现{{自动变更检测}}可以让你在两个测试中仅调用一次fixture.detectChanges()

 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

在“自动更改检测”示例中,这条注释很重要,并且说明即使使用“AutoDetect”,你的测试仍然需要调用“fixture.detectChanges()”。第二个和第三个测试揭示了一个重要的限制。Angular测试环境不知道测试已经更改了组件的标题。ComponentFixtureAutoDetect服务响应异步活动,如Promise解析、计时器和DOM事件。但是直接同步更新组件属性是不可见的。测试必须手动调用fixture.detectChanges()来触发另一个变化检测周期。由于您正在设置Promise时解决它的方式,我怀疑它被视为同步更新,因此“自动检测服务”将不会对其进行响应。
component.values = Promise.resolve(['A', 'B']);

自动变更检测


检查所给的各个示例,可以了解为什么在没有使用AutoDetect的情况下需要调用两次fixture.detectChanges()。第一次触发Delayed change detection模型中的ngOnInit......第二次调用更新视图。

根据下面代码示例中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');
}));

更多异步测试示例


总结: 如果没有利用{{自动变更检测}},调用{{fixture.detectChanges()}}将会“步进”{{延迟变更检测}}模型...这样您就有机会在Angular启动数据绑定和调用生命周期钩子之前检查和更改组件的状态。
此外,请注意提供链接中的以下注释:

与其想知道测试装置何时执行或不执行变更检测,本指南中的示例始终明确调用{{detectChanges()}}。多次调用{{detectChanges()}}并不会造成任何损害,即使超出必要范围。


第二个Stackblitz示例

第二个示例的Stackblitz表明,注释掉第53行的detectChanges()会导致相同的console.log输出。在whenStable()之前调用两次detectChanges()是没有必要的。您正在调用三次detectChanges(),但是在whenStable()之前的第二次调用没有任何影响。你只从新示例中的两个detectChanges()获得了真正的收益。

多次调用detectChanges()不会造成任何损害。

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


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

提供stackblitz以演示以下三种变体的不同输出结果。

  • 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


1
虽然这些信息很好,但它们并没有真正回答我为什么需要调用这些函数两次的问题。当然,自动变更检测无法检测到属性更改之类的事情,因此我需要调用detectChanges(),但为什么要调用两次呢?与此同时,我进行了更多的研究,并在我的问题中添加了第二个示例。 - yankee
1
第一次调用 detectChanges(),它会调用 NgOnInit 函数,分别在第一个示例的第27行和第二个示例的第37行,在 beforeEach 块中。如果您计算其中一个,则问题是为什么我需要调用该函数三次。 - yankee
提供第二个 StackBlitz 示例来说明注释掉第 53 行的 detectChanges() 会导致相同的 console.log 输出。 - Marshal
仍然没有变化。所有这些做的只是以混乱的方式改变执行顺序。毕竟,await somePromise; x();somePromise.then(() => x());是相同的。这个stackblitz之所以工作是因为你从组件中删除了then(x => x).then(x => x) - yankee
请注意:在 Stackblitz 中使用完整的示例时,await somePromise; x(); 的行为与 somePromise.then(() => x()); 不同。 - Marshal
显示剩余11条评论

1
我发现这个问题是因为我花了几个小时调试,弄清楚为什么我需要在我的测试用例中多次编写 detectChanges / whenStable
我从 @Marshal 的答案中了解到了 "自动更改检测"(使用 ComponentFixtureAutoDetect ),并尝试了一下。但是,由于我的组件有一些必需的且在 ngOnInit 中使用的 @Input,因此无法使用它。启用 ComponentFixtureAutoDetect 会导致错误,因为一旦我执行 TestBed.createComponent(),它就会运行 ngOnInit() 导致错误。
最终,我创建了一个帮助函数,使用 .autoDetectChanges参考)暂时启用单个调用。
async autoWhenStable<C>(fixture: ComponentFixture<C>) {
    fixture.autoDetectChanges(true);
    await fixture.whenStable();
    fixture.autoDetectChanges(false);
}

这似乎有助于我的测试用例编写,因为我不需要担心需要调用whenStable的次数。
与大多数地方提出的hack(只需多次调用它并没有什么坏处)相比,感觉好多了:

async robustWhenStable<C>(fixture: ComponentFixture<C>) {
    for (let i = 0; i < 10; i++) {
        fixture.detectChanges();
        await fixture.whenStable();
    }
}

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

请查看:稳定使用时 你应该在whenStable()中调用detectChanges

fixture.whenStable()返回一个Promise,当JavaScript引擎的任务队列变为空时,它会被解决。


1
这基本上与编写 await fixture.whenStable(); fixture.detectChanges(); 而不是 fixture.detectChanges(); await fixture.whenStable(); 相同,而且这不会改变测试的结果。 - yankee
那么上面的例子还是失败了吗? - Mac_W
是的,它仍然失败了。 - yankee
我认为你的“当稳定使用”链接可能已经被移动或更改了。我在那个页面上再也找不到关于whenStable()的任何信息了。 - jmrah

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接